From b84dd4082c3748231524b46303394e6db16e0a37 Mon Sep 17 00:00:00 2001 From: sakuradairong Date: Mon, 1 Jun 2026 14:43:05 +0800 Subject: [PATCH] feat: add project config files and restic sub-module extraction --- .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 83 ++++++++ .../gitnexus/gitnexus-debugging/SKILL.md | 89 ++++++++ .../gitnexus/gitnexus-exploring/SKILL.md | 78 +++++++ .../skills/gitnexus/gitnexus-guide/SKILL.md | 64 ++++++ .../gitnexus-impact-analysis/SKILL.md | 97 +++++++++ .../gitnexus/gitnexus-refactoring/SKILL.md | 121 +++++++++++ .codegraph/.gitignore | 16 ++ .gitignore | 17 ++ AGENTS.md | 43 ++++ CLAUDE.md | 43 ++++ .../backup/RemoteSyncManager.kt | 196 ++++++++++++++++++ .../androidbackupgui/backup/ResticBackup.kt | 86 ++++++++ .../backup/ResticCommandRunner.kt | 131 ++++++++++++ .../backup/ResticEnvResolver.kt | 45 ++++ .../backup/ResticMaintenance.kt | 96 +++++++++ .../androidbackupgui/backup/ResticRepoInit.kt | 68 ++++++ .../androidbackupgui/backup/ResticRestore.kt | 101 +++++++++ .../backup/ResticSnapshotOps.kt | 100 +++++++++ 18 files changed, 1474 insertions(+) create mode 100644 .claude/skills/gitnexus/gitnexus-cli/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-debugging/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-exploring/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-guide/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md create mode 100644 .codegraph/.gitignore create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/ResticEnvResolver.kt create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt create mode 100644 app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 0000000..cd9a83b --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,83 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +All commands work via `npx` — no global install required. + +## Commands + +### analyze — Build or refresh the index + +```bash +npx gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. + +### status — Check index freshness + +```bash +npx gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +npx gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +npx gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +npx gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 0000000..9510b97 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] gitnexus_query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code related to error: + +``` +gitnexus_query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**gitnexus_context** — full context for a suspect: + +``` +gitnexus_context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. gitnexus_query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. gitnexus_context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 0000000..927a4e4 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. gitnexus_query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. gitnexus_context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 0000000..937ac73 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 0000000..e19af28 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. gitnexus_impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 0000000..f48cc01 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | gitnexus_query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. gitnexus_detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb35a25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Android / Gradle +.gradle/ +app/build/ +local.properties + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Keystore (regenerate if needed) +debug.keystore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a59511a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **android-backup-gui** (773 symbols, 2066 relationships, 66 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/android-backup-gui/context` | Codebase overview, check index freshness | +| `gitnexus://repo/android-backup-gui/clusters` | All functional areas | +| `gitnexus://repo/android-backup-gui/processes` | All execution flows | +| `gitnexus://repo/android-backup-gui/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a59511a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **android-backup-gui** (773 symbols, 2066 relationships, 66 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/android-backup-gui/context` | Codebase overview, check index freshness | +| `gitnexus://repo/android-backup-gui/clusters` | All functional areas | +| `gitnexus://repo/android-backup-gui/processes` | All execution flows | +| `gitnexus://repo/android-backup-gui/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt b/app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt new file mode 100644 index 0000000..d3a9284 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/RemoteSyncManager.kt @@ -0,0 +1,196 @@ +package com.example.androidbackupgui.backup + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +/** + * Manages remote transport lifecycle (SMB/WebDAV) and local temp repo sync. + * + * For SMB/WebDAV backends, restic runs against a local temp directory; + * [RemoteTransport] syncs files to/from the remote backend. + * + * All sync operations are serialized via [repoSyncMutex] so concurrent + * operations don't corrupt the local temp repo. + */ +class RemoteSyncManager { + + private val TAG = "ResticWrapper" + + /** Local temp directory used as restic repo for SMB/WebDAV backends. */ + @Volatile + var tempRepoDir: String = "" + + /** Domain for SMB NTLM authentication. */ + @Volatile + var backendDomain: String = "" + + // ── Transport cache ────────────────────────────────── + @Volatile private var transport: RemoteTransport? = null + private var transportConfigKey: String = "" + private val transportLock = Any() + + /** Serializes access to tempRepoDir so concurrent operations don't corrupt each other. */ + private val repoSyncMutex = Mutex() + + // ── Transport lifecycle ────────────────────────────── + + private fun ensureTransport( + backend: String, url: String, user: String, pass: String, share: String, repoPath: String + ): RemoteTransport? = synchronized(transportLock) { + val key = "$backend|$url|$user|$pass|$share|$backendDomain|$repoPath" + if (key != transportConfigKey || transport == null) { + transport?.let { Log.i(TAG, "transport config changed ($transportConfigKey -> $key), recreating") } + // Clear local temp repo when backend config changes so + // syncFromRemote downloads fresh data from the new backend + if (transportConfigKey.isNotEmpty() && tempRepoDir.isNotEmpty()) { + val dir = File(tempRepoDir) + val deleted = dir.deleteRecursively() + Log.i(TAG, "cleared local temp repo: $tempRepoDir (deleted=$deleted)") + dir.mkdirs() + } + transport = RemoteTransport.create(backend, url, user, pass, share, backendDomain) + if (transport != null) { + transportConfigKey = key + Log.i(TAG, "transport created: $backend @ $url repo=$repoPath domain=$backendDomain") + } else { + Log.e(TAG, "transport creation failed for backend=$backend url=$url") + } + } + return transport + } + + // ── Temp dir lifecycle ─────────────────────────────── + + /** Clean up local temp repo and cache directories. */ + private fun cleanupTempDirs() { + if (tempRepoDir.isEmpty()) return + try { + val repoDir = File(tempRepoDir) + if (repoDir.exists()) { + val deleted = repoDir.deleteRecursively() + Log.i(TAG, "cleanupTempDirs: deleted $tempRepoDir ($deleted)") + } + val cacheDir = File(tempRepoDir.substringBeforeLast("/") + "/restic_cache") + if (cacheDir.exists()) { + val deleted = cacheDir.deleteRecursively() + Log.i(TAG, "cleanupTempDirs: deleted cache $cacheDir ($deleted)") + } + } catch (e: Exception) { + Log.w(TAG, "cleanupTempDirs failed: ${e.message}") + } + } + + /** True if [tempRepoDir] already contains an initialized restic repository (has a config file). */ + private fun isLocalRepoPopulated(): Boolean { + if (tempRepoDir.isEmpty()) return false + return File(tempRepoDir, "config").isFile + } + + // ── Sync engine ────────────────────────────────────── + + /** + * Execute [action] with remote repo synced before/after as needed. + * For local/rest-server backends, executes [action] directly without sync. + * Protected by [repoSyncMutex] so concurrent operations don't corrupt tempRepoDir. + * + * Cleanup strategy: + * - Write ops (needsUpload=true): cleanup only on successful sync to remote. + * On syncToRemote failure the local repo is preserved so the next + * operation can retry — destroying it would lose the just-created snapshot. + * - Read-only ops (needsUpload=false): keep local cache for subsequent operations. + * - Read-only ops skip download entirely if local repo is already populated. + */ + suspend fun withRemoteSync( + backend: String, + backendUrl: String, + backendUser: String, + backendPass: String, + backendShare: String, + repoPath: String, + needsDownload: Boolean, + needsUpload: Boolean, + onProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + action: suspend () -> Result + ): Result { + if (backend != "smb" && backend != "webdav") return action() + + return repoSyncMutex.withLock { + var shouldCleanup = false + try { + val t = ensureTransport(backend, backendUrl, backendUser, backendPass, backendShare, repoPath) + ?: return@withLock Result.failure(Exception("Failed to create transport for backend: $backend")) + + val localDir = File(tempRepoDir) + + val emitProgress: suspend (RemoteTransport.TransferProgress) -> Unit = { p -> + withContext(Dispatchers.Main) { onProgress(p) } + } + + // Write ops always download to avoid overwriting remote changes. + // Read-only ops skip download if local repo is already present. + val actualDownload = needsDownload && (needsUpload || !isLocalRepoPopulated()) + if (actualDownload) { + Log.i(TAG, "syncFromRemote start: $repoPath -> $tempRepoDir") + val syncResult = RemoteTransport.syncFromRemote(t, localDir, repoPath, emitProgress, onByteProgress) + if (syncResult.isFailure) { + shouldCleanup = true + Log.e(TAG, "syncFromRemote FAILED: ${syncResult.exceptionOrNull()?.message}") + return@withLock Result.failure( + Exception("syncFromRemote failed: ${syncResult.exceptionOrNull()?.message}") + ) + } + Log.i(TAG, "syncFromRemote complete") + } else if (needsDownload) { + Log.i(TAG, "syncFromRemote skipped: local repo already populated") + } + + val result = action() + + if (needsUpload && result.isSuccess) { + Log.i(TAG, "syncToRemote start: $tempRepoDir -> $repoPath") + val uploadResult = RemoteTransport.syncToRemote(t, localDir, repoPath, emitProgress, onByteProgress) + if (uploadResult.isFailure) { + shouldCleanup = false // PRESERVE local repo — snapshot would be lost + Log.e(TAG, "syncToRemote FAILED: ${uploadResult.exceptionOrNull()?.message} — local repo preserved for retry") + return@withLock Result.failure( + Exception("syncToRemote failed: ${uploadResult.exceptionOrNull()?.message}") + ) + } + Log.i(TAG, "syncToRemote complete") + shouldCleanup = true + } else if (result.isFailure) { + shouldCleanup = true + } + + result + } catch (e: CancellationException) { + shouldCleanup = true + throw e + } catch (e: Exception) { + shouldCleanup = true + Result.failure(e) + } finally { + if (shouldCleanup) { + Log.i(TAG, "withRemoteSync: cleaning up temp dirs") + cleanupTempDirs() + } else { + Log.d(TAG, "withRemoteSync: keeping local repo for subsequent ops") + } + } + } + } + + /** + * Public safety-net cleanup called by fragment lifecycle. + * Waits for any in-progress operation to finish, then deletes temp dirs. + */ + suspend fun cleanup() { + repoSyncMutex.withLock { cleanupTempDirs() } + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt new file mode 100644 index 0000000..d1ddf21 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticBackup.kt @@ -0,0 +1,86 @@ +package com.example.androidbackupgui.backup + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlin.coroutines.coroutineContext + +/** Shared Json instance configured for restic's snake_case output via @SerialName. */ +private val resticJson = Json { ignoreUnknownKeys = true } + +/** + * Backup operations: running restic backup and parsing its summary output. + * + * Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and + * [RemoteSyncManager] which are shared across sub-modules. + */ +class ResticBackup( + private val runner: ResticCommandRunner, + private val envResolver: ResticEnvResolver, + private val syncManager: RemoteSyncManager +) { + private val TAG = "ResticWrapper" + + // ── Backup ───────────────────────────────────────── + + suspend fun backup( + repoPath: String, + password: String, + paths: List, + tags: List = emptyList(), + hostname: String? = null, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + onProgress: suspend (ResticWrapper.ResticProgress) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + val emit: suspend (ResticWrapper.ResticProgress) -> Unit = { p -> withContext(Dispatchers.Main) { onProgress(p) } } + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = true, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val args = mutableListOf("backup", "--json") + for (path in paths) args.add(path) + for (tag in tags) { args.add("--tag"); args.add(tag) } + if (hostname != null) { args.add("--host"); args.add(hostname) } + + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runResticStreaming(env, args) { line -> + if (!coroutineContext.isActive) return@runResticStreaming + try { + val progress = resticJson.decodeFromString(line) + if (progress.messageType == "status") emit(progress) + } catch (_: Exception) { /* ignore non-JSON lines */ } + } + + if (result.exitCode != 0) { + return@withRemoteSync Result.failure(Exception("restic backup failed: ${result.stderr}")) + } + + parseBackupSummary(result.stdout) + } + } + + // ── Internal helpers ─────────────────────────────── + + /** Parse the JSON summary from the end of restic backup output. */ + private fun parseBackupSummary(stdout: String): Result { + val lines = stdout.lines() + for (i in lines.indices.reversed()) { + val line = lines[i].trim() + if (!line.startsWith("{")) continue + try { + val summary = resticJson.decodeFromString(line) + if (summary.snapshotId.isNotEmpty()) return Result.success(summary) + } catch (_: Exception) { /* keep looking */ } + } + return Result.failure(Exception("No summary found in restic output")) + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt new file mode 100644 index 0000000..19f2831 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticCommandRunner.kt @@ -0,0 +1,131 @@ +package com.example.androidbackupgui.backup + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import kotlin.coroutines.coroutineContext +import kotlinx.serialization.Serializable + +/** + * Manages restic binary process execution. + * Holds the binary path and provides blocking and streaming execution. + */ +class ResticCommandRunner { + + private val TAG = "ResticWrapper" + + /** Path to the restic binary. Default assumes it's on PATH (e.g. Termux). */ + var binaryPath: String = "restic" + + @Serializable + data class CommandResult( + val stdout: String, + val stderr: String, + val exitCode: Int + ) + + /** Build the full command list to run restic. */ + fun buildCommandArgs(args: List): List { + val cmd = listOf(binaryPath) + args + Log.d(TAG, "buildCommandArgs: binaryPath=$binaryPath args=$args → cmd=$cmd") + return cmd + } + + /** Run restic (non-streaming). */ + fun runRestic(env: Map, args: List): CommandResult { + val cmdArgs = buildCommandArgs(args) + Log.i(TAG, "runRestic cmd=${cmdArgs.joinToString(" ")}") + Log.d(TAG, "runRestic REPOSITORY=${env["RESTIC_REPOSITORY"]}") + + return try { + val pb = ProcessBuilder(cmdArgs) + pb.environment().putAll(env) + pb.redirectErrorStream(false) + val process = pb.start() + + val stderrText = StringBuilder() + val stderrThread = Thread({ + try { + process.errorStream.bufferedReader().use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + Log.d(TAG, "restic stderr: $line") + stderrText.appendLine(line) + } + } + } catch (_: Exception) {} + }, "restic-stderr").apply { isDaemon = true; start() } + + val stdout = process.inputStream.bufferedReader().use(BufferedReader::readText) + val exitCode = process.waitFor() + stderrThread.join(5000) + Log.i(TAG, "runRestic exitCode=$exitCode stdout_len=${stdout.length}") + if (stderrText.isNotEmpty()) Log.w(TAG, "runRestic stderr: ${stderrText}") + CommandResult(stdout.trim(), stderrText.toString().trim(), exitCode) + } catch (e: Exception) { + Log.e(TAG, "runRestic exception", e) + CommandResult("", e.message ?: "Unknown error", -1) + } + } + + /** Run restic with single-string args. */ + fun runRestic(env: Map, vararg args: String): CommandResult { + return runRestic(env, args.toList()) + } + + /** Run restic, calling onLine for each stdout line (for streaming progress). */ + suspend fun runResticStreaming( + env: Map, + args: List, + onLine: suspend (String) -> Unit + ): CommandResult = withContext(Dispatchers.IO) { + val cmdArgs = buildCommandArgs(args) + Log.i(TAG, "runResticStreaming cmd=${cmdArgs.joinToString(" ")}") + Log.d(TAG, "runResticStreaming REPOSITORY=${env["RESTIC_REPOSITORY"]}") + + var process: Process? = null + try { + val pb = ProcessBuilder(cmdArgs) + pb.environment().putAll(env) + pb.redirectErrorStream(false) + process = pb.start() + + val stdoutText = StringBuilder() + val reader = process.inputStream.bufferedReader() + val stderrReader = process.errorStream.bufferedReader() + + val stderrText = StringBuilder() + val stderrThread = Thread({ + try { stderrReader.use { stderrText.append(it.readText()) } } catch (_: Exception) {} + }, "restic-stderr").apply { isDaemon = true; start() } + + try { + var line: String? + while (reader.readLine().also { line = it } != null) { + if (!coroutineContext.isActive) { + process.destroy() + break + } + val l = line!! + stdoutText.appendLine(l) + onLine(l) + } + } finally { + try { reader.close() } catch (_: Exception) {} + } + + stderrThread.join(5000) + val exitCode = try { process.waitFor() } catch (_: Exception) { -1 } + + Log.i(TAG, "runResticStreaming exitCode=$exitCode stdout_len=${stdoutText.length}") + if (stderrText.isNotEmpty()) Log.w(TAG, "runResticStreaming stderr: ${stderrText}") + CommandResult(stdoutText.toString().trim(), stderrText.toString().trim(), exitCode) + } catch (e: Exception) { + Log.e(TAG, "runResticStreaming exception", e) + try { process?.destroy() } catch (_: Exception) {} + CommandResult("", e.message ?: "Unknown error", -1) + } + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticEnvResolver.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticEnvResolver.kt new file mode 100644 index 0000000..0c492b0 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticEnvResolver.kt @@ -0,0 +1,45 @@ +package com.example.androidbackupgui.backup + +/** + * Stateless helper for constructing restic environment variables and repo URLs. + */ +class ResticEnvResolver { + + /** Build environment for restic. For SMB/WebDAV backends, uses local temp dir as repo. */ + fun buildFullEnv( + repoPath: String, + password: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + tempRepoDir: String = "" + ): Map { + val env = HashMap(System.getenv() ?: emptyMap()) + env["RESTIC_REPOSITORY"] = if (backend == "smb" || backend == "webdav") { + tempRepoDir + } else { + buildRepoUrl(backend, repoPath, backendUrl) + } + env["RESTIC_PASSWORD"] = password + // Provide a cache directory on Android (no $HOME by default) + if (tempRepoDir.isNotEmpty()) { + val cacheDir = tempRepoDir.substringBeforeLast("/") + "/restic_cache" + env["HOME"] = cacheDir + env["XDG_CACHE_HOME"] = cacheDir + } + return env + } + + /** Build a display-friendly repository URL for UI. */ + fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String { + return when (backend) { + "local" -> repoPath + "rest-server" -> "rest:${backendUrl.trimEnd('/')}/$repoPath" + "webdav" -> "${backendUrl.trimEnd('/')}/$repoPath" + "smb" -> "smb:${backendUrl.trimEnd('/')}/$repoPath" + else -> repoPath + } + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt new file mode 100644 index 0000000..168513b --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticMaintenance.kt @@ -0,0 +1,96 @@ +package com.example.androidbackupgui.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Repository maintenance operations: prune, check, stats. + * + * [prune] requires both download and upload (it removes pack files from the remote). + * [check] and [stats] are download-only read operations. + * Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and + * [RemoteSyncManager] which are shared across sub-modules. + */ +class ResticMaintenance( + private val runner: ResticCommandRunner, + private val envResolver: ResticEnvResolver, + private val syncManager: RemoteSyncManager +) { + // ── Prune ────────────────────────────────────────── + + suspend fun prune( + repoPath: String, + password: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + ): Result = + withContext(Dispatchers.IO) { + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = true, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runRestic(env, "prune") + if (result.exitCode == 0) Result.success(result.stdout) + else Result.failure(Exception("restic prune failed: ${result.stderr}")) + } + } + + // ── Check ────────────────────────────────────────── + + suspend fun check( + repoPath: String, + password: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + ): Result = + withContext(Dispatchers.IO) { + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = false, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runRestic(env, "check") + if (result.exitCode == 0) Result.success(result.stdout) + else Result.failure(Exception("restic check failed: ${result.stderr}")) + } + } + + // ── Stats ────────────────────────────────────────── + + suspend fun stats( + repoPath: String, + password: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + ): Result = + withContext(Dispatchers.IO) { + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = false, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runRestic(env, "stats") + if (result.exitCode == 0) Result.success(result.stdout) + else Result.failure(Exception("restic stats failed: ${result.stderr}")) + } + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt new file mode 100644 index 0000000..b166fde --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticRepoInit.kt @@ -0,0 +1,68 @@ +package com.example.androidbackupgui.backup + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Repository lifecycle operations: init and repo URL construction. + * + * Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and + * [RemoteSyncManager] which are shared across sub-modules. + */ +class ResticRepoInit( + private val runner: ResticCommandRunner, + private val envResolver: ResticEnvResolver, + private val syncManager: RemoteSyncManager +) { + private val TAG = "ResticWrapper" + + // ── Repository initialization ────────────────────── + + suspend fun init( + repoPath: String, + password: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + ): Result = + withContext(Dispatchers.IO) { + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = true, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runRestic(env, "init") + // exitCode 0 = brand new repo created, needs upload + if (result.exitCode == 0) { + return@withRemoteSync Result.success(Unit) + } + // exitCode 1 = config already exists; verify the repo is actually usable + if (result.exitCode == 1) { + val verify = runner.runRestic(env, "snapshots", "--json") + if (verify.exitCode == 0) { + // Repo is healthy — already initialized with matching password + Log.i(TAG, "init: repo already initialized and verified") + return@withRemoteSync Result.success(Unit) + } + // Config exists but repo is corrupted (wrong password, missing keys, etc.) + return@withRemoteSync Result.failure( + Exception("仓库已存在但无法验证: ${verify.stderr.ifEmpty { "密码错误或密钥缺失" }}。请删除远端仓库后重试。") + ) + } + Result.failure(Exception("restic init failed: ${result.stderr}")) + } + } + + // ── Public URL helper ────────────────────────────── + + /** Build a display-friendly repository URL for UI. */ + fun buildRepoUrl(backend: String, repoPath: String, backendUrl: String): String { + return envResolver.buildRepoUrl(backend, repoPath, backendUrl) + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt new file mode 100644 index 0000000..21e6765 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticRestore.kt @@ -0,0 +1,101 @@ +package com.example.androidbackupgui.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.File +import kotlinx.serialization.json.Json +import kotlin.coroutines.coroutineContext + +/** Shared Json instance configured for restic's snake_case output via @SerialName. */ +private val resticJson = Json { ignoreUnknownKeys = true } + +/** + * Restore operations: full directory restore and single-file dump. + * + * Both are download-only operations (no upload to remote needed). + * Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and + * [RemoteSyncManager] which are shared across sub-modules. + */ +class ResticRestore( + private val runner: ResticCommandRunner, + private val envResolver: ResticEnvResolver, + private val syncManager: RemoteSyncManager +) { + // ── Restore ──────────────────────────────────────── + + suspend fun restore( + repoPath: String, + password: String, + snapshotId: String, + targetPath: String, + include: String? = null, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + onProgress: suspend (String) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + val emit: suspend (String) -> Unit = { s -> withContext(Dispatchers.Main) { onProgress(s) } } + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = false, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + File(targetPath).mkdirs() + + val args = mutableListOf("restore", snapshotId, "--target", targetPath, "--json") + if (include != null) { args.add("--include"); args.add(include) } + + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runResticStreaming(env, args) { line -> + if (!coroutineContext.isActive) return@runResticStreaming + try { + val progress = resticJson.decodeFromString(line) + when (progress.messageType) { + "status" -> { + val percent = "%.1f".format(progress.percentDone * 100) + emit("恢复进度: $percent%") + } + "summary" -> { + emit("恢复完成: ${progress.totalFiles} 个文件") + } + } + } catch (_: Exception) { emit(line) } + } + + if (result.exitCode == 0) Result.success(Unit) + else Result.failure(Exception("restic restore failed: ${result.stderr}")) + } + } + + // ── File dump ────────────────────────────────────── + + suspend fun dump( + repoPath: String, + password: String, + snapshotId: String, + filePath: String, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = false, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runRestic(env, "dump", snapshotId, filePath) + if (result.exitCode == 0) Result.success(result.stdout) + else Result.failure(Exception(result.stderr.ifEmpty { "restic dump failed with exit code ${result.exitCode}" })) + } + } +} diff --git a/app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt b/app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt new file mode 100644 index 0000000..683aa27 --- /dev/null +++ b/app/src/main/java/com/example/androidbackupgui/backup/ResticSnapshotOps.kt @@ -0,0 +1,100 @@ +package com.example.androidbackupgui.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + + +/** Shared Json instance configured for restic's snake_case output via @SerialName. */ +private val resticJson = Json { ignoreUnknownKeys = true } +/** + * Snapshot listing and retention policy operations. + * + * [listSnapshots] is download-only; [forget] requires both download and upload + * (forget removes snapshots from the remote). + * Delegates execution to [ResticCommandRunner], [ResticEnvResolver], and + * [RemoteSyncManager] which are shared across sub-modules. + */ +class ResticSnapshotOps( + private val runner: ResticCommandRunner, + private val envResolver: ResticEnvResolver, + private val syncManager: RemoteSyncManager +) { + // ── List snapshots ───────────────────────────────── + + suspend fun listSnapshots( + repoPath: String, + password: String, + tag: String? = null, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + ): Result> = withContext(Dispatchers.IO) { + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = false, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val args = mutableListOf("snapshots", "--json") + if (tag != null) { args.add("--tag"); args.add(tag) } + + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runRestic(env, args) + + if (result.exitCode != 0) { + return@withRemoteSync Result.failure(Exception("restic snapshots failed: ${result.stderr}")) + } + + try { + val snapshots = resticJson.decodeFromString>( + result.stdout.ifEmpty { "[]" } + ) + Result.success(snapshots.sortedByDescending { it.time }) + } catch (e: Exception) { + Result.failure(Exception("Failed to parse snapshot JSON: ${e.message}")) + } + } + } + + // ── Forget (retention policy) ────────────────────── + + suspend fun forget( + repoPath: String, + password: String, + keepDaily: Int = 7, + keepWeekly: Int = 4, + keepMonthly: Int = 3, + dryRun: Boolean = false, + backend: String = "local", + backendUrl: String = "", + backendUser: String = "", + backendPass: String = "", + backendShare: String = "", + onSyncProgress: suspend (RemoteTransport.TransferProgress) -> Unit = {}, + onByteSyncProgress: suspend (RemoteTransport.ByteProgress) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + syncManager.withRemoteSync(backend, backendUrl, backendUser, backendPass, backendShare, repoPath, + needsDownload = true, needsUpload = true, + onProgress = onSyncProgress, + onByteProgress = onByteSyncProgress, + ) { + val args = mutableListOf( + "forget", + "--keep-daily", keepDaily.toString(), + "--keep-weekly", keepWeekly.toString(), + "--keep-monthly", keepMonthly.toString() + ) + if (dryRun) args.add("--dry-run") + + val env = envResolver.buildFullEnv(repoPath, password, backend, backendUrl, backendUser, backendPass, backendShare, syncManager.tempRepoDir) + val result = runner.runRestic(env, args) + + if (result.exitCode == 0) Result.success(result.stdout) + else Result.failure(Exception("restic forget failed: ${result.stderr}")) + } + } +}