diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5a8cec3..3552f7d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,7 @@ -# Dependabot configuration for version updates +# Dependabot: weekly bumps for Gradle (including version catalog) and Actions. version: 2 updates: - # Gradle dependencies - package-ecosystem: "gradle" directory: "/" schedule: @@ -16,8 +15,31 @@ updates: commit-message: prefix: "deps:" include: "scope" + groups: + androidx: + patterns: + - "androidx*" + - "com.google.android.gms*" + - "com.google.mlkit*" + kotlin-toolchain: + patterns: + - "org.jetbrains.kotlin*" + - "org.jetbrains.kotlinx*" + - "com.google.devtools.ksp*" + testing: + patterns: + - "org.junit*" + - "io.kotest*" + - "io.mockk*" + - "androidx.test*" + di-and-crypto: + patterns: + - "com.google.dagger*" + - "org.bouncycastle*" + - "net.lingala.zip4j*" + - "androidx.security*" + - "com.google.code.gson*" - # GitHub Actions dependencies - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0b55e64..d5b93f6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'zulu' @@ -25,7 +25,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.gradle/caches - key: gradle-${{ hashFiles('**/*.gradle*') }} + key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }} - name: Bump version run: | sed -i "s/versionCode = [0-9]*/versionCode = ${{ github.run_number }}/" app/build.gradle.kts @@ -39,6 +39,7 @@ jobs: mkdir -p artifacts cp app/build/outputs/apk/debug/app-debug.apk artifacts/document-manager-debug.apk cp app/build/outputs/apk/release/app-release*.apk artifacts/document-manager-release.apk 2>/dev/null || true + cp app/build/outputs/sbom/debug/sbom.json artifacts/sbom.json 2>/dev/null || true - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ffbbe2d..dc83a25 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'zulu' @@ -31,7 +31,7 @@ jobs: uses: android-actions/setup-android@v3 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: java-kotlin build-mode: manual @@ -42,7 +42,7 @@ jobs: ./gradlew assembleDebug --no-daemon - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 continue-on-error: true with: category: /language:java-kotlin diff --git a/.gitignore b/.gitignore index 8297a6d..42d9fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ Thumbs.db google-services.json # Debug output -app/src/main/java/com/app/traveldocs/debug/debug_logs/ +app/src/main/java/com/app/paperstow/debug/debug_logs/ # Build artifacts build_output.txt diff --git a/.kiro/specs/travel-document-manager/design.md b/.kiro/specs/travel-document-manager/design.md index dde5ff7..224f514 100644 --- a/.kiro/specs/travel-document-manager/design.md +++ b/.kiro/specs/travel-document-manager/design.md @@ -376,7 +376,7 @@ The `device_key` is a 256-bit key generated once and stored in AndroidKeyStore. ### File Storage Layout ``` -/data/data/com.app.traveldocs/ +/data/data/com.app.paperstow/ ├── databases/ │ ├── member_{id}.db # SQLCipher-encrypted Room DB per member │ └── app_meta.db # Unencrypted: member list, app config @@ -722,7 +722,7 @@ app/src/androidTest/ # Integration + UI tests (device/emulator) **Component**: `DebugLogger` (debug/) - Singleton with 3 outputs: Logcat (tag: TravelDocs), ring buffer (500), file -- File at: `/data/data/com.app.traveldocs/files/debug_logs/traveldocs_debug.log` +- File at: `/data/data/com.app.paperstow/files/debug_logs/traveldocs_debug.log` - Auto-rotates at 5MB **Component**: `DebugLogScreen` + `DebugFloatingButton` (debug/) diff --git a/LICENSE b/LICENSE index c8a3a10..e12f2d1 100644 --- a/LICENSE +++ b/LICENSE @@ -90,7 +90,7 @@ the Work, You may choose to offer acceptance of support, warranty, indemnity, or other liability obligations consistent with this License. - Copyright 2024 Travel Document Manager Contributors + Copyright 2026 Paperstow Contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ca0ff8c --- /dev/null +++ b/NOTICE @@ -0,0 +1,96 @@ +Paperstow +Copyright 2026 Paperstow Contributors + +This product is licensed under the Apache License, Version 2.0. +See the LICENSE file in the repository root. + +This product redistributes software developed by third parties. Those +components remain under their own licenses. A component inventory is in +docs/THIRD_PARTY_LICENSES.md. + +--------------------------------------------------------------------------- +Apache License 2.0 components (selected) + +AndroidX (Core, Activity, Lifecycle, Navigation, Compose, Room, Biometric, +DocumentFile, Security Crypto, Hilt Navigation, Test) +Copyright The Android Open Source Project +https://developer.android.com/jetpack/androidx + +Kotlin and kotlinx.coroutines +Copyright JetBrains s.r.o. and Kotlin contributors +https://github.com/JetBrains/kotlin + +Dagger / Hilt +Copyright Google LLC +https://github.com/google/dagger + +Zip4j +Copyright Srikanth Lingala +https://github.com/srikanth-lingala/zip4j + +Gson +Copyright Google LLC +https://github.com/google/gson + +Tink Android (via androidx.security:security-crypto) +Copyright Google LLC +https://github.com/tink-crypto/tink-java + +JSpecify +Copyright The JSpecify Authors +https://jspecify.dev/ + +Kotest (test) +https://github.com/kotest/kotest + +MockK (test) +https://github.com/mockk/mockk + +Material Icons (Apache 2.0) +https://fonts.google.com/icons + +--------------------------------------------------------------------------- +Bouncy Castle (MIT-style "Bouncy Castle Licence") + +bcprov-jdk18on +Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc. +https://www.bouncycastle.org/ + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +https://www.bouncycastle.org/licence.html + +--------------------------------------------------------------------------- +Google ML Kit and Play services (not Apache) + +ML Kit Text Recognition and ML Kit Document Scanner client libraries are +distributed under the ML Kit Terms of Service, and they pull in Google Play +services libraries under the Android Software Development Kit License. + +https://developers.google.com/ml-kit/terms +https://developer.android.com/studio/terms + +These terms apply to the binary SDKs. They do not change the Apache 2.0 +license of Paperstow source code. + +--------------------------------------------------------------------------- +Eclipse Public License 2.0 (test classpath only; not shipped in the app) + +JUnit 5 (Jupiter / Platform) +https://www.eclipse.org/legal/epl-v20.html diff --git a/README.md b/README.md index 3db5e92..4e32742 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,27 @@ -# DocVault +# Paperstow -Private, encrypted document vault for Android. No cloud, no accounts, no compromises. +Keep a copy of your family travel papers on this device. Encrypted. No account. No cloud. ## Why this exists -I needed a secure place to keep passport scans, insurance docs, and travel papers on my phone where: -- They're encrypted and locked behind biometrics -- Nothing gets uploaded anywhere without me explicitly doing it -- I can find stuff fast with tags and search +I needed a place to keep passport scans, visas, tickets, and insurance on the phone where: +- They're encrypted and unlocked with the device's fingerprint, face, or screen lock +- Nothing gets uploaded unless I share or archive it +- I can find a page fast with tags and on-device OCR - Large PDFs don't hang the app -So I built this. It's open source, free to use, and the community is welcome to improve it. +So I built Paperstow. It's open source, free, and the community is welcome to improve it. ## What it does -- **Import** documents from files, camera, or local folders -- **OCR** extracts text and classifies docs automatically (passport, visa, ticket, etc.) -- **Encrypt** everything with AES-256-GCM, keys in Android KeyStore -- **Search** by tags and free-text across all documents -- **Share** via the standard Android share sheet — decrypted on the way out -- **Backup & Restore** *(experimental)* — password-protected ZIP to local folder -- **Per-document PIN** — lock individual sensitive documents with an additional PIN -- **Folder import** with automatic tagging from subfolder names -- **Dark theme** — toggle in settings, applies immediately -- **Google Drive import** *(experimental)* — import from Drive folders -- **S3-compatible backup** *(experimental)* — backup to AWS S3, MinIO, Backblaze -- **Audio playback & Android Auto** *(experimental)* — MP3/M4A import with car head unit support -- **GPS tagging** *(experimental)* — capture location on import, background tracking -- **WiFi sharing** *(experimental)* — embedded web server for LAN document management -- **Import from URL** *(experimental)* — download and import files from any web address -- **DICOM viewer** *(experimental)* — medical image preview with custom parser +- **Import** from files, camera, or a local folder +- **OCR** extracts text and classifies travel papers (passport, visa, ticket, and similar) +- **Encrypt** with AES-256-GCM; keys stay in Android KeyStore +- **Search** by tags and text from the page +- **Share** through Android's share sheet when a family member needs a copy +- **Archive & restore** — password-protected ZIP to a folder you pick +- **Folder import** — subfolder names become tags +- **Dark theme** — toggle in settings @@ -54,11 +46,9 @@ Watch the demo: https://youtube.com/shorts/E43BueHc3gQ?feature=share ## Principles -1. Your documents never leave your phone unless YOU share/backup them +1. Your documents never leave the phone unless YOU share or archive them 2. No telemetry without explicit opt-in consent -3. GPS tracking is off by default, behind a feature flag -4. Experimental features are hidden until you turn them on -5. The app works fully offline — network is only for optional cloud backup +3. The app works fully offline — internet is only for an optional OCR model refresh ## Built with AI @@ -77,7 +67,7 @@ Pre-built APKs are available from [GitHub Releases](https://github.com/sethusrin No build tools required — just download and install. ### App Name -This app is called **DocVault** — a secure vault for your important documents. +This app is called **Paperstow** — a place to stow the family's travel papers on the phone. ## Installing on Your Android Device @@ -109,9 +99,9 @@ For general use, download the **release** APK. 4. Tap **Install** → **Done** ### Notes -- **App Name:** This app is called **DocVault** -- **Package Name:** `com.app.traveldocs` -- When installing, you'll see it as "DocVault" on your app drawer +- **App Name:** Paperstow +- **Package Name:** `com.app.paperstow` +- When installing, you'll see it as "Paperstow" on your app drawer ### Option B — Install via USB from your computer @@ -143,7 +133,7 @@ adb install document-manager-release.apk ### After Installing -1. Launch **DocVault** from your app drawer +1. Launch **Paperstow** from your app drawer 2. Read and accept the End User License Agreement 3. The app uses your device's **biometric authentication** (fingerprint/face/PIN) — no separate app password needed 4. Start importing documents via **Import** button on the home screen @@ -169,7 +159,7 @@ See the [full setup guide](#development-setup) below if you need to install the ## Project layout ``` -app/src/main/java/com/app/traveldocs/ +app/src/main/java/com/app/paperstow/ ├── domain/ # Business logic. Pure Kotlin. No Android deps. │ ├── model/ # Document, Tag, SearchResult, etc. │ ├── repository/ # Interfaces only @@ -204,9 +194,9 @@ The domain layer has zero Android imports. Data implements domain interfaces. Pr | Hilt | Standard Android DI. ViewModels get auto-scoped. | | Kotest property tests | Domain invariants verified with random inputs, not just cherry-picked examples | -## App Name: DocVault +## App Name: Paperstow -This app is called **DocVault** — a secure vault for your important documents. The name reflects its purpose as an encrypted storage location for sensitive documents. +This app is called **Paperstow**. The listing title is unique; the Android package remains `com.app.paperstow`. ## Feature flags @@ -236,8 +226,8 @@ Yeah, there's a DICOM viewer. Built from scratch — no library. Parses the tag- ### Prerequisites - **JDK 17** — Azul Zulu or Temurin work well -- **Android SDK** — API 34, Build Tools 34.0.0 -- **A phone or emulator** — Tested on Pixel 10a (arm64) +- **Android SDK** — API 36, Build Tools 35.0.0 +- **A phone or local emulator** — `scripts/setup.sh` installs AVD `Paperstow_API36` (Play Store image) if it is not already present ### Environment @@ -273,8 +263,8 @@ echo "sdk.dir=$ANDROID_HOME" > local.properties # Build both APK and AAB for Play Store ./scripts/build-release.sh -# One-command build + deploy (default: release) -./scripts/deploy.sh +# One-command build + deploy (default: debug; starts Paperstow_API36 if needed) +./scripts/deploy.sh debug # Or specify variant ./scripts/deploy.sh release # production build (minified, no debug logs) @@ -294,8 +284,8 @@ This creates `~/release.keystore` used for signing release builds. ### Build & deploy ```bash -# One-command build + deploy (default: release) -./scripts/deploy.sh +# One-command build + deploy (default: debug; starts Paperstow_API36 if needed) +./scripts/deploy.sh debug # Or specify variant ./scripts/deploy.sh release # production build (minified, no debug logs) @@ -321,7 +311,7 @@ This creates `~/release.keystore` used for signing release builds. # Live logcat adb logcat -s TravelDocs # live logs -adb shell run-as com.app.traveldocs cat files/debug_logs/traveldocs_debug.log +adb shell run-as com.app.paperstow cat files/debug_logs/traveldocs_debug.log ``` Or just tap the 🐛 icon in the app — there's a full log viewer built in. @@ -355,17 +345,22 @@ Things that would be particularly useful: | [.kiro/specs/…/requirements.md](.kiro/specs/travel-document-manager/requirements.md) | 47 requirements with acceptance criteria | | [.kiro/specs/…/design.md](.kiro/specs/travel-document-manager/design.md) | Component interfaces, algorithms | | [PRIVACY_POLICY.md](docs/PRIVACY_POLICY.md) | Privacy policy (required for Play Store) | +| [LICENSE](LICENSE) / [NOTICE](NOTICE) | Apache 2.0 plus third-party attribution | +| [docs/THIRD_PARTY_LICENSES.md](docs/THIRD_PARTY_LICENSES.md) | Dependency license inventory | | [docs/KIRO_GENERATION_PROMPT.md](docs/KIRO_GENERATION_PROMPT.md) | One-shot Kiro prompt to regenerate this app + 10 key design decisions | ## License -Apache 2.0. See [LICENSE](LICENSE). +Paperstow source is **Apache License 2.0**. See [LICENSE](LICENSE) and [NOTICE](NOTICE). + +Third-party components keep their own terms (Apache 2.0, Bouncy Castle MIT-style, Google ML Kit / Play services, and EPL-2.0 JUnit on the test classpath). Inventory: [docs/THIRD_PARTY_LICENSES.md](docs/THIRD_PARTY_LICENSES.md). ## Acknowledgments -- [ML Kit](https://developers.google.com/ml-kit) — OCR engine -- [BouncyCastle](https://www.bouncycastle.org/) — Argon2id hashing -- [Material Icons](https://fonts.google.com/icons) — Icon set (Apache 2.0) -- [Kotest](https://kotest.io/) — Property-based testing -- [Zip4j](https://github.com/srikanth-lingala/zip4j) — Password-protected ZIP archives -# document-manager +- [AndroidX](https://developer.android.com/jetpack/androidx) / [Jetpack Compose](https://developer.android.com/jetpack/compose) — UI and platform (Apache 2.0) +- [ML Kit](https://developers.google.com/ml-kit) — on-device OCR and document scanner ([ML Kit Terms](https://developers.google.com/ml-kit/terms)) +- [Bouncy Castle](https://www.bouncycastle.org/) — Argon2id / HKDF (Bouncy Castle Licence) +- [Zip4j](https://github.com/srikanth-lingala/zip4j) — password-protected ZIP archives (Apache 2.0) +- [Hilt](https://dagger.dev/hilt/) — dependency injection (Apache 2.0) +- [Material Icons](https://fonts.google.com/icons) — icon set (Apache 2.0) +- [Kotest](https://kotest.io/) — property-based testing (Apache 2.0) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5863e8..1e09720 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,29 +1,37 @@ +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.time.Instant + plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("com.google.dagger.hilt.android") - id("com.google.devtools.ksp") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) } android { - namespace = "com.app.traveldocs" + namespace = "com.app.paperstow" compileSdk = 36 defaultConfig { - applicationId = "com.app.traveldocs" + applicationId = "com.app.paperstow" minSdk = 26 ndk { abiFilters += listOf("arm64-v8a") } targetSdk = 36 - versionCode = 2 - versionName = "1.0.1" + versionCode = 3 + versionName = "1.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - testOptions { - unitTests.isReturnDefaultValues = true - } vectorDrawables { useSupportLibrary = true } @@ -31,18 +39,30 @@ android { signingConfigs { create("release") { - // Release keystore - replace with your own before Play Store publishing - // Generate with: ./scripts/release_keystore.sh - storeFile = file(System.getenv("HOME") + "/release.keystore") - storePassword = "documentmanager2024" - keyAlias = "upload_key" - keyPassword = "documentmanager2024" + val keystorePath = System.getenv("DOCVAULT_KEYSTORE") + ?: (System.getenv("HOME") + "/release.keystore") + val ks = file(keystorePath) + if (ks.exists()) { + storeFile = ks + storePassword = System.getenv("DOCVAULT_STORE_PASSWORD") ?: "" + keyAlias = System.getenv("DOCVAULT_KEY_ALIAS") ?: "upload_key" + keyPassword = System.getenv("DOCVAULT_KEY_PASSWORD") ?: "" + } } } buildTypes { + debug { + ndk { + abiFilters.clear() + abiFilters += listOf("arm64-v8a", "x86_64") + } + } release { - signingConfig = signingConfigs.getByName("release") + val releaseSigning = signingConfigs.getByName("release") + if (releaseSigning.storeFile?.exists() == true) { + signingConfig = releaseSigning + } isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -62,15 +82,12 @@ android { buildFeatures { compose = true - buildConfig = true } - - composeOptions { - kotlinCompilerExtensionVersion = "1.5.8" + buildConfig = true } packaging { jniLibs { - useLegacyPackaging = true + useLegacyPackaging = false } resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -84,92 +101,189 @@ android { unitTests.all { it.useJUnitPlatform() } + unitTests.isReturnDefaultValues = true + } +} + +configurations.configureEach { + resolutionStrategy { + failOnChangingVersions() } } dependencies { - // Kotlin - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation(libs.coroutines.android) - // Jetpack Compose + Material 3 - val composeBom = platform("androidx.compose:compose-bom:2024.09.00") + val composeBom = platform(libs.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.activity:activity-compose:1.8.2") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") - implementation("androidx.navigation:navigation-compose:2.7.6") - debugImplementation("androidx.compose.ui:ui-tooling") - debugImplementation("androidx.compose.ui:ui-test-manifest") - - // AndroidX Core - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.biometric:biometric:1.1.0") - - implementation("androidx.documentfile:documentfile:1.0.1") - implementation("net.lingala.zip4j:zip4j:2.11.5") - implementation("org.nanohttpd:nanohttpd:2.3.1") - implementation("androidx.media:media:1.7.0") // MediaBrowserService for Android Auto - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - - // Hilt Dependency Injection - implementation("com.google.dagger:hilt-android:2.50") - ksp("com.google.dagger:hilt-android-compiler:2.50") - implementation("androidx.hilt:hilt-navigation-compose:1.1.0") - - // Room + SQLCipher (encrypted database) - val roomVersion = "2.6.1" - implementation("androidx.room:room-runtime:$roomVersion") - implementation("androidx.room:room-ktx:$roomVersion") - ksp("androidx.room:room-compiler:$roomVersion") - - // ML Kit Text Recognition v2 - implementation("com.google.mlkit:text-recognition:16.0.0") - - // ML Kit Document Scanner (stable version) - implementation("com.google.android.gms:play-services-mlkit-document-scanner:16.0.0") - - // Google Drive API - implementation("com.google.api-client:google-api-client-android:2.2.0") - implementation("com.google.apis:google-api-services-drive:v3-rev20231128-2.0.0") - implementation("com.google.android.gms:play-services-auth:21.1.0") - - // BouncyCastle (Argon2id) - implementation("org.bouncycastle:bcprov-jdk18on:1.77") - - // Security / AndroidKeyStore - implementation("androidx.security:security-crypto:1.1.0-alpha06") - - // JUnit 5 (unit testing) - testImplementation(platform("org.junit:junit-bom:5.10.1")) - testImplementation("org.junit.jupiter:junit-jupiter-api") - testImplementation("org.junit.jupiter:junit-jupiter-params") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") - - // Kotest Property Testing - testImplementation("io.kotest:kotest-runner-junit5:5.8.0") - testImplementation("io.kotest:kotest-assertions-core:5.8.0") - testImplementation("io.kotest:kotest-property:5.8.0") - testImplementation("io.kotest:kotest-framework-datatest:5.8.0") - - // MockK - testImplementation("io.mockk:mockk:1.13.9") - - // Kotlin Test - testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.22") - - // Coroutines Test - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") - - // Android Instrumentation Tests - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - androidTestImplementation("com.google.dagger:hilt-android-testing:2.50") - kspAndroidTest("com.google.dagger:hilt-android-compiler:2.50") + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) + implementation(libs.activity.compose) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + implementation(libs.navigation.compose) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) + + implementation(libs.core.ktx) + implementation(libs.biometric) + implementation(libs.documentfile) + implementation(libs.zip4j) + implementation(libs.lifecycle.runtime.ktx) + + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + implementation(libs.hilt.navigation.compose) + + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + implementation(libs.mlkit.text.recognition) + implementation(libs.mlkit.document.scanner) + + implementation(libs.bouncycastle) + implementation(libs.security.crypto) + + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + + // security-crypto 1.1.0 still pulls Gson 2.8.9 via Tink; force a patched line. + constraints { + implementation(libs.gson) + } + + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.kotest.property) + + testImplementation(libs.mockk) + testImplementation(libs.kotlin.test) + testImplementation(libs.coroutines.test) + testImplementation("org.json:json:20250517") + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.android.compiler) +} + +androidComponents { + onVariants(selector().all()) { variant -> + val variantName = variant.name + val taskName = "generate${variantName.replaceFirstChar { it.uppercase() }}Sbom" + val generate = tasks.register(taskName) { + group = "sbom" + description = "Write a CycloneDX SBOM for the $variantName build and package it in the APK." + this.variantName.set(variantName) + appName.set("Paperstow") + appVersion.set(providers.provider { android.defaultConfig.versionName ?: "0" }) + configurationName.set(variant.runtimeConfiguration.name) + runtimeClasspath.from(variant.runtimeConfiguration) + outputDir.set(layout.buildDirectory.dir("generated/sbomAssets/$variantName")) + archiveDir.set(layout.buildDirectory.dir("outputs/sbom/$variantName")) + } + variant.sources.assets?.addGeneratedSourceDirectory(generate, GenerateSbomTask::outputDir) + } +} + +abstract class GenerateSbomTask : DefaultTask() { + @get:Input + abstract val variantName: Property + + @get:Input + abstract val appName: Property + + @get:Input + abstract val appVersion: Property + + @get:Input + abstract val configurationName: Property + + @get:Classpath + abstract val runtimeClasspath: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:OutputDirectory + abstract val archiveDir: DirectoryProperty + + @TaskAction + fun generate() { + val config = project.configurations.getByName(configurationName.get()) + val components = linkedMapOf>() + config.incoming.resolutionResult.allComponents.forEach { component -> + val id = component.id + if (id is org.gradle.api.artifacts.component.ModuleComponentIdentifier && id.version.isNotBlank()) { + components["${id.group}:${id.module}:${id.version}"] = Triple(id.group, id.module, id.version) + } + } + val sorted = components.values.sortedWith(compareBy({ it.first }, { it.second }, { it.third })) + val timestamp = Instant.now().toString() + val json = buildCycloneDx( + appName = appName.get(), + appVersion = appVersion.get(), + variant = variantName.get(), + timestamp = timestamp, + components = sorted + ) + fun writeTo(dir: File) { + dir.mkdirs() + File(dir, "sbom.json").writeText(json) + } + writeTo(outputDir.get().asFile) + writeTo(archiveDir.get().asFile) + logger.lifecycle("SBOM ${variantName.get()}: ${sorted.size} components → ${archiveDir.get().asFile}/sbom.json") + } + + private fun buildCycloneDx( + appName: String, + appVersion: String, + variant: String, + timestamp: String, + components: List> + ): String { + fun esc(value: String) = value.replace("\\", "\\\\").replace("\"", "\\\"") + val rows = components.joinToString(",\n") { (group, name, version) -> + val purl = "pkg:maven/${esc(group)}/${esc(name)}@$version" + """ { + "type": "library", + "bom-ref": "$purl", + "group": "${esc(group)}", + "name": "${esc(name)}", + "version": "${esc(version)}", + "purl": "$purl" + }""" + } + return """{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "timestamp": "${esc(timestamp)}", + "component": { + "type": "application", + "name": "${esc(appName)}", + "version": "${esc(appVersion)}", + "bom-ref": "pkg:maven/com.app.paperstow/paperstow@${esc(appVersion)}" + }, + "properties": [ + { "name": "build:variant", "value": "${esc(variant)}" } + ] + }, + "components": [ +$rows + ] +} +""" + } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index fcb1987..aa33963 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,7 +5,7 @@ -dontwarn org.bouncycastle.** # Keep Room entities and database classes --keep class com.app.traveldocs.data.local.** { *; } +-keep class com.app.paperstow.data.local.** { *; } -keep class * extends androidx.room.RoomDatabase { *; } -keep @androidx.room.Entity class * { *; } -keep @androidx.room.Database class * { *; } @@ -30,21 +30,9 @@ -keep class com.google.mlkit.** { *; } -dontwarn com.google.mlkit.** -# Google Play Services & API Client +# Google Play Services (ML Kit) -keep class com.google.android.gms.** { *; } --keep class com.google.api.** { *; } -dontwarn com.google.android.gms.** --dontwarn com.google.api.** - -# Apache HttpClient (for Google Drive API) --keep class org.apache.http.** { *; } --dontwarn org.apache.http.** --dontwarn javax.naming.** --dontwarn org.ietf.jgss.** - -# Retrofit/OkHttp (for Drive/S3 backend) --keep class retrofit2.** { *; } --keep class okhttp3.** { *; } # AndroidX Core -keep class androidx.core.** { *; } @@ -65,4 +53,4 @@ -keep class dagger.hilt.internal.** { *; } # Keep auto-generated code for Navigation --keep class com.app.traveldocs.di.** { *; } +-keep class com.app.paperstow.di.** { *; } diff --git a/app/src/androidTest/java/com/app/traveldocs/data/local/.gitkeep b/app/src/androidTest/java/com/app/paperstow/data/local/.gitkeep similarity index 100% rename from app/src/androidTest/java/com/app/traveldocs/data/local/.gitkeep rename to app/src/androidTest/java/com/app/paperstow/data/local/.gitkeep diff --git a/app/src/androidTest/java/com/app/traveldocs/data/scanner/.gitkeep b/app/src/androidTest/java/com/app/paperstow/data/scanner/.gitkeep similarity index 100% rename from app/src/androidTest/java/com/app/traveldocs/data/scanner/.gitkeep rename to app/src/androidTest/java/com/app/paperstow/data/scanner/.gitkeep diff --git a/app/src/androidTest/java/com/app/traveldocs/presentation/.gitkeep b/app/src/androidTest/java/com/app/paperstow/presentation/.gitkeep similarity index 100% rename from app/src/androidTest/java/com/app/traveldocs/presentation/.gitkeep rename to app/src/androidTest/java/com/app/paperstow/presentation/.gitkeep diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b303635..4b0fded 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,27 +2,30 @@ - + - - - + + + - - - - + + + + + + + - + @@ -60,7 +63,7 @@ - + @@ -73,7 +76,6 @@ - @@ -84,26 +86,23 @@ + + + + + - - - - - - - + android:stopWithTask="false" /> - + + tools:node="remove" /> + + + Sample trail + + + Sample trail + + + + battery 84% + + + + battery 81% + + + + battery 76% + + + + diff --git a/app/src/main/assets/demo/travel_insurance.pdf b/app/src/main/assets/demo/travel_insurance.pdf new file mode 100644 index 0000000..026666c Binary files /dev/null and b/app/src/main/assets/demo/travel_insurance.pdf differ diff --git a/app/src/main/assets/demo/trip_plans.md b/app/src/main/assets/demo/trip_plans.md new file mode 100644 index 0000000..c056255 --- /dev/null +++ b/app/src/main/assets/demo/trip_plans.md @@ -0,0 +1,11 @@ +# Lakeside trip plans + +Simple sample checklist for John Doe and Jane Doe. + +- [x] Book flights and hotel +- [x] Buy travel insurance +- [ ] Pack medicines and copies of health records +- [ ] Download boarding passes +- [ ] Share itinerary with Alex Doe +- [ ] Confirm Lakeside Inn late check-in +- [ ] Review vaccination record before departure diff --git a/app/src/main/assets/demo/vaccination_record.pdf b/app/src/main/assets/demo/vaccination_record.pdf new file mode 100644 index 0000000..dc7b153 Binary files /dev/null and b/app/src/main/assets/demo/vaccination_record.pdf differ diff --git a/app/src/main/assets/demo/visa_letter.pdf b/app/src/main/assets/demo/visa_letter.pdf new file mode 100644 index 0000000..0e0be36 Binary files /dev/null and b/app/src/main/assets/demo/visa_letter.pdf differ diff --git a/app/src/main/assets/demo/visa_stamp.png b/app/src/main/assets/demo/visa_stamp.png new file mode 100644 index 0000000..eb404b9 Binary files /dev/null and b/app/src/main/assets/demo/visa_stamp.png differ diff --git a/app/src/main/assets/tag_stock/car.jpg b/app/src/main/assets/tag_stock/car.jpg new file mode 100644 index 0000000..279a412 Binary files /dev/null and b/app/src/main/assets/tag_stock/car.jpg differ diff --git a/app/src/main/assets/tag_stock/family.jpg b/app/src/main/assets/tag_stock/family.jpg new file mode 100644 index 0000000..d7f55db Binary files /dev/null and b/app/src/main/assets/tag_stock/family.jpg differ diff --git a/app/src/main/assets/tag_stock/folder.jpg b/app/src/main/assets/tag_stock/folder.jpg new file mode 100644 index 0000000..f7769e2 Binary files /dev/null and b/app/src/main/assets/tag_stock/folder.jpg differ diff --git a/app/src/main/assets/tag_stock/health.jpg b/app/src/main/assets/tag_stock/health.jpg new file mode 100644 index 0000000..c02ff18 Binary files /dev/null and b/app/src/main/assets/tag_stock/health.jpg differ diff --git a/app/src/main/assets/tag_stock/hotel.jpg b/app/src/main/assets/tag_stock/hotel.jpg new file mode 100644 index 0000000..be9d45d Binary files /dev/null and b/app/src/main/assets/tag_stock/hotel.jpg differ diff --git a/app/src/main/assets/tag_stock/insurance.jpg b/app/src/main/assets/tag_stock/insurance.jpg new file mode 100644 index 0000000..4e1708c Binary files /dev/null and b/app/src/main/assets/tag_stock/insurance.jpg differ diff --git a/app/src/main/assets/tag_stock/notes.jpg b/app/src/main/assets/tag_stock/notes.jpg new file mode 100644 index 0000000..d27cf64 Binary files /dev/null and b/app/src/main/assets/tag_stock/notes.jpg differ diff --git a/app/src/main/assets/tag_stock/passport.jpg b/app/src/main/assets/tag_stock/passport.jpg new file mode 100644 index 0000000..fd6e909 Binary files /dev/null and b/app/src/main/assets/tag_stock/passport.jpg differ diff --git a/app/src/main/assets/tag_stock/plans.jpg b/app/src/main/assets/tag_stock/plans.jpg new file mode 100644 index 0000000..01ad882 Binary files /dev/null and b/app/src/main/assets/tag_stock/plans.jpg differ diff --git a/app/src/main/assets/tag_stock/tickets.jpg b/app/src/main/assets/tag_stock/tickets.jpg new file mode 100644 index 0000000..ef5de58 Binary files /dev/null and b/app/src/main/assets/tag_stock/tickets.jpg differ diff --git a/app/src/main/assets/tag_stock/trail.jpg b/app/src/main/assets/tag_stock/trail.jpg new file mode 100644 index 0000000..d9eee37 Binary files /dev/null and b/app/src/main/assets/tag_stock/trail.jpg differ diff --git a/app/src/main/assets/tag_stock/trip.jpg b/app/src/main/assets/tag_stock/trip.jpg new file mode 100644 index 0000000..e546091 Binary files /dev/null and b/app/src/main/assets/tag_stock/trip.jpg differ diff --git a/app/src/main/assets/tag_stock/visa.jpg b/app/src/main/assets/tag_stock/visa.jpg new file mode 100644 index 0000000..14a37f1 Binary files /dev/null and b/app/src/main/assets/tag_stock/visa.jpg differ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..3665a1f Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/app/traveldocs/TravelDocsApplication.kt b/app/src/main/java/com/app/paperstow/TravelDocsApplication.kt similarity index 88% rename from app/src/main/java/com/app/traveldocs/TravelDocsApplication.kt rename to app/src/main/java/com/app/paperstow/TravelDocsApplication.kt index 187f627..72efddc 100644 --- a/app/src/main/java/com/app/traveldocs/TravelDocsApplication.kt +++ b/app/src/main/java/com/app/paperstow/TravelDocsApplication.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs +package com.app.paperstow import android.app.Application -import com.app.traveldocs.debug.CrashHandler -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.CrashHandler +import com.app.paperstow.debug.DebugLogger import dagger.hilt.android.HiltAndroidApp /** @@ -20,7 +20,7 @@ class TravelDocsApplication : Application() { super.onCreate() DebugLogger.init(this) CrashHandler.install(this) - DebugLogger.i("App", "Document Manager started. Build: ${BuildConfig.VERSION_NAME}") + DebugLogger.i("App", "Paperstow started. Build: ${BuildConfig.VERSION_NAME}") // verifyDatabaseTables() — disabled: was deleting restored DBs. Room handles schema itself. } @@ -49,7 +49,7 @@ class TravelDocsApplication : Application() { java.io.File(dbFile.path + "-wal").delete() java.io.File(dbFile.path + "-shm").delete() DebugLogger.i("App", "Corrupted DB removed. Room will create fresh tables on first query.") - com.app.traveldocs.debug.UsageTelemetry.action("App", "corrupted_db_deleted") + com.app.paperstow.debug.UsageTelemetry.action("App", "corrupted_db_deleted") } } catch (e: Exception) { DebugLogger.e("App", "Database verification failed (non-fatal)", e) diff --git a/app/src/main/java/com/app/paperstow/data/backup/BackupManager.kt b/app/src/main/java/com/app/paperstow/data/backup/BackupManager.kt new file mode 100644 index 0000000..e4d8c60 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/backup/BackupManager.kt @@ -0,0 +1,206 @@ +package com.app.paperstow.data.backup + +import android.content.Context +import com.app.paperstow.BuildConfig +import com.app.paperstow.debug.DebugLogger +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.model.ZipParameters +import net.lingala.zip4j.model.enums.AesKeyStrength +import net.lingala.zip4j.model.enums.CompressionLevel +import net.lingala.zip4j.model.enums.CompressionMethod +import net.lingala.zip4j.model.enums.EncryptionMethod +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BackupManager @Inject constructor() { + + data class BackupResult(val zipFile: File, val fileCount: Int, val totalBytes: Long) + + /** + * Creates a ZIP with decrypted document contents so it can be restored on this + * or another device. [backupPin] is optional: when blank, the archive is not encrypted. + */ + fun createBackupZip( + context: Context, + backupPin: String? = null, + includeTrailGpx: Boolean = false + ): BackupResult { + val pin = backupPin?.trim().orEmpty() + val protect = pin.isNotEmpty() + if (protect && pin.length < 4) { + throw IllegalArgumentException("Backup password must be at least 4 characters") + } + DebugLogger.i("Backup", "Creating transportable backup archive (password=${protect})...") + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val zipPath = File(context.cacheDir, "backup_$timestamp.zip") + val tempDir = File(context.cacheDir, "backup_staging_$timestamp") + tempDir.mkdirs() + + var fileCount = 0 + var totalBytes = 0L + val fileManifest = JSONArray() + + // 1. Decrypt document files and stage them + val docsDir = File(context.filesDir, "docs") + if (docsDir.exists()) { + docsDir.walkTopDown().filter { it.isFile && it.name.endsWith(".enc") }.forEach { encFile -> + try { + val decrypted = decryptFile(context, encFile) + if (decrypted != null) { + val relativePath = encFile.relativeTo(docsDir).path.replace(".enc", "") + val fileId = encFile.nameWithoutExtension + val outFile = File(tempDir, "docs/$relativePath") + outFile.parentFile?.mkdirs() + outFile.writeBytes(decrypted) + + fileCount++ + totalBytes += decrypted.size + val hash = java.security.MessageDigest.getInstance("SHA-256") + .digest(decrypted).joinToString("") { "%02x".format(it) } + fileManifest.put(JSONObject().apply { + put("path", "docs/$relativePath") + put("size", decrypted.size) + put("sha256", hash) + put("originalFileId", fileId) + }) + } + } catch (e: Exception) { + DebugLogger.e("Backup", "Failed to decrypt ${encFile.name}", e) + } + } + } + + val destDb = File(tempDir, "database/traveldocs.db") + RoomDbFiles.snapshot(context, destDb) + val dbCheck = RoomDbFiles.inspect(destDb) + DebugLogger.i("Backup", "DB snapshot: docs=${dbCheck.documentCount} tables=${dbCheck.tables} v=${dbCheck.userVersion}") + fileCount++ + totalBytes += destDb.length() + + if (includeTrailGpx) { + try { + val gpx = exportTrailGpx(context) + val trailFile = File(tempDir, "trail/my_trail.gpx") + trailFile.parentFile?.mkdirs() + trailFile.writeText(gpx) + fileCount++ + totalBytes += trailFile.length() + fileManifest.put(JSONObject().apply { + put("path", "trail/my_trail.gpx") + put("size", trailFile.length()) + put("sha256", java.security.MessageDigest.getInstance("SHA-256") + .digest(gpx.toByteArray()).joinToString("") { "%02x".format(it) }) + }) + DebugLogger.i("Backup", "Included My Trail as trail/my_trail.gpx") + } catch (e: Exception) { + DebugLogger.w("Backup", "Could not export trail GPX: ${e.message}") + } + } + + // 3. Write manifest with schema and file hashes + val manifest = JSONObject().apply { + put("schemaVersion", 2) + put("timestamp", timestamp) + put("appVersion", BuildConfig.VERSION_NAME) + put("fileCount", fileCount) + put("totalSizeBytes", totalBytes) + put("encrypted", protect) + put("transportable", true) + put("files", fileManifest) + } + File(tempDir, "manifest.json").writeText(manifest.toString(2)) + + val zipFile = if (protect) ZipFile(zipPath, pin.toCharArray()) else ZipFile(zipPath) + val params = ZipParameters().apply { + compressionMethod = CompressionMethod.DEFLATE + compressionLevel = CompressionLevel.NORMAL + if (protect) { + isEncryptFiles = true + encryptionMethod = EncryptionMethod.AES + aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256 + } + } + tempDir.walkTopDown().filter { it.isFile }.forEach { file -> + val entryName = file.relativeTo(tempDir).path + params.fileNameInZip = entryName + zipFile.addFile(file, params) + } + + // 5. Cleanup staging + tempDir.deleteRecursively() + + DebugLogger.i("Backup", "Backup created: ${zipPath.name}, $fileCount files, ${totalBytes / 1024}KB, password=${protect}") + return BackupResult(zipPath, fileCount, totalBytes) + } + + private fun exportTrailGpx(context: Context): String { + val places = mutableListOf() + val dbFile = context.getDatabasePath("traveldocs.db") + if (dbFile.exists()) { + val db = android.database.sqlite.SQLiteDatabase.openDatabase( + dbFile.path, null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY + ) + try { + db.rawQuery( + "SELECT latitude, longitude, timestamp, batteryPercent FROM gps_tracks ORDER BY timestamp ASC", + null + ).use { cursor -> + val lat = cursor.getColumnIndex("latitude") + val lon = cursor.getColumnIndex("longitude") + val time = cursor.getColumnIndex("timestamp") + val bat = cursor.getColumnIndex("batteryPercent") + while (cursor.moveToNext()) { + places += com.app.paperstow.domain.safety.SafetyPlace( + latitude = cursor.getDouble(lat), + longitude = cursor.getDouble(lon), + timestamp = cursor.getLong(time), + batteryPercent = if (bat >= 0) cursor.getInt(bat) else -1 + ) + } + } + } finally { + db.close() + } + } + return com.app.paperstow.domain.safety.GpxTrail.toGpx(places) + } + + /** + * Decrypt a .enc file using the same key from Android KeyStore. + */ + private fun decryptFile(context: Context, encFile: File): ByteArray? { + return try { + val encData = encFile.readBytes() + if (encData.size <= 12) return null + val iv = encData.copyOfRange(0, 12) + val ciphertext = encData.copyOfRange(12, encData.size) + + val keyStore = java.security.KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + val key = (keyStore.getEntry("travel_docs_file_encryption_key", null) as java.security.KeyStore.SecretKeyEntry).secretKey + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + cipher.doFinal(ciphertext) + } catch (e: Exception) { + DebugLogger.e("Backup", "Decrypt failed: ${encFile.name}", e) + null + } + } + + fun suggestedFolderName(): String = "Paperstow_Backup" + + fun suggestedFileName(): String { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + return "paperstow_backup_$ts.zip" + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/backup/BackupRestore.kt b/app/src/main/java/com/app/paperstow/data/backup/BackupRestore.kt similarity index 67% rename from app/src/main/java/com/app/traveldocs/data/backup/BackupRestore.kt rename to app/src/main/java/com/app/paperstow/data/backup/BackupRestore.kt index e678b26..9e01bdc 100644 --- a/app/src/main/java/com/app/traveldocs/data/backup/BackupRestore.kt +++ b/app/src/main/java/com/app/paperstow/data/backup/BackupRestore.kt @@ -1,7 +1,7 @@ -package com.app.traveldocs.data.backup +package com.app.paperstow.data.backup import android.content.Context -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.DebugLogger import net.lingala.zip4j.ZipFile import org.json.JSONObject import org.json.JSONArray @@ -24,7 +24,6 @@ object BackupRestore { val fileCount: Int, val totalSizeBytes: Long, val isPasswordProtected: Boolean, - val pinProtectedCount: Int, val files: List, val errorMessage: String? = null ) @@ -32,8 +31,7 @@ object BackupRestore { data class FileEntry( val path: String, val size: Long, - val sha256: String, - val hasPinProtection: Boolean + val sha256: String ) data class RestoreResult( @@ -52,11 +50,12 @@ object BackupRestore { DebugLogger.i("Restore", "Inspecting backup: ${zipFile.name} (${zipFile.length() / 1024}KB)") val extractDir = File(context.cacheDir, "inspect_${System.currentTimeMillis()}") extractDir.mkdirs() + val pin = password?.trim()?.takeIf { it.isNotEmpty() } try { - val zip = if (password != null) ZipFile(zipFile, password.toCharArray()) else ZipFile(zipFile) - if (zip.isEncrypted && password == null) { - return BackupInspection(false, 0, "", 0, 0, true, 0, emptyList(), "Backup is password-protected. Please provide the PIN.") + val zip = if (pin != null) ZipFile(zipFile, pin.toCharArray()) else ZipFile(zipFile) + if (zip.isEncrypted && pin == null) { + return BackupInspection(false, 0, "", 0, 0, true, emptyList(), "Backup is password-protected. Please provide the password.") } // Extract only manifest for inspection zip.extractAll(extractDir.absolutePath) @@ -64,7 +63,7 @@ object BackupRestore { val manifestFile = File(extractDir, "manifest.json") if (!manifestFile.exists()) { extractDir.deleteRecursively() - return BackupInspection(false, 0, "", 0, 0, false, 0, emptyList(), "No manifest.json found in backup. Invalid backup file.") + return BackupInspection(false, 0, "", 0, 0, false, emptyList(), "No manifest.json found in backup. Invalid backup file.") } val manifest = JSONObject(manifestFile.readText()) @@ -76,27 +75,33 @@ object BackupRestore { val filesArray = manifest.optJSONArray("files") ?: JSONArray() val files = mutableListOf() - var pinCount = 0 for (i in 0 until filesArray.length()) { val f = filesArray.getJSONObject(i) - val hasPin = f.optBoolean("hasPinProtection", false) - if (hasPin) pinCount++ files.add(FileEntry( path = f.getString("path"), size = f.optLong("size", 0), - sha256 = f.optString("sha256", ""), - hasPinProtection = hasPin + sha256 = f.optString("sha256", "") )) } + val backupDb = File(extractDir, "database/traveldocs.db") + val dbCheck = if (backupDb.exists()) RoomDbFiles.inspect(backupDb) else null extractDir.deleteRecursively() - DebugLogger.i("Restore", "Inspection: $fileCount files, ${totalSize/1024}KB, $pinCount PIN-protected") - return BackupInspection(true, schema, timestamp, fileCount, totalSize, encrypted, pinCount, files) + if (dbCheck == null) { + return BackupInspection(false, schema, timestamp, fileCount, totalSize, encrypted, files, + "Backup has no database/traveldocs.db") + } + if (!dbCheck.hasDocumentsTable) { + return BackupInspection(false, schema, timestamp, fileCount, totalSize, encrypted, files, + "Backup database is empty or corrupt (no documents table). ${dbCheck.error ?: "tables=${dbCheck.tables}"}") + } + DebugLogger.i("Restore", "Inspection: $fileCount files, ${totalSize/1024}KB, docs=${dbCheck.documentCount}") + return BackupInspection(true, schema, timestamp, fileCount, totalSize, encrypted, files) } catch (e: Exception) { extractDir.deleteRecursively() DebugLogger.e("Restore", "Inspection failed", e) - return BackupInspection(false, 0, "", 0, 0, false, 0, emptyList(), "Failed to read backup: ${e.message}") + return BackupInspection(false, 0, "", 0, 0, false, emptyList(), "Failed to read backup: ${e.message}") } } @@ -115,8 +120,9 @@ object BackupRestore { try { // 1. Extract - val zip = if (password != null) ZipFile(zipFile, password.toCharArray()) else ZipFile(zipFile) - if (zip.isEncrypted && password == null) { + val pin = password?.trim()?.takeIf { it.isNotEmpty() } + val zip = if (pin != null) ZipFile(zipFile, pin.toCharArray()) else ZipFile(zipFile) + if (zip.isEncrypted && pin == null) { return RestoreResult(false, 0, 0, 0, "Password required.", "Restore failed: backup is password-protected.") } zip.extractAll(extractDir.absolutePath) @@ -128,30 +134,66 @@ object BackupRestore { val expectedFiles = mutableMapOf() for (i in 0 until filesArray.length()) { val f = filesArray.getJSONObject(i) - val entry = FileEntry(f.getString("path"), f.optLong("size", 0), f.optString("sha256", ""), f.optBoolean("hasPinProtection", false)) + val entry = FileEntry(f.getString("path"), f.optLong("size", 0), f.optString("sha256", "")) expectedFiles[entry.path] = entry } reportLines.add("Manifest: schema=${manifest?.optInt("schemaVersion", 1)}, files=${expectedFiles.size}") reportLines.add("") - // 3. Close and restore database val backupDb = File(extractDir, "database/traveldocs.db") - if (backupDb.exists()) { - val targetDb = context.getDatabasePath("traveldocs.db") - val walFile = File(targetDb.path + "-wal") - val shmFile = File(targetDb.path + "-shm") - try { - val db = androidx.room.Room.databaseBuilder(context, com.app.traveldocs.data.local.TravelDocsDatabase::class.java, "traveldocs.db").build() - db.close() - } catch (_: Exception) {} - walFile.delete() - shmFile.delete() - targetDb.parentFile?.mkdirs() - backupDb.copyTo(targetDb, overwrite = true) - reportLines.add("Database: restored (${targetDb.length()/1024}KB)") - } else { + val backupCheck = if (backupDb.exists()) RoomDbFiles.inspect(backupDb) else null + if (backupCheck == null) { + extractDir.deleteRecursively() reportLines.add("Database: NOT found in backup") + return RestoreResult(false, 0, 0, 0, "Backup has no database.", reportLines.joinToString("\n")) + } + reportLines.add("Backup DB: ${backupDb.length()} bytes, tables=${backupCheck.tables}, docs=${backupCheck.documentCount}, v=${backupCheck.userVersion}") + if (!backupCheck.hasDocumentsTable) { + extractDir.deleteRecursively() + reportLines.add("ERROR: documents table not in backup DB — live library was not changed") + DebugLogger.e("Restore", "Backup DB missing documents table: ${backupCheck.error ?: backupCheck.tables}") + return RestoreResult( + false, 0, 0, 0, + "Backup database is empty or corrupt (no documents table). Restore did not change this phone.", + reportLines.joinToString("\n") + ) + } + + val targetDb = RoomDbFiles.liveFile(context) + val rollback = File(context.cacheDir, "restore_rollback_${System.currentTimeMillis()}.db") + fun rollbackLiveDb() { + if (!rollback.exists()) return + rollback.copyTo(targetDb, overwrite = true) + RoomDbFiles.deleteSidecars(targetDb) + reportLines.add("Rolled back to the previous database") + } + + RoomDbFiles.closeLiveDatabase(context) + if (targetDb.exists()) { + try { + targetDb.copyTo(rollback, overwrite = true) + } catch (e: Exception) { + DebugLogger.w("Restore", "Could not snapshot live DB for rollback: ${e.message}") + } } + RoomDbFiles.deleteSidecars(targetDb) + targetDb.parentFile?.mkdirs() + backupDb.copyTo(targetDb, overwrite = true) + RoomDbFiles.deleteSidecars(targetDb) + + val liveCheck = RoomDbFiles.inspect(targetDb) + reportLines.add("Live DB after copy: tables=${liveCheck.tables}, docs=${liveCheck.documentCount}") + if (!liveCheck.hasDocumentsTable) { + rollbackLiveDb() + extractDir.deleteRecursively() + rollback.delete() + return RestoreResult( + false, 0, 0, 0, + "Restore failed: documents table missing after copy. Previous library was put back.", + reportLines.joinToString("\n") + ) + } + reportLines.add("Database: restored (${targetDb.length() / 1024}KB, ${liveCheck.documentCount} documents)") // 4. Restore document files with verification var filesProcessed = 0 @@ -194,8 +236,7 @@ object BackupRestore { if (encrypted != null) { targetFile.writeBytes(encrypted) filesRestored++ - val status = if (expectedEntry?.hasPinProtection == true) "OK (PIN-protected)" else "OK" - reportLines.add(" $status: $relativePath (${fileBytes.size/1024}KB)") + reportLines.add(" OK: $relativePath (${fileBytes.size/1024}KB)") } else { reportLines.add(" FAIL (encryption): $relativePath") } @@ -225,8 +266,18 @@ object BackupRestore { extractDir.deleteRecursively() - val msg = "Restored $filesRestored of $filesProcessed documents" + - (if (filesFailedVerification > 0) " ($filesFailedVerification failed verification)" else "") + val docsMissingOnDisk = filesProcessed > 0 && filesRestored == 0 + val failed = filesFailedVerification > 0 || docsMissingOnDisk + if (failed) { + rollbackLiveDb() + rollback.delete() + val msg = "Restore failed: $filesRestored of $filesProcessed files written" + + (if (filesFailedVerification > 0) ", $filesFailedVerification failed verification" else "") + DebugLogger.e("Restore", msg) + return RestoreResult(false, filesProcessed, filesRestored, filesFailedVerification, msg, reportLines.joinToString("\n")) + } + rollback.delete() + val msg = "Restored $filesRestored of $filesProcessed documents" DebugLogger.i("Restore", msg) return RestoreResult(true, filesProcessed, filesRestored, filesFailedVerification, msg, reportLines.joinToString("\n")) diff --git a/app/src/main/java/com/app/paperstow/data/backup/RoomDbFiles.kt b/app/src/main/java/com/app/paperstow/data/backup/RoomDbFiles.kt new file mode 100644 index 0000000..b71de26 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/backup/RoomDbFiles.kt @@ -0,0 +1,124 @@ +package com.app.paperstow.data.backup + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import com.app.paperstow.data.local.TravelDocsDatabase +import com.app.paperstow.debug.DebugLogger +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.io.File + +/** + * Safe snapshot / swap of the Room file. Copying traveldocs.db while Room still + * has WAL open is what produced backups (and restores) with no documents table. + */ +object RoomDbFiles { + + data class Inspection( + val validSqlite: Boolean, + val hasDocumentsTable: Boolean, + val documentCount: Int, + val userVersion: Int, + val tables: List, + val error: String? = null + ) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface DatabaseEntryPoint { + fun database(): TravelDocsDatabase + } + + fun liveFile(context: Context): File = context.getDatabasePath("traveldocs.db") + + fun closeLiveDatabase(context: Context) { + try { + val db = EntryPointAccessors.fromApplication(context, DatabaseEntryPoint::class.java).database() + if (db.isOpen) { + db.close() + DebugLogger.i("DB", "Closed live Room database") + } + } catch (e: Exception) { + DebugLogger.w("DB", "Could not close live Room database: ${e.message}") + } + } + + fun snapshot(context: Context, dest: File) { + dest.parentFile?.mkdirs() + if (dest.exists()) dest.delete() + val live = liveFile(context) + if (!live.exists()) { + throw IllegalStateException("No live database to back up") + } + val source = SQLiteDatabase.openDatabase(live.path, null, SQLiteDatabase.OPEN_READWRITE) + try { + try { + val escaped = dest.absolutePath.replace("'", "''") + source.execSQL("VACUUM INTO '$escaped'") + } catch (e: Exception) { + DebugLogger.w("DB", "VACUUM INTO failed (${e.message}); checkpoint + copy") + source.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).close() + live.copyTo(dest, overwrite = true) + return + } + } finally { + if (source.isOpen) source.close() + } + val check = inspect(dest) + if (!check.hasDocumentsTable) { + throw IllegalStateException( + "Snapshot is missing the documents table (tables=${check.tables})" + ) + } + } + + fun inspect(file: File): Inspection { + if (!file.exists() || file.length() < 100) { + return Inspection(false, false, 0, 0, emptyList(), "Database file is missing or too small (${file.length()} bytes)") + } + val header = ByteArray(16) + file.inputStream().use { input -> + var read = 0 + while (read < header.size) { + val n = input.read(header, read, header.size - read) + if (n < 0) break + read += n + } + } + val magic = String(header, Charsets.US_ASCII) + if (!magic.startsWith("SQLite format 3")) { + return Inspection(false, false, 0, 0, emptyList(), "Not a SQLite file") + } + return try { + val db = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY) + try { + val tables = mutableListOf() + db.rawQuery("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", null).use { cursor -> + while (cursor.moveToNext()) tables += cursor.getString(0) + } + val hasDocuments = tables.contains("documents") + val count = if (hasDocuments) { + db.rawQuery("SELECT COUNT(*) FROM documents", null).use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else 0 + } + } else 0 + val version = db.rawQuery("PRAGMA user_version", null).use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else 0 + } + Inspection(true, hasDocuments, count, version, tables) + } finally { + db.close() + } + } catch (e: Exception) { + Inspection(false, false, 0, 0, emptyList(), e.message) + } + } + + fun deleteSidecars(dbFile: File) { + File(dbFile.path + "-wal").delete() + File(dbFile.path + "-shm").delete() + File(dbFile.path + "-journal").delete() + } +} diff --git a/app/src/main/java/com/app/paperstow/data/demo/DemoCatalog.kt b/app/src/main/java/com/app/paperstow/data/demo/DemoCatalog.kt new file mode 100644 index 0000000..c893ed5 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/demo/DemoCatalog.kt @@ -0,0 +1,38 @@ +package com.app.paperstow.data.demo + +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType + +const val DEMO_SYSTEM_TAG = "__DEMO" +const val DEMO_FOLDER_TAG = "Sample trip" + +data class DemoAsset( + val assetName: String, + val fileName: String, + val format: DocumentFormat, + val type: DocumentType, + val tags: List +) + +object DemoCatalog { + val items: List = listOf( + DemoAsset("john_doe_passport.pdf", "John_Doe_Passport.pdf", DocumentFormat.PDF, DocumentType.PASSPORT, listOf("Passport")), + DemoAsset("john_doe_photo.png", "John_Doe_Photo.png", DocumentFormat.PNG, DocumentType.PASSPORT, listOf("Passport")), + DemoAsset("jane_doe_photo.png", "Jane_Doe_Photo.png", DocumentFormat.PNG, DocumentType.PASSPORT, listOf("Passport")), + DemoAsset("visa_letter.pdf", "Jane_Doe_Visa_Letter.pdf", DocumentFormat.PDF, DocumentType.VISA, listOf("Visa")), + DemoAsset("visa_stamp.png", "Visa_Entry_Stamp.png", DocumentFormat.PNG, DocumentType.VISA, listOf("Visa")), + DemoAsset("boarding_pass.pdf", "Boarding_Pass_PS101.pdf", DocumentFormat.PDF, DocumentType.TICKET, listOf("Tickets")), + DemoAsset("boarding_pass_mobile.jpg", "Boarding_Pass_Mobile.jpg", DocumentFormat.JPG, DocumentType.TICKET, listOf("Tickets")), + DemoAsset("hotel_confirmation.pdf", "Lakeside_Inn.pdf", DocumentFormat.PDF, DocumentType.HOTEL_BOOKING, listOf("Hotel")), + DemoAsset("hotel_key_packet.gif", "Hotel_Key_Packet.gif", DocumentFormat.GIF, DocumentType.HOTEL_BOOKING, listOf("Hotel")), + DemoAsset("travel_insurance.pdf", "Travel_Insurance.pdf", DocumentFormat.PDF, DocumentType.HEALTH_INSURANCE, listOf("Insurance")), + DemoAsset("vaccination_record.pdf", "John_Doe_Vaccination.pdf", DocumentFormat.PDF, DocumentType.HEALTH_INSURANCE, listOf("Health")), + DemoAsset("blood_test_report.pdf", "Jane_Doe_Blood_Test.pdf", DocumentFormat.PDF, DocumentType.HEALTH_INSURANCE, listOf("Health")), + DemoAsset("jane_vaccine_card.bmp", "Jane_Doe_Vaccine_Card.bmp", DocumentFormat.BMP, DocumentType.HEALTH_INSURANCE, listOf("Health")), + DemoAsset("emergency_contacts.txt", "Emergency_Contacts.txt", DocumentFormat.TEXT, DocumentType.UNKNOWN, listOf("Notes")), + DemoAsset("packing_list.txt", "Packing_List.txt", DocumentFormat.TEXT, DocumentType.UNKNOWN, listOf("Notes")), + DemoAsset("flight_itinerary.txt", "Flight_Itinerary.txt", DocumentFormat.TEXT, DocumentType.UNKNOWN, listOf("Notes", "Tickets")), + DemoAsset("trip_plans.md", "Trip_Plans.md", DocumentFormat.MARKDOWN, DocumentType.UNKNOWN, listOf("Plans")), + DemoAsset("sample_trail.gpx", "Sample_Trail.gpx", DocumentFormat.GPX, DocumentType.UNKNOWN, listOf("Trail")) + ) +} diff --git a/app/src/main/java/com/app/paperstow/data/demo/DemoDataRepository.kt b/app/src/main/java/com/app/paperstow/data/demo/DemoDataRepository.kt new file mode 100644 index 0000000..d000022 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/demo/DemoDataRepository.kt @@ -0,0 +1,67 @@ +package com.app.paperstow.data.demo + +import android.content.Context +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DemoDataRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val fileStorage: DocumentFileStorage, + private val documentRepository: DocumentRepository +) { + suspend fun demoDocuments(): List = + documentRepository.getAll("default-member").first() + .filter { doc -> doc.tags.any { it.name == DEMO_SYSTEM_TAG } } + + suspend fun isLoaded(): Boolean = demoDocuments().isNotEmpty() + + suspend fun importSampleTrip(): Result = runCatching { + if (isLoaded()) return@runCatching demoDocuments().size + var count = 0 + val now = Instant.now() + DemoCatalog.items.forEach { item -> + val bytes = context.assets.open("demo/${item.assetName}").use { it.readBytes() } + val fileId = fileStorage.store("default-member", bytes, item.format).getOrThrow() + val tags = (listOf(DEMO_SYSTEM_TAG, DEMO_FOLDER_TAG) + item.tags).distinct().map { + Tag(name = it, isAutoGenerated = it == DEMO_SYSTEM_TAG) + } + documentRepository.insert( + Document( + id = fileId, + memberId = "default-member", + type = item.type, + format = item.format, + originalFileName = item.fileName, + metadata = emptyMap(), + tags = tags, + createdAt = now, + updatedAt = now, + extractionConfidence = 1f, + requiresManualReview = false + ) + ).getOrThrow() + count++ + } + DebugLogger.i("Demo", "Imported $count sample documents") + count + } + + suspend fun removeSampleTrip(): Result = runCatching { + val docs = demoDocuments() + docs.forEach { doc -> + fileStorage.secureDelete(doc.id) + documentRepository.delete(doc.id).getOrThrow() + } + DebugLogger.i("Demo", "Removed ${docs.size} sample documents") + docs.size + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/dicom/DicomParser.kt b/app/src/main/java/com/app/paperstow/data/dicom/DicomParser.kt similarity index 99% rename from app/src/main/java/com/app/traveldocs/data/dicom/DicomParser.kt rename to app/src/main/java/com/app/paperstow/data/dicom/DicomParser.kt index 582009b..28a7630 100644 --- a/app/src/main/java/com/app/traveldocs/data/dicom/DicomParser.kt +++ b/app/src/main/java/com/app/paperstow/data/dicom/DicomParser.kt @@ -1,7 +1,7 @@ -package com.app.traveldocs.data.dicom +package com.app.paperstow.data.dicom import android.graphics.Bitmap -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.DebugLogger import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/app/src/main/java/com/app/traveldocs/data/drive/.gitkeep b/app/src/main/java/com/app/paperstow/data/drive/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/data/drive/.gitkeep rename to app/src/main/java/com/app/paperstow/data/drive/.gitkeep diff --git a/app/src/main/java/com/app/paperstow/data/feedback/FeedbackDraftStore.kt b/app/src/main/java/com/app/paperstow/data/feedback/FeedbackDraftStore.kt new file mode 100644 index 0000000..275dc81 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/feedback/FeedbackDraftStore.kt @@ -0,0 +1,156 @@ +package com.app.paperstow.data.feedback + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +data class FeedbackDraft( + val id: String, + val liked: String, + val disliked: String, + val createdAt: Long, + val updatedAt: Long +) { + fun isBlank(): Boolean = liked.isBlank() && disliked.isBlank() + + fun preview(): String { + val likedBit = liked.trim().take(40) + val dislikedBit = disliked.trim().take(40) + return when { + likedBit.isNotEmpty() && dislikedBit.isNotEmpty() -> "Liked: $likedBit" + likedBit.isNotEmpty() -> likedBit + dislikedBit.isNotEmpty() -> "Not liked: $dislikedBit" + else -> "Empty draft" + } + } + + fun asNoteBody(): String = buildString { + appendLine("What I liked") + appendLine(liked.trim().ifBlank { "(none)" }) + appendLine() + appendLine("What I did not like") + appendLine(disliked.trim().ifBlank { "(none)" }) + } +} + +@Singleton +class FeedbackDraftStore @Inject constructor( + @ApplicationContext context: Context +) { + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun currentId(): String? = prefs.getString(KEY_CURRENT, null) + + fun draftsNewestFirst(): List = + readAll().sortedByDescending { it.updatedAt }.take(MAX_DRAFTS) + + fun get(id: String): FeedbackDraft? = readAll().firstOrNull { it.id == id } + + fun currentOrNew(): FeedbackDraft { + val id = currentId() + if (id != null) get(id)?.let { return it } + val draft = FeedbackDraft( + id = UUID.randomUUID().toString(), + liked = "", + disliked = "", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + upsert(draft, makeCurrent = true) + return draft + } + + fun startNew(): FeedbackDraft { + val existing = currentOrNew() + if (existing.isBlank()) return existing + val draft = FeedbackDraft( + id = UUID.randomUUID().toString(), + liked = "", + disliked = "", + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + upsert(draft, makeCurrent = true) + return draft + } + + fun saveCurrent(liked: String, disliked: String): FeedbackDraft { + val now = System.currentTimeMillis() + val previous = currentOrNew() + val draft = previous.copy(liked = liked, disliked = disliked, updatedAt = now) + if (draft.isBlank()) { + upsert(draft, makeCurrent = true) + return draft + } + upsert(draft, makeCurrent = true) + return draft + } + + fun setCurrent(id: String) { + if (get(id) != null) prefs.edit().putString(KEY_CURRENT, id).apply() + } + + fun delete(id: String) { + val remaining = readAll().filter { it.id != id } + writeAll(remaining) + if (currentId() == id) { + val next = remaining.maxByOrNull { it.updatedAt } + prefs.edit().putString(KEY_CURRENT, next?.id).apply() + } + } + + private fun upsert(draft: FeedbackDraft, makeCurrent: Boolean) { + val others = readAll().filter { it.id != draft.id } + val merged = (listOf(draft) + others).sortedByDescending { it.updatedAt }.take(MAX_DRAFTS) + writeAll(merged) + if (makeCurrent) prefs.edit().putString(KEY_CURRENT, draft.id).apply() + } + + private fun readAll(): List { + val raw = prefs.getString(KEY_DRAFTS, null) ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + add( + FeedbackDraft( + id = obj.getString("id"), + liked = obj.optString("liked"), + disliked = obj.optString("disliked"), + createdAt = obj.optLong("createdAt"), + updatedAt = obj.optLong("updatedAt") + ) + ) + } + } + }.getOrDefault(emptyList()) + } + + private fun writeAll(drafts: List) { + val array = JSONArray() + drafts.forEach { draft -> + array.put( + JSONObject().apply { + put("id", draft.id) + put("liked", draft.liked) + put("disliked", draft.disliked) + put("createdAt", draft.createdAt) + put("updatedAt", draft.updatedAt) + } + ) + } + prefs.edit().putString(KEY_DRAFTS, array.toString()).apply() + } + + companion object { + private const val PREFS = "feedback_drafts" + private const val KEY_DRAFTS = "drafts" + private const val KEY_CURRENT = "current_id" + private const val MAX_DRAFTS = 20 + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/importer/DocumentFormatValidator.kt b/app/src/main/java/com/app/paperstow/data/importer/DocumentFormatValidator.kt similarity index 61% rename from app/src/main/java/com/app/traveldocs/data/importer/DocumentFormatValidator.kt rename to app/src/main/java/com/app/paperstow/data/importer/DocumentFormatValidator.kt index 8d0f35b..c297be8 100644 --- a/app/src/main/java/com/app/traveldocs/data/importer/DocumentFormatValidator.kt +++ b/app/src/main/java/com/app/paperstow/data/importer/DocumentFormatValidator.kt @@ -1,6 +1,6 @@ -package com.app.traveldocs.data.importer +package com.app.paperstow.data.importer -import com.app.traveldocs.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentFormat import javax.inject.Inject import javax.inject.Singleton @@ -31,16 +31,46 @@ class DocumentFormatValidator @Inject constructor() { */ fun validateAndDetectFormat(bytes: ByteArray, mimeType: String?): DocumentFormat? { if (bytes.isEmpty()) return null + val detected = detect(bytes, mimeType, null) + return if (detected == DocumentFormat.UNKNOWN) null else detected + } - // First, try to detect format from magic bytes - val detectedFormat = detectFromMagicBytes(bytes) - - // If we detected a format from magic bytes, use it - if (detectedFormat != null) return detectedFormat + /** + * Resolve format from magic bytes, then MIME / file-name hints (HEIC, plain text). + */ + fun detect(bytes: ByteArray, mimeType: String?, fileName: String?): DocumentFormat { + if (bytes.isNotEmpty()) { + detectFromMagicBytes(bytes)?.let { return it } + } + val mime = mimeType?.lowercase().orEmpty() + val name = fileName?.lowercase().orEmpty() + return when { + mime.contains("heic") || mime.contains("heif") || + name.endsWith(".heic") || name.endsWith(".heif") -> DocumentFormat.HEIC + mime.contains("markdown") || name.endsWith(".md") || name.endsWith(".markdown") -> + DocumentFormat.MARKDOWN + mime.contains("gpx") || name.endsWith(".gpx") || + com.app.paperstow.domain.safety.GpxTrail.looksLikeGpx(bytes) -> DocumentFormat.GPX + mime == "text/plain" || mime.startsWith("text/") || name.endsWith(".txt") -> DocumentFormat.TEXT + else -> DocumentFormat.UNKNOWN + } + } - // If magic bytes don't match any known format, try MIME type as fallback - // but still require magic bytes to match for security - return null + fun isSupportedForImport(mime: String?, fileName: String?): Boolean { + val mime = mime?.lowercase().orEmpty() + val name = fileName?.lowercase().orEmpty() + if (mime.contains("video") || mime.contains("audio") || mime.contains("dicom")) return false + return mime.contains("pdf") || + mime.contains("jpeg") || mime.contains("jpg") || + mime.contains("png") || mime.contains("webp") || + mime.contains("heic") || mime.contains("heif") || + mime.contains("bmp") || mime.contains("gif") || + mime == "text/plain" || mime.startsWith("text/") || + name.endsWith(".pdf") || name.endsWith(".jpg") || name.endsWith(".jpeg") || + name.endsWith(".png") || name.endsWith(".webp") || name.endsWith(".heic") || + name.endsWith(".heif") || name.endsWith(".bmp") || name.endsWith(".gif") || + name.endsWith(".txt") || name.endsWith(".md") || name.endsWith(".markdown") || + name.endsWith(".gpx") } /** @@ -74,14 +104,17 @@ class DocumentFormatValidator @Inject constructor() { DocumentFormat.PDF -> isPdf(bytes) DocumentFormat.JPG -> isJpeg(bytes) DocumentFormat.PNG -> isPng(bytes) + DocumentFormat.TEXT -> bytes.isNotEmpty() + DocumentFormat.MARKDOWN -> bytes.isNotEmpty() + DocumentFormat.GPX -> com.app.paperstow.domain.safety.GpxTrail.looksLikeGpx(bytes) DocumentFormat.VIDEO -> true DocumentFormat.WEBP -> isWebP(bytes) - DocumentFormat.HEIC -> true // HEIC detection is complex; trust MIME type + DocumentFormat.HEIC -> true DocumentFormat.BMP -> isBmp(bytes) DocumentFormat.GIF -> isGif(bytes) DocumentFormat.DICOM -> isDicom(bytes) - DocumentFormat.AUDIO -> true // Audio formats validated by MIME - DocumentFormat.UNKNOWN -> true // Accept anything + DocumentFormat.AUDIO -> true + DocumentFormat.UNKNOWN -> true } } diff --git a/app/src/main/java/com/app/traveldocs/data/importer/FileDocumentImporter.kt b/app/src/main/java/com/app/paperstow/data/importer/FileDocumentImporter.kt similarity index 96% rename from app/src/main/java/com/app/traveldocs/data/importer/FileDocumentImporter.kt rename to app/src/main/java/com/app/paperstow/data/importer/FileDocumentImporter.kt index d0a200d..8f1380d 100644 --- a/app/src/main/java/com/app/traveldocs/data/importer/FileDocumentImporter.kt +++ b/app/src/main/java/com/app/paperstow/data/importer/FileDocumentImporter.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.data.importer +package com.app.paperstow.data.importer import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.ImportedDocument -import com.app.traveldocs.domain.repository.DocumentImporter +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.ImportedDocument +import com.app.paperstow.domain.repository.DocumentImporter import dagger.hilt.android.qualifiers.ApplicationContext import java.io.IOException import javax.inject.Inject diff --git a/app/src/main/java/com/app/traveldocs/data/local/ConsentPreferences.kt b/app/src/main/java/com/app/paperstow/data/local/ConsentPreferences.kt similarity index 84% rename from app/src/main/java/com/app/traveldocs/data/local/ConsentPreferences.kt rename to app/src/main/java/com/app/paperstow/data/local/ConsentPreferences.kt index 7d52fd7..4d773ad 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/ConsentPreferences.kt +++ b/app/src/main/java/com/app/paperstow/data/local/ConsentPreferences.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local import android.content.Context import android.content.SharedPreferences @@ -13,7 +13,6 @@ class ConsentPreferences @Inject constructor(@dagger.hilt.android.qualifiers.App private const val KEY_HAS_CONSENTED = "has_consented" private const val KEY_REGION = "selected_region" private const val KEY_ENCRYPTION_ENABLED = "encryption_enabled" - private const val KEY_PIN_WARNING_ACKNOWLEDGED = "pin_warning_acked" // Countries where personal-use encryption is generally permitted val ENCRYPTION_PERMITTED_REGIONS = listOf( @@ -39,18 +38,16 @@ class ConsentPreferences @Inject constructor(@dagger.hilt.android.qualifiers.App val hasConsented: Boolean get() = prefs.getBoolean(KEY_HAS_CONSENTED, false) val selectedRegion: String get() = prefs.getString(KEY_REGION, "") ?: "" val encryptionEnabled: Boolean get() = prefs.getBoolean(KEY_ENCRYPTION_ENABLED, false) - val pinWarningAcknowledged: Boolean get() = prefs.getBoolean(KEY_PIN_WARNING_ACKNOWLEDGED, false) fun isEncryptionPermittedForRegion(region: String): Boolean { return region in ENCRYPTION_PERMITTED_REGIONS } - fun saveConsent(region: String, encryptionEnabled: Boolean, pinWarningAcked: Boolean) { + fun saveConsent(region: String, encryptionEnabled: Boolean) { prefs.edit() .putBoolean(KEY_HAS_CONSENTED, true) .putString(KEY_REGION, region) .putBoolean(KEY_ENCRYPTION_ENABLED, encryptionEnabled) - .putBoolean(KEY_PIN_WARNING_ACKNOWLEDGED, pinWarningAcked) .apply() } } diff --git a/app/src/main/java/com/app/traveldocs/data/local/DatabaseRecovery.kt b/app/src/main/java/com/app/paperstow/data/local/DatabaseRecovery.kt similarity index 96% rename from app/src/main/java/com/app/traveldocs/data/local/DatabaseRecovery.kt rename to app/src/main/java/com/app/paperstow/data/local/DatabaseRecovery.kt index 7431332..9bfd725 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/DatabaseRecovery.kt +++ b/app/src/main/java/com/app/paperstow/data/local/DatabaseRecovery.kt @@ -1,7 +1,7 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local import android.content.Context -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.DebugLogger import java.io.File import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/app/traveldocs/data/local/DisclaimerPreferences.kt b/app/src/main/java/com/app/paperstow/data/local/DisclaimerPreferences.kt similarity index 96% rename from app/src/main/java/com/app/traveldocs/data/local/DisclaimerPreferences.kt rename to app/src/main/java/com/app/paperstow/data/local/DisclaimerPreferences.kt index 95607d4..c60a9c9 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/DisclaimerPreferences.kt +++ b/app/src/main/java/com/app/paperstow/data/local/DisclaimerPreferences.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext diff --git a/app/src/main/java/com/app/traveldocs/data/local/DocumentRepositoryImpl.kt b/app/src/main/java/com/app/paperstow/data/local/DocumentRepositoryImpl.kt similarity index 83% rename from app/src/main/java/com/app/traveldocs/data/local/DocumentRepositoryImpl.kt rename to app/src/main/java/com/app/paperstow/data/local/DocumentRepositoryImpl.kt index 5591b76..87bf05a 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/DocumentRepositoryImpl.kt +++ b/app/src/main/java/com/app/paperstow/data/local/DocumentRepositoryImpl.kt @@ -1,19 +1,19 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local import androidx.room.withTransaction -import com.app.traveldocs.data.local.dao.DocumentDao -import com.app.traveldocs.data.local.dao.DocumentMetadataDao -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.entity.DocumentEntity -import com.app.traveldocs.data.local.entity.DocumentMetadataEntity -import com.app.traveldocs.data.local.entity.DocumentTagEntity -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.MetadataField -import com.app.traveldocs.domain.model.SearchQuery -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.repository.DocumentRepository +import com.app.paperstow.data.local.dao.DocumentDao +import com.app.paperstow.data.local.dao.DocumentMetadataDao +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.entity.DocumentEntity +import com.app.paperstow.data.local.entity.DocumentMetadataEntity +import com.app.paperstow.data.local.entity.DocumentTagEntity +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.MetadataField +import com.app.paperstow.domain.model.SearchQuery +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.repository.DocumentRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.time.Instant @@ -89,6 +89,14 @@ class DocumentRepositoryImpl @Inject constructor( } } + override suspend fun updateName( + documentId: String, + originalFileName: String, + updatedAt: Instant + ): Result = runCatching { + documentDao.updateNameAndTimestamp(documentId, originalFileName, updatedAt.toEpochMilli()) + } + override suspend fun delete(documentId: String): Result { return try { database.withTransaction { @@ -106,6 +114,10 @@ class DocumentRepositoryImpl @Inject constructor( return documentDao.getCount(memberId) } + override suspend fun updateOcrText(documentId: String, ocrText: String): Result = runCatching { + documentDao.updateOcrText(documentId, ocrText.take(SearchIndex.MAX_CHARS)) + } + override suspend fun search(memberId: String, query: SearchQuery): List { val entities = when { query.tags.isNotEmpty() && query.freeText != null -> { @@ -185,7 +197,8 @@ class DocumentRepositoryImpl @Inject constructor( createdAt = document.createdAt.toEpochMilli(), updatedAt = document.updatedAt.toEpochMilli(), extractionConfidence = document.extractionConfidence, - requiresManualReview = document.requiresManualReview + requiresManualReview = document.requiresManualReview, + ocrText = "" ) } } diff --git a/app/src/main/java/com/app/paperstow/data/local/FeatureFlags.kt b/app/src/main/java/com/app/paperstow/data/local/FeatureFlags.kt new file mode 100644 index 0000000..7651adb --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/local/FeatureFlags.kt @@ -0,0 +1,36 @@ +package com.app.paperstow.data.local + +import android.content.Context + +/** + * Legacy SharedPreferences flags. + * + * Experimental cloud, GPS, WiFi, Auto, and DICOM features were removed from the + * Play Store build. These readers stay so existing preference files do not crash + * older call sites, but all default to OFF and setters are no-ops for removed features. + */ +object FeatureFlags { + fun isExperimentalEnabled(context: Context) = false + fun isGoogleDriveEnabled(context: Context) = false + fun isS3Enabled(context: Context) = false + fun isGpsTrackingEnabled(context: Context) = false + fun setGpsTracking(context: Context, v: Boolean) { /* removed */ } + + fun isBackupRestoreEnabled(context: Context) = true + fun isWifiShareEnabled(context: Context) = false + fun setWifiShare(context: Context, v: Boolean) { /* removed */ } + + fun isAudioPlaybackEnabled(context: Context) = false + fun setAudioPlayback(context: Context, v: Boolean) { /* removed */ } + + fun isExtendedFormatsEnabled(context: Context) = false + fun setExtendedFormats(context: Context, v: Boolean) { /* removed */ } + + fun setExperimental(context: Context, v: Boolean) { /* removed */ } + fun setGoogleDrive(context: Context, v: Boolean) { /* removed */ } + fun setS3(context: Context, v: Boolean) { /* removed */ } + fun setBackupRestore(context: Context, v: Boolean) { /* always on */ } + + fun isFormatEnabled(context: Context, format: String) = format != "dicom" + fun setFormatEnabled(context: Context, format: String, v: Boolean) { /* removed */ } +} diff --git a/app/src/main/java/com/app/traveldocs/data/local/InputSanitizer.kt b/app/src/main/java/com/app/paperstow/data/local/InputSanitizer.kt similarity index 94% rename from app/src/main/java/com/app/traveldocs/data/local/InputSanitizer.kt rename to app/src/main/java/com/app/paperstow/data/local/InputSanitizer.kt index 6d4e1fd..ddd464e 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/InputSanitizer.kt +++ b/app/src/main/java/com/app/paperstow/data/local/InputSanitizer.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local object InputSanitizer { private val TAG_REGEX = Regex("[a-zA-Z0-9 _\\-]+") diff --git a/app/src/main/java/com/app/traveldocs/data/local/SearchEngineImpl.kt b/app/src/main/java/com/app/paperstow/data/local/SearchEngineImpl.kt similarity index 79% rename from app/src/main/java/com/app/traveldocs/data/local/SearchEngineImpl.kt rename to app/src/main/java/com/app/paperstow/data/local/SearchEngineImpl.kt index c3f5667..4b92697 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/SearchEngineImpl.kt +++ b/app/src/main/java/com/app/paperstow/data/local/SearchEngineImpl.kt @@ -1,15 +1,15 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.QueryIntent -import com.app.traveldocs.domain.model.SearchQuery -import com.app.traveldocs.domain.model.SearchResult -import com.app.traveldocs.domain.repository.DocumentChecklistGenerator -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.repository.NaturalLanguageParser -import com.app.traveldocs.domain.repository.SearchEngine +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.QueryIntent +import com.app.paperstow.domain.model.SearchQuery +import com.app.paperstow.domain.model.SearchResult +import com.app.paperstow.domain.repository.DocumentChecklistGenerator +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.repository.NaturalLanguageParser +import com.app.paperstow.domain.repository.SearchEngine import kotlinx.coroutines.flow.first import java.time.Instant import javax.inject.Inject @@ -45,15 +45,7 @@ class SearchEngineImpl @Inject constructor( return when (parseResult.intent) { QueryIntent.DOCUMENT_SEARCH -> { - val documents = if (parseResult.searchTerms.isNotEmpty() && parseResult.searchTerms.any { it.length > 4 }) { - val searchQuery = SearchQuery( - tags = parseResult.searchTerms, - freeText = query - ) - documentRepository.search(memberId, searchQuery) - } else { - searchFreeForm(memberId, query) - } + val documents = searchFreeForm(memberId, query) DebugLogger.i("Search", "DOCUMENT_SEARCH result: ${documents.size} documents") SearchResult.DocumentResults(documents) } diff --git a/app/src/main/java/com/app/paperstow/data/local/SearchIndex.kt b/app/src/main/java/com/app/paperstow/data/local/SearchIndex.kt new file mode 100644 index 0000000..eb1606c --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/local/SearchIndex.kt @@ -0,0 +1,76 @@ +package com.app.paperstow.data.local + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import com.app.paperstow.domain.model.DocumentFormat +import java.io.ByteArrayOutputStream +import java.io.File + +object SearchIndex { + const val MAX_CHARS = 50_000 + + fun textForImport(format: DocumentFormat, bytes: ByteArray, ocrText: String): String { + val fromFile = when (format) { + DocumentFormat.TEXT, DocumentFormat.MARKDOWN, DocumentFormat.GPX -> + runCatching { String(bytes, Charsets.UTF_8) }.getOrDefault("") + else -> "" + } + return (ocrText.ifBlank { fromFile }).trim().take(MAX_CHARS) + } + + fun isImage(format: DocumentFormat): Boolean = format in setOf( + DocumentFormat.JPG, DocumentFormat.PNG, DocumentFormat.WEBP, + DocumentFormat.HEIC, DocumentFormat.BMP, DocumentFormat.GIF + ) + + fun isPlainText(format: DocumentFormat): Boolean = format in setOf( + DocumentFormat.TEXT, DocumentFormat.MARKDOWN, DocumentFormat.GPX + ) + + /** Image bytes ML Kit can read: the file itself, or rasterized PDF pages. */ + fun imagesForOcr(format: DocumentFormat, bytes: ByteArray, cacheDir: File): List = when { + isImage(format) -> listOf(bytes) + format == DocumentFormat.PDF -> pdfPagesAsJpeg(bytes, cacheDir) + else -> emptyList() + } + + fun pdfPagesAsJpeg(bytes: ByteArray, cacheDir: File, maxPages: Int = 4): List { + if (bytes.size < 5 || String(bytes, 0, 5, Charsets.ISO_8859_1) != "%PDF-") return emptyList() + val file = File.createTempFile("ocr", ".pdf", cacheDir) + return try { + file.writeBytes(bytes) + val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(pfd) + try { + val count = minOf(renderer.pageCount, maxPages) + (0 until count).mapNotNull { i -> + val page = renderer.openPage(i) + try { + val w = (page.width * 1.5f).toInt().coerceIn(400, 1600) + val h = (page.height * 1.5f).toInt().coerceIn(400, 2200) + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + // PdfRenderer paints onto a transparent bitmap. JPEG drops alpha, + // so unpainted pixels become black and ML Kit reads nothing. + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out) + bitmap.recycle() + out.toByteArray() + } finally { + page.close() + } + } + } finally { + renderer.close() + pfd.close() + } + } catch (_: Exception) { + emptyList() + } finally { + file.delete() + } + } +} diff --git a/app/src/main/java/com/app/paperstow/data/local/SearchIndexRebuilder.kt b/app/src/main/java/com/app/paperstow/data/local/SearchIndexRebuilder.kt new file mode 100644 index 0000000..29fb622 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/local/SearchIndexRebuilder.kt @@ -0,0 +1,82 @@ +package com.app.paperstow.data.local + +import android.content.Context +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.repository.MetadataExtractor +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +data class IndexRebuildState( + val running: Boolean = false, + val current: Int = 0, + val total: Int = 0, + val fileName: String = "", + val excerpt: String = "", + val done: Boolean = false, + val error: String? = null +) + +@Singleton +class SearchIndexRebuilder @Inject constructor( + @ApplicationContext private val context: Context, + private val documentRepository: DocumentRepository, + private val fileStorage: DocumentFileStorage, + private val metadataExtractor: MetadataExtractor +) { + private val _state = MutableStateFlow(IndexRebuildState()) + val state: StateFlow = _state.asStateFlow() + + suspend fun rebuild() = withContext(Dispatchers.IO) { + if (_state.value.running) return@withContext + try { + val docs = documentRepository.getAll("default-member").first() + _state.value = IndexRebuildState(running = true, total = docs.size) + docs.forEachIndexed { index, doc -> + val name = doc.originalFileName ?: doc.id + _state.value = _state.value.copy( + current = index + 1, + fileName = name, + excerpt = "Reading…" + ) + val bytes = fileStorage.retrieve(doc.id).getOrNull() + val text = if (bytes == null) { + "" + } else { + extractText(doc.format, bytes) + } + documentRepository.updateOcrText(doc.id, text) + _state.value = _state.value.copy( + current = index + 1, + fileName = name, + excerpt = text.ifBlank { "(no text found)" } + ) + DebugLogger.i("SearchIndex", "Indexed ${index + 1}/${docs.size} $name chars=${text.length}") + } + _state.value = _state.value.copy(running = false, done = true, excerpt = _state.value.excerpt) + } catch (e: Exception) { + DebugLogger.e("SearchIndex", "Rebuild failed", e) + _state.value = _state.value.copy(running = false, error = e.message ?: "Rebuild failed") + } + } + + private suspend fun extractText(format: com.app.paperstow.domain.model.DocumentFormat, bytes: ByteArray): String { + if (SearchIndex.isPlainText(format)) { + return SearchIndex.textForImport(format, bytes, "") + } + val chunks = SearchIndex.imagesForOcr(format, bytes, context.cacheDir).mapNotNull { page -> + val outcome = metadataExtractor.extract(page) + outcome.onFailure { e -> DebugLogger.w("SearchIndex", "OCR failed: ${e.message}") } + outcome.getOrNull()?.rawText + } + return chunks.joinToString("\n").trim().take(SearchIndex.MAX_CHARS) + } +} diff --git a/app/src/main/java/com/app/paperstow/data/local/TagColorStore.kt b/app/src/main/java/com/app/paperstow/data/local/TagColorStore.kt new file mode 100644 index 0000000..f8c93fb --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/local/TagColorStore.kt @@ -0,0 +1,163 @@ +package com.app.paperstow.data.local + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Optional folder photo for each tag. Photos stay on this phone. + */ +@Singleton +class TagColorStore @Inject constructor( + @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context +) { + companion object { + private const val PREFS = "tag_colors" + + val PALETTE = listOf( + 0xFF1E88E5.toInt(), + 0xFF00C853.toInt(), + 0xFFFF6D00.toInt(), + 0xFFFF1744.toInt(), + 0xFFAA00FF.toInt(), + 0xFFFFD600.toInt(), + 0xFF00B8D4.toInt(), + 0xFFF50057.toInt(), + 0xFF304FFE.toInt(), + 0xFF64DD17.toInt(), + 0xFFFFAB00.toInt(), + 0xFF00BFA5.toInt(), + 0xFFD500F9.toInt(), + 0xFFFF3D00.toInt(), + 0xFF2979FF.toInt(), + 0xFF76FF03.toInt() + ) + + val EMOJIS = listOf( + "✈️", "🏨", "🛂", "🎫", "🚗", "💊", "📝", "📍", + "👨‍👩‍👧", "💼", "🔑", "💳", "📄", "✅", "🎒", "🌍", + "🚆", "🚢", "🏠", "⭐", "🔔", "💡" + ) + + fun contrastOn(color: Int): Int { + val r = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val b = color and 0xFF + val luminance = (0.299 * r + 0.587 * g + 0.114 * b) + return if (luminance > 160) 0xFF212121.toInt() else 0xFFFFFFFF.toInt() + } + } + + private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private fun iconDir(): File = File(context.filesDir, "tag_icons").apply { mkdirs() } + + fun getColor(tagName: String): Int { + val stored = prefs().getInt("color_$tagName", -1) + if (stored != -1) return stored + val idx = (tagName.hashCode().and(0x7FFFFFFF)) % PALETTE.size + return PALETTE[idx] + } + + fun setColor(tagName: String, color: Int) { + prefs().edit().putInt("color_$tagName", color).apply() + } + + fun getEmoji(tagName: String): String? { + val stored = prefs().getString("emoji_$tagName", null) + if (!stored.isNullOrBlank()) return stored + return defaultEmoji(tagName) + } + + fun setEmoji(tagName: String, emoji: String?) { + val edit = prefs().edit() + if (emoji.isNullOrBlank()) edit.remove("emoji_$tagName") + else edit.putString("emoji_$tagName", emoji) + edit.apply() + } + + fun imageFile(tagName: String): File? { + val file = File(iconDir(), fileName(tagName)) + return if (file.exists() && file.length() > 0) file else null + } + + fun saveImage(tagName: String, uri: Uri): Boolean { + return runCatching { + context.contentResolver.openInputStream(uri)?.use { input -> + val decoded = BitmapFactory.decodeStream(input) ?: return false + val size = maxOf(decoded.width, decoded.height) + val scaled = if (size > 256) { + val factor = 256f / size + Bitmap.createScaledBitmap( + decoded, + (decoded.width * factor).toInt().coerceAtLeast(1), + (decoded.height * factor).toInt().coerceAtLeast(1), + true + ) + } else decoded + val out = File(iconDir(), fileName(tagName)) + out.outputStream().use { stream -> + scaled.compress(Bitmap.CompressFormat.JPEG, 85, stream) + } + if (scaled !== decoded) scaled.recycle() + } ?: return false + true + }.getOrDefault(false) + } + + fun clearImage(tagName: String) { + File(iconDir(), fileName(tagName)).delete() + } + + fun isShownOnHome(tagName: String): Boolean = + prefs().getBoolean("show_home_$tagName", true) + + fun setShownOnHome(tagName: String, shown: Boolean) { + prefs().edit().putBoolean("show_home_$tagName", shown).apply() + } + + fun renameLook(oldName: String, newName: String) { + if (oldName == newName) return + setColor(newName, getColor(oldName)) + prefs().getString("emoji_$oldName", null)?.let { setEmoji(newName, it) } + setShownOnHome(newName, isShownOnHome(oldName)) + val oldFile = File(iconDir(), fileName(oldName)) + if (oldFile.exists()) oldFile.copyTo(File(iconDir(), fileName(newName)), overwrite = true) + clearLook(oldName) + } + + fun clearLook(tagName: String) { + prefs().edit() + .remove("color_$tagName") + .remove("emoji_$tagName") + .remove("show_home_$tagName") + .apply() + clearImage(tagName) + } + + private fun fileName(tagName: String): String { + val safe = tagName.lowercase().replace(Regex("[^a-z0-9._-]"), "_").take(40) + return "${safe}_${tagName.hashCode() and 0x7FFFFFFF}.jpg" + } + + private fun defaultEmoji(tagName: String): String? { + val n = tagName.lowercase() + return when { + n.contains("passport") -> "🛂" + n.contains("visa") -> "🎫" + n.contains("ticket") || n.contains("flight") -> "✈️" + n.contains("hotel") -> "🏨" + n.contains("health") || n.contains("insurance") || n.contains("vaccine") -> "💊" + n.contains("note") -> "📝" + n.contains("plan") || n.contains("check") -> "✅" + n.contains("trail") -> "📍" + n.contains("car") || n.contains("rental") -> "🚗" + n.contains("sample") || n.contains("trip") -> "🌍" + else -> null + } + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/local/TagRepositoryImpl.kt b/app/src/main/java/com/app/paperstow/data/local/TagRepositoryImpl.kt similarity index 69% rename from app/src/main/java/com/app/traveldocs/data/local/TagRepositoryImpl.kt rename to app/src/main/java/com/app/paperstow/data/local/TagRepositoryImpl.kt index 77a09a0..620db46 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/TagRepositoryImpl.kt +++ b/app/src/main/java/com/app/paperstow/data/local/TagRepositoryImpl.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.entity.DocumentTagEntity -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.repository.TagRepository +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.entity.DocumentTagEntity +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.repository.TagRepository import javax.inject.Inject import javax.inject.Singleton @@ -14,6 +14,7 @@ class TagRepositoryImpl @Inject constructor( companion object { private const val MAX_TAGS_PER_DOCUMENT = 20 + const val CATALOG_DOCUMENT_ID = DocumentTagDao.CATALOG_DOCUMENT_ID } override suspend fun getTagsForDocument(documentId: String): List { @@ -41,7 +42,6 @@ class TagRepositoryImpl @Inject constructor( override suspend fun removeTag(documentId: String, tagName: String): Result { // System tags (__ prefix) cannot be removed directly by the user. - // They are managed by their owning feature (e.g., PIN lock manages __PIN_PROTECTED). if (tagName.startsWith("__")) { return Result.failure(IllegalArgumentException("System tag '$tagName' cannot be removed manually")) } @@ -71,6 +71,32 @@ class TagRepositoryImpl @Inject constructor( .distinctBy { it.name } } + override suspend fun createStandaloneTag(memberId: String, tagName: String): Result { + val name = tagName.trim() + if (name.isEmpty()) { + return Result.failure(IllegalArgumentException("Tag name is empty")) + } + if (name.startsWith("__")) { + return Result.failure(IllegalArgumentException("System tag names are reserved")) + } + return try { + val existing = documentTagDao.getAllTagsForMember(memberId).any { it.tag.equals(name, ignoreCase = false) } + if (!existing) { + documentTagDao.insert( + DocumentTagEntity( + documentId = CATALOG_DOCUMENT_ID, + tag = name, + isAutoGenerated = false, + createdAt = System.currentTimeMillis() + ) + ) + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun getTagUsageCount(memberId: String, tagName: String): Int { return documentTagDao.getDocumentCountForTag(memberId, tagName) } diff --git a/app/src/main/java/com/app/paperstow/data/local/TravelDocsDatabase.kt b/app/src/main/java/com/app/paperstow/data/local/TravelDocsDatabase.kt new file mode 100644 index 0000000..6825144 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/local/TravelDocsDatabase.kt @@ -0,0 +1,49 @@ +package com.app.paperstow.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.app.paperstow.data.local.dao.DocumentDao +import com.app.paperstow.data.local.dao.DocumentMetadataDao +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.dao.FamilyMemberDao +import com.app.paperstow.data.local.dao.GpsTrackDao +import com.app.paperstow.data.local.entity.DocumentEntity +import com.app.paperstow.data.local.entity.DocumentMetadataEntity +import com.app.paperstow.data.local.entity.DocumentTagEntity +import com.app.paperstow.data.local.entity.FamilyMemberEntity +import com.app.paperstow.data.local.entity.GpsTrackEntity + +@Database( + entities = [ + DocumentEntity::class, + DocumentMetadataEntity::class, + DocumentTagEntity::class, + FamilyMemberEntity::class, + GpsTrackEntity::class + ], + version = 4, + exportSchema = false +) +abstract class TravelDocsDatabase : RoomDatabase() { + abstract fun documentDao(): DocumentDao + abstract fun documentMetadataDao(): DocumentMetadataDao + abstract fun documentTagDao(): DocumentTagDao + abstract fun familyMemberDao(): FamilyMemberDao + abstract fun gpsTrackDao(): GpsTrackDao + + companion object { + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE gps_tracks ADD COLUMN batteryPercent INTEGER NOT NULL DEFAULT -1") + } + } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE documents ADD COLUMN ocrText TEXT NOT NULL DEFAULT ''") + } + } + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/local/auth/AuthRepositoryImpl.kt b/app/src/main/java/com/app/paperstow/data/local/auth/AuthRepositoryImpl.kt similarity index 94% rename from app/src/main/java/com/app/traveldocs/data/local/auth/AuthRepositoryImpl.kt rename to app/src/main/java/com/app/paperstow/data/local/auth/AuthRepositoryImpl.kt index 98b08b1..2c916e3 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/auth/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/app/paperstow/data/local/auth/AuthRepositoryImpl.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.data.local.auth +package com.app.paperstow.data.local.auth import android.content.Context -import com.app.traveldocs.data.local.crypto.PinHasher -import com.app.traveldocs.data.local.dao.FamilyMemberDao -import com.app.traveldocs.data.local.entity.FamilyMemberEntity -import com.app.traveldocs.domain.model.LockoutState -import com.app.traveldocs.domain.repository.AuthRepository -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.data.local.crypto.PinHasher +import com.app.paperstow.data.local.dao.FamilyMemberDao +import com.app.paperstow.data.local.entity.FamilyMemberEntity +import com.app.paperstow.domain.model.LockoutState +import com.app.paperstow.domain.repository.AuthRepository +import com.app.paperstow.debug.DebugLogger import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/app/traveldocs/data/local/auth/AuthSessionManager.kt b/app/src/main/java/com/app/paperstow/data/local/auth/AuthSessionManager.kt similarity index 92% rename from app/src/main/java/com/app/traveldocs/data/local/auth/AuthSessionManager.kt rename to app/src/main/java/com/app/paperstow/data/local/auth/AuthSessionManager.kt index 3e14013..65e1626 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/auth/AuthSessionManager.kt +++ b/app/src/main/java/com/app/paperstow/data/local/auth/AuthSessionManager.kt @@ -1,7 +1,7 @@ -package com.app.traveldocs.data.local.auth +package com.app.paperstow.data.local.auth -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.repository.SessionManager +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.repository.SessionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob diff --git a/app/src/main/java/com/app/paperstow/data/local/auth/SecurityAlertService.kt b/app/src/main/java/com/app/paperstow/data/local/auth/SecurityAlertService.kt new file mode 100644 index 0000000..267f7b7 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/local/auth/SecurityAlertService.kt @@ -0,0 +1,26 @@ +package com.app.paperstow.data.local.auth + +import android.content.Context +import com.app.paperstow.debug.DebugLogger +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Records PIN lockout events locally. Does not send SMS or show notifications + * (those require extra Play declarations). + */ +@Singleton +class SecurityAlertService @Inject constructor( + @dagger.hilt.android.qualifiers.ApplicationContext + private val context: Context, + private val securityPreferences: SecurityPreferences +) { + fun onLockoutTriggered() { + val timestamp = SimpleDateFormat("HH:mm dd-MMM-yyyy", Locale.US).format(Date()) + securityPreferences.lastAlertTime = System.currentTimeMillis() + DebugLogger.i("SecurityAlert", "PIN lockout at $timestamp") + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/local/auth/SecurityPreferences.kt b/app/src/main/java/com/app/paperstow/data/local/auth/SecurityPreferences.kt similarity index 85% rename from app/src/main/java/com/app/traveldocs/data/local/auth/SecurityPreferences.kt rename to app/src/main/java/com/app/paperstow/data/local/auth/SecurityPreferences.kt index 4333145..5dd51ca 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/auth/SecurityPreferences.kt +++ b/app/src/main/java/com/app/paperstow/data/local/auth/SecurityPreferences.kt @@ -1,10 +1,10 @@ -package com.app.traveldocs.data.local.auth +package com.app.paperstow.data.local.auth import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys -import com.app.traveldocs.debug.DebugLogger +import androidx.security.crypto.MasterKey +import com.app.paperstow.debug.DebugLogger import javax.inject.Inject import javax.inject.Singleton @@ -21,9 +21,13 @@ class SecurityPreferences @Inject constructor(@dagger.hilt.android.qualifiers.Ap private val prefs: SharedPreferences by lazy { try { - val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() EncryptedSharedPreferences.create( - PREFS_NAME, masterKey, context, + context, + PREFS_NAME, + masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) diff --git a/app/src/main/java/com/app/traveldocs/data/local/auth/TimeProvider.kt b/app/src/main/java/com/app/paperstow/data/local/auth/TimeProvider.kt similarity index 88% rename from app/src/main/java/com/app/traveldocs/data/local/auth/TimeProvider.kt rename to app/src/main/java/com/app/paperstow/data/local/auth/TimeProvider.kt index 2d17a0b..9269878 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/auth/TimeProvider.kt +++ b/app/src/main/java/com/app/paperstow/data/local/auth/TimeProvider.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.auth +package com.app.paperstow.data.local.auth import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/app/traveldocs/data/local/crypto/DeviceKeyManager.kt b/app/src/main/java/com/app/paperstow/data/local/crypto/DeviceKeyManager.kt similarity index 98% rename from app/src/main/java/com/app/traveldocs/data/local/crypto/DeviceKeyManager.kt rename to app/src/main/java/com/app/paperstow/data/local/crypto/DeviceKeyManager.kt index 3ca9448..cbc59dc 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/crypto/DeviceKeyManager.kt +++ b/app/src/main/java/com/app/paperstow/data/local/crypto/DeviceKeyManager.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.crypto +package com.app.paperstow.data.local.crypto import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties diff --git a/app/src/main/java/com/app/traveldocs/data/local/crypto/KeyDerivation.kt b/app/src/main/java/com/app/paperstow/data/local/crypto/KeyDerivation.kt similarity index 97% rename from app/src/main/java/com/app/traveldocs/data/local/crypto/KeyDerivation.kt rename to app/src/main/java/com/app/paperstow/data/local/crypto/KeyDerivation.kt index b1ddb96..d122db9 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/crypto/KeyDerivation.kt +++ b/app/src/main/java/com/app/paperstow/data/local/crypto/KeyDerivation.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.crypto +package com.app.paperstow.data.local.crypto import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.crypto.generators.HKDFBytesGenerator diff --git a/app/src/main/java/com/app/traveldocs/data/local/crypto/PinHasher.kt b/app/src/main/java/com/app/paperstow/data/local/crypto/PinHasher.kt similarity index 98% rename from app/src/main/java/com/app/traveldocs/data/local/crypto/PinHasher.kt rename to app/src/main/java/com/app/paperstow/data/local/crypto/PinHasher.kt index a47caeb..5816d97 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/crypto/PinHasher.kt +++ b/app/src/main/java/com/app/paperstow/data/local/crypto/PinHasher.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.crypto +package com.app.paperstow.data.local.crypto import org.bouncycastle.crypto.generators.Argon2BytesGenerator import org.bouncycastle.crypto.params.Argon2Parameters diff --git a/app/src/main/java/com/app/traveldocs/data/local/dao/DocumentDao.kt b/app/src/main/java/com/app/paperstow/data/local/dao/DocumentDao.kt similarity index 67% rename from app/src/main/java/com/app/traveldocs/data/local/dao/DocumentDao.kt rename to app/src/main/java/com/app/paperstow/data/local/dao/DocumentDao.kt index 77c0707..90500da 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/dao/DocumentDao.kt +++ b/app/src/main/java/com/app/paperstow/data/local/dao/DocumentDao.kt @@ -1,10 +1,10 @@ -package com.app.traveldocs.data.local.dao +package com.app.paperstow.data.local.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.app.traveldocs.data.local.entity.DocumentEntity +import com.app.paperstow.data.local.entity.DocumentEntity import kotlinx.coroutines.flow.Flow @Dao @@ -19,9 +19,18 @@ interface DocumentDao { @Query("SELECT * FROM documents WHERE id = :documentId") suspend fun getById(documentId: String): DocumentEntity? + @Query("SELECT * FROM documents") + suspend fun getAll(): List + @Query("DELETE FROM documents WHERE id = :documentId") suspend fun delete(documentId: String) + @Query("UPDATE documents SET originalFileName = :originalFileName, updatedAt = :updatedAt WHERE id = :id") + suspend fun updateNameAndTimestamp(id: String, originalFileName: String, updatedAt: Long) + + @Query("UPDATE documents SET ocrText = :ocrText WHERE id = :id") + suspend fun updateOcrText(id: String, ocrText: String) + @Query("SELECT COUNT(*) FROM documents WHERE memberId = :memberId") suspend fun getCount(memberId: String): Int @@ -31,7 +40,10 @@ interface DocumentDao { LEFT JOIN document_tags dt ON d.id = dt.documentId LEFT JOIN document_metadata dm ON d.id = dm.documentId WHERE d.memberId = :memberId - AND (:freeText IS NULL OR dt.tag LIKE '%' || :freeText || '%' OR dm.value LIKE '%' || :freeText || '%') + AND (:freeText IS NULL OR d.originalFileName LIKE '%' || :freeText || '%' + OR d.ocrText LIKE '%' || :freeText || '%' + OR dt.tag LIKE '%' || :freeText || '%' + OR dm.value LIKE '%' || :freeText || '%') ORDER BY d.updatedAt DESC """ ) @@ -56,7 +68,10 @@ interface DocumentDao { LEFT JOIN document_metadata dm ON d.id = dm.documentId WHERE d.memberId = :memberId AND dt.tag IN (:tags) - AND (:freeText IS NULL OR dt.tag LIKE '%' || :freeText || '%' OR dm.value LIKE '%' || :freeText || '%') + AND (:freeText IS NULL OR d.originalFileName LIKE '%' || :freeText || '%' + OR d.ocrText LIKE '%' || :freeText || '%' + OR dt.tag LIKE '%' || :freeText || '%' + OR dm.value LIKE '%' || :freeText || '%') GROUP BY d.id HAVING COUNT(DISTINCT dt.tag) = :tagCount ORDER BY d.updatedAt DESC diff --git a/app/src/main/java/com/app/traveldocs/data/local/dao/DocumentMetadataDao.kt b/app/src/main/java/com/app/paperstow/data/local/dao/DocumentMetadataDao.kt similarity index 86% rename from app/src/main/java/com/app/traveldocs/data/local/dao/DocumentMetadataDao.kt rename to app/src/main/java/com/app/paperstow/data/local/dao/DocumentMetadataDao.kt index db01566..5710bf1 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/dao/DocumentMetadataDao.kt +++ b/app/src/main/java/com/app/paperstow/data/local/dao/DocumentMetadataDao.kt @@ -1,10 +1,10 @@ -package com.app.traveldocs.data.local.dao +package com.app.paperstow.data.local.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.app.traveldocs.data.local.entity.DocumentMetadataEntity +import com.app.paperstow.data.local.entity.DocumentMetadataEntity @Dao interface DocumentMetadataDao { diff --git a/app/src/main/java/com/app/traveldocs/data/local/dao/DocumentTagDao.kt b/app/src/main/java/com/app/paperstow/data/local/dao/DocumentTagDao.kt similarity index 56% rename from app/src/main/java/com/app/traveldocs/data/local/dao/DocumentTagDao.kt rename to app/src/main/java/com/app/paperstow/data/local/dao/DocumentTagDao.kt index 55318a2..2790ab9 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/dao/DocumentTagDao.kt +++ b/app/src/main/java/com/app/paperstow/data/local/dao/DocumentTagDao.kt @@ -1,14 +1,18 @@ -package com.app.traveldocs.data.local.dao +package com.app.paperstow.data.local.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.app.traveldocs.data.local.entity.DocumentTagEntity +import com.app.paperstow.data.local.entity.DocumentTagEntity @Dao interface DocumentTagDao { + companion object { + const val CATALOG_DOCUMENT_ID = "__tag_catalog__" + } + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(tag: DocumentTagEntity) @@ -26,12 +30,19 @@ interface DocumentTagDao { @Query( """ - DELETE FROM document_tags - WHERE tag = :tag - AND documentId IN (SELECT id FROM documents WHERE memberId = :memberId) + DELETE FROM document_tags + WHERE tag = :tag + AND ( + documentId = :catalogId + OR documentId IN (SELECT id FROM documents WHERE memberId = :memberId) + ) """ ) - suspend fun deleteTagGlobally(memberId: String, tag: String) + suspend fun deleteTagGlobally( + memberId: String, + tag: String, + catalogId: String = CATALOG_DOCUMENT_ID + ) @Query("SELECT COUNT(*) FROM document_tags WHERE documentId = :documentId") suspend fun getCount(documentId: String): Int @@ -39,11 +50,14 @@ interface DocumentTagDao { @Query( """ SELECT DISTINCT dt.* FROM document_tags dt - INNER JOIN documents d ON dt.documentId = d.id - WHERE d.memberId = :memberId + LEFT JOIN documents d ON dt.documentId = d.id + WHERE d.memberId = :memberId OR dt.documentId = :catalogId """ ) - suspend fun getAllTagsForMember(memberId: String): List + suspend fun getAllTagsForMember( + memberId: String, + catalogId: String = CATALOG_DOCUMENT_ID + ): List @Query( """ @@ -57,8 +71,16 @@ interface DocumentTagDao { @Query( """ UPDATE document_tags SET tag = :newName - WHERE tag = :oldName AND documentId IN (SELECT id FROM documents WHERE memberId = :memberId) + WHERE tag = :oldName AND ( + documentId = :catalogId + OR documentId IN (SELECT id FROM documents WHERE memberId = :memberId) + ) """ ) - suspend fun renameTag(memberId: String, oldName: String, newName: String) + suspend fun renameTag( + memberId: String, + oldName: String, + newName: String, + catalogId: String = CATALOG_DOCUMENT_ID + ) } diff --git a/app/src/main/java/com/app/traveldocs/data/local/dao/FamilyMemberDao.kt b/app/src/main/java/com/app/paperstow/data/local/dao/FamilyMemberDao.kt similarity index 86% rename from app/src/main/java/com/app/traveldocs/data/local/dao/FamilyMemberDao.kt rename to app/src/main/java/com/app/paperstow/data/local/dao/FamilyMemberDao.kt index 8f09c9d..8147e3e 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/dao/FamilyMemberDao.kt +++ b/app/src/main/java/com/app/paperstow/data/local/dao/FamilyMemberDao.kt @@ -1,11 +1,11 @@ -package com.app.traveldocs.data.local.dao +package com.app.paperstow.data.local.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import com.app.traveldocs.data.local.entity.FamilyMemberEntity +import com.app.paperstow.data.local.entity.FamilyMemberEntity @Dao interface FamilyMemberDao { diff --git a/app/src/main/java/com/app/traveldocs/data/local/dao/GpsTrackDao.kt b/app/src/main/java/com/app/paperstow/data/local/dao/GpsTrackDao.kt similarity index 56% rename from app/src/main/java/com/app/traveldocs/data/local/dao/GpsTrackDao.kt rename to app/src/main/java/com/app/paperstow/data/local/dao/GpsTrackDao.kt index e9f548e..81e25de 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/dao/GpsTrackDao.kt +++ b/app/src/main/java/com/app/paperstow/data/local/dao/GpsTrackDao.kt @@ -1,9 +1,10 @@ -package com.app.traveldocs.data.local.dao +package com.app.paperstow.data.local.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.Query -import com.app.traveldocs.data.local.entity.GpsTrackEntity +import com.app.paperstow.data.local.entity.GpsTrackEntity +import kotlinx.coroutines.flow.Flow @Dao interface GpsTrackDao { @@ -16,12 +17,21 @@ interface GpsTrackDao { @Query("SELECT * FROM gps_tracks WHERE timestamp BETWEEN :startTime AND :endTime ORDER BY timestamp ASC") suspend fun getTracksBetween(startTime: Long, endTime: Long): List + @Query("SELECT * FROM gps_tracks ORDER BY timestamp DESC") + fun observeAllNewestFirst(): Flow> + @Query("SELECT * FROM gps_tracks ORDER BY timestamp ASC") suspend fun getAllTracks(): List @Query("SELECT COUNT(*) FROM gps_tracks") suspend fun getCount(): Int + @Query("UPDATE gps_tracks SET timestamp = :timestamp, batteryPercent = :batteryPercent WHERE id = :id") + suspend fun updateTimestampAndBattery(id: Long, timestamp: Long, batteryPercent: Int) + + @Query("DELETE FROM gps_tracks WHERE timestamp < :before") + suspend fun deleteOlderThan(before: Long) + @Query("DELETE FROM gps_tracks") suspend fun deleteAll() } diff --git a/app/src/main/java/com/app/traveldocs/data/local/entity/DocumentEntity.kt b/app/src/main/java/com/app/paperstow/data/local/entity/DocumentEntity.kt similarity index 75% rename from app/src/main/java/com/app/traveldocs/data/local/entity/DocumentEntity.kt rename to app/src/main/java/com/app/paperstow/data/local/entity/DocumentEntity.kt index 73c128e..8caca39 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/entity/DocumentEntity.kt +++ b/app/src/main/java/com/app/paperstow/data/local/entity/DocumentEntity.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.entity +package com.app.paperstow.data.local.entity import androidx.room.Entity import androidx.room.PrimaryKey @@ -14,5 +14,6 @@ data class DocumentEntity( val createdAt: Long, val updatedAt: Long, val extractionConfidence: Float?, - val requiresManualReview: Boolean = false + val requiresManualReview: Boolean = false, + val ocrText: String = "" ) diff --git a/app/src/main/java/com/app/traveldocs/data/local/entity/DocumentMetadataEntity.kt b/app/src/main/java/com/app/paperstow/data/local/entity/DocumentMetadataEntity.kt similarity index 86% rename from app/src/main/java/com/app/traveldocs/data/local/entity/DocumentMetadataEntity.kt rename to app/src/main/java/com/app/paperstow/data/local/entity/DocumentMetadataEntity.kt index 4ad4222..1d24c0a 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/entity/DocumentMetadataEntity.kt +++ b/app/src/main/java/com/app/paperstow/data/local/entity/DocumentMetadataEntity.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.entity +package com.app.paperstow.data.local.entity import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/app/src/main/java/com/app/traveldocs/data/local/entity/DocumentTagEntity.kt b/app/src/main/java/com/app/paperstow/data/local/entity/DocumentTagEntity.kt similarity index 84% rename from app/src/main/java/com/app/traveldocs/data/local/entity/DocumentTagEntity.kt rename to app/src/main/java/com/app/paperstow/data/local/entity/DocumentTagEntity.kt index d2fe8d6..667bb84 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/entity/DocumentTagEntity.kt +++ b/app/src/main/java/com/app/paperstow/data/local/entity/DocumentTagEntity.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.entity +package com.app.paperstow.data.local.entity import androidx.room.Entity diff --git a/app/src/main/java/com/app/traveldocs/data/local/entity/FamilyMemberEntity.kt b/app/src/main/java/com/app/paperstow/data/local/entity/FamilyMemberEntity.kt similarity index 87% rename from app/src/main/java/com/app/traveldocs/data/local/entity/FamilyMemberEntity.kt rename to app/src/main/java/com/app/paperstow/data/local/entity/FamilyMemberEntity.kt index 3f72aef..53f8504 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/entity/FamilyMemberEntity.kt +++ b/app/src/main/java/com/app/paperstow/data/local/entity/FamilyMemberEntity.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.entity +package com.app.paperstow.data.local.entity import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/app/src/main/java/com/app/traveldocs/data/local/entity/GpsTrackEntity.kt b/app/src/main/java/com/app/paperstow/data/local/entity/GpsTrackEntity.kt similarity index 71% rename from app/src/main/java/com/app/traveldocs/data/local/entity/GpsTrackEntity.kt rename to app/src/main/java/com/app/paperstow/data/local/entity/GpsTrackEntity.kt index 92f6123..de9b5ed 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/entity/GpsTrackEntity.kt +++ b/app/src/main/java/com/app/paperstow/data/local/entity/GpsTrackEntity.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local.entity +package com.app.paperstow.data.local.entity import androidx.room.Entity import androidx.room.PrimaryKey @@ -10,5 +10,6 @@ data class GpsTrackEntity( val longitude: Double, val accuracy: Float, val timestamp: Long, - val isMoving: Boolean = false + val isMoving: Boolean = false, + val batteryPercent: Int = -1 ) diff --git a/app/src/main/java/com/app/traveldocs/data/local/storage/DocumentFileStorageImpl.kt b/app/src/main/java/com/app/paperstow/data/local/storage/DocumentFileStorageImpl.kt similarity index 92% rename from app/src/main/java/com/app/traveldocs/data/local/storage/DocumentFileStorageImpl.kt rename to app/src/main/java/com/app/paperstow/data/local/storage/DocumentFileStorageImpl.kt index 35d8a22..a909246 100644 --- a/app/src/main/java/com/app/traveldocs/data/local/storage/DocumentFileStorageImpl.kt +++ b/app/src/main/java/com/app/paperstow/data/local/storage/DocumentFileStorageImpl.kt @@ -1,11 +1,11 @@ -package com.app.traveldocs.data.local.storage +package com.app.paperstow.data.local.storage -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.DebugLogger import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.repository.DocumentFileStorage import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.security.KeyStore @@ -58,6 +58,12 @@ class DocumentFileStorageImpl @Inject constructor( fileId } + override suspend fun replace(fileId: String, fileData: ByteArray): Result = runCatching { + val file = findFile(fileId) + ?: throw IllegalArgumentException("File not found: $fileId") + file.writeBytes(encrypt(fileData)) + } + override suspend fun retrieve(fileId: String): Result = runCatching { DebugLogger.d("FileStorage", "retrieve: fileId=$fileId") val file = findFile(fileId) diff --git a/app/src/main/java/com/app/traveldocs/data/nlp/.gitkeep b/app/src/main/java/com/app/paperstow/data/nlp/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/data/nlp/.gitkeep rename to app/src/main/java/com/app/paperstow/data/nlp/.gitkeep diff --git a/app/src/main/java/com/app/traveldocs/data/nlp/BasicDocumentChecklistGenerator.kt b/app/src/main/java/com/app/paperstow/data/nlp/BasicDocumentChecklistGenerator.kt similarity index 90% rename from app/src/main/java/com/app/traveldocs/data/nlp/BasicDocumentChecklistGenerator.kt rename to app/src/main/java/com/app/paperstow/data/nlp/BasicDocumentChecklistGenerator.kt index 20cae72..0980205 100644 --- a/app/src/main/java/com/app/traveldocs/data/nlp/BasicDocumentChecklistGenerator.kt +++ b/app/src/main/java/com/app/paperstow/data/nlp/BasicDocumentChecklistGenerator.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.data.nlp +package com.app.paperstow.data.nlp -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.MissingDocument -import com.app.traveldocs.domain.model.RequiredDocument -import com.app.traveldocs.domain.model.TravelDocumentChecklist -import com.app.traveldocs.domain.model.TravelParameters -import com.app.traveldocs.domain.repository.DocumentChecklistGenerator +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.MissingDocument +import com.app.paperstow.domain.model.RequiredDocument +import com.app.paperstow.domain.model.TravelDocumentChecklist +import com.app.paperstow.domain.model.TravelParameters +import com.app.paperstow.domain.repository.DocumentChecklistGenerator import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/app/traveldocs/data/nlp/RegexNaturalLanguageParser.kt b/app/src/main/java/com/app/paperstow/data/nlp/RegexNaturalLanguageParser.kt similarity index 97% rename from app/src/main/java/com/app/traveldocs/data/nlp/RegexNaturalLanguageParser.kt rename to app/src/main/java/com/app/paperstow/data/nlp/RegexNaturalLanguageParser.kt index feab340..274228c 100644 --- a/app/src/main/java/com/app/traveldocs/data/nlp/RegexNaturalLanguageParser.kt +++ b/app/src/main/java/com/app/paperstow/data/nlp/RegexNaturalLanguageParser.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.data.nlp +package com.app.paperstow.data.nlp -import com.app.traveldocs.domain.model.ParseResult -import com.app.traveldocs.domain.model.QueryIntent -import com.app.traveldocs.domain.model.TravelParameters -import com.app.traveldocs.domain.repository.NaturalLanguageParser +import com.app.paperstow.domain.model.ParseResult +import com.app.paperstow.domain.model.QueryIntent +import com.app.paperstow.domain.model.TravelParameters +import com.app.paperstow.domain.repository.NaturalLanguageParser import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/app/paperstow/data/safety/DeviceBattery.kt b/app/src/main/java/com/app/paperstow/data/safety/DeviceBattery.kt new file mode 100644 index 0000000..4a1ab8f --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/safety/DeviceBattery.kt @@ -0,0 +1,13 @@ +package com.app.paperstow.data.safety + +import android.content.Context +import android.os.BatteryManager + +object DeviceBattery { + /** Remaining battery as 0–100, or -1 if the device does not report it. */ + fun remainingPercent(context: Context): Int { + val manager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager ?: return -1 + val percent = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + return if (percent in 0..100) percent else -1 + } +} diff --git a/app/src/main/java/com/app/paperstow/data/safety/SafetyLocationRepository.kt b/app/src/main/java/com/app/paperstow/data/safety/SafetyLocationRepository.kt new file mode 100644 index 0000000..c1850db --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/safety/SafetyLocationRepository.kt @@ -0,0 +1,61 @@ +package com.app.paperstow.data.safety + +import android.content.Context +import com.app.paperstow.data.local.dao.GpsTrackDao +import com.app.paperstow.data.local.entity.GpsTrackEntity +import com.app.paperstow.domain.safety.SafetyPlace +import com.app.paperstow.domain.safety.UniqueLocations +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SafetyLocationRepository @Inject constructor( + private val dao: GpsTrackDao, + @ApplicationContext private val context: Context +) { + fun observeLast24Hours(): Flow> { + return dao.observeAllNewestFirst().map { rows -> + val cutoff = System.currentTimeMillis() - UniqueLocations.WINDOW_MS + rows.filter { it.timestamp >= cutoff }.map { it.toPlace() } + } + } + + suspend fun pruneExpired() { + dao.deleteOlderThan(System.currentTimeMillis() - UniqueLocations.WINDOW_MS) + } + + suspend fun recordFix(latitude: Double, longitude: Double, accuracy: Float, nowMs: Long = System.currentTimeMillis()) { + if (accuracy > UniqueLocations.MAX_ACCURACY_METERS) return + pruneExpired() + val batteryPercent = DeviceBattery.remainingPercent(context) + val last = dao.getLastTrack() + if (last != null && !UniqueLocations.isNewPlace(last.latitude, last.longitude, latitude, longitude)) { + dao.updateTimestampAndBattery(last.id, nowMs, batteryPercent) + return + } + dao.insert( + GpsTrackEntity( + latitude = latitude, + longitude = longitude, + accuracy = accuracy, + timestamp = nowMs, + isMoving = last != null, + batteryPercent = batteryPercent + ) + ) + } + + suspend fun clearAll() { + dao.deleteAll() + } + + private fun GpsTrackEntity.toPlace() = SafetyPlace( + latitude = latitude, + longitude = longitude, + timestamp = timestamp, + batteryPercent = batteryPercent + ) +} diff --git a/app/src/main/java/com/app/paperstow/data/safety/SafetyTrailPreferences.kt b/app/src/main/java/com/app/paperstow/data/safety/SafetyTrailPreferences.kt new file mode 100644 index 0000000..5f3ac04 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/safety/SafetyTrailPreferences.kt @@ -0,0 +1,28 @@ +package com.app.paperstow.data.safety + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object SafetyTrailPreferences { + private const val PREFS = "safety_trail_prefs" + private const val KEY_WANTED = "user_wants_trail" + + private val _running = MutableStateFlow(false) + val running: StateFlow = _running.asStateFlow() + + fun wantsTrail(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_WANTED, false) + + fun setWantsTrail(context: Context, wanted: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_WANTED, wanted) + .apply() + } + + fun setRunning(running: Boolean) { + _running.value = running + } +} diff --git a/app/src/main/java/com/app/paperstow/data/safety/SafetyTrailService.kt b/app/src/main/java/com/app/paperstow/data/safety/SafetyTrailService.kt new file mode 100644 index 0000000..6dc67a9 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/safety/SafetyTrailService.kt @@ -0,0 +1,200 @@ +package com.app.paperstow.data.safety + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.app.paperstow.R +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.presentation.MainActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * User-started location trail. A visible notification stays up while unique + * places are recorded. Does not use ACCESS_BACKGROUND_LOCATION. + */ +@AndroidEntryPoint +class SafetyTrailService : Service() { + + @Inject lateinit var repository: SafetyLocationRepository + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var locationManager: LocationManager? = null + private val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + scope.launch { + repository.recordFix(location.latitude, location.longitude, location.accuracy) + } + } + + @Deprecated("Deprecated in Java") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + override fun onProviderEnabled(provider: String) = Unit + override fun onProviderDisabled(provider: String) = Unit + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannel() + locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + SafetyTrailPreferences.setWantsTrail(this, false) + stopUpdates() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + if (intent?.action == ACTION_START || SafetyTrailPreferences.wantsTrail(this) || intent == null) { + SafetyTrailPreferences.setWantsTrail(this, true) + startInForeground() + beginUpdates() + return START_STICKY + } + stopSelf() + return START_NOT_STICKY + } + + override fun onDestroy() { + stopUpdates() + SafetyTrailPreferences.setRunning(false) + scope.cancel() + super.onDestroy() + } + + private fun startInForeground() { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= 34) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + } else { + startForeground(NOTIFICATION_ID, notification) + } + SafetyTrailPreferences.setRunning(true) + } + + private fun beginUpdates() { + val manager = locationManager ?: return + if (!hasLocationPermission()) { + DebugLogger.w(TAG, "Location permission missing — stopping trail") + SafetyTrailPreferences.setWantsTrail(this, false) + stopSelf() + return + } + try { + manager.getProviders(true).forEach { provider -> + if (provider == LocationManager.GPS_PROVIDER || provider == LocationManager.NETWORK_PROVIDER) { + manager.requestLocationUpdates(provider, MIN_TIME_MS, MIN_DISTANCE_METERS, listener) + } + } + listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER).forEach { provider -> + if (manager.isProviderEnabled(provider)) { + manager.getLastKnownLocation(provider)?.let { location -> + scope.launch { + repository.recordFix(location.latitude, location.longitude, location.accuracy) + } + } + } + } + DebugLogger.i(TAG, "My Trail started") + } catch (e: SecurityException) { + DebugLogger.e(TAG, "Location updates denied", e) + SafetyTrailPreferences.setWantsTrail(this, false) + stopSelf() + } + } + + private fun stopUpdates() { + try { + locationManager?.removeUpdates(listener) + } catch (_: Exception) { + } + SafetyTrailPreferences.setRunning(false) + } + + private fun hasLocationPermission(): Boolean { + val fine = ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) + val coarse = ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) + return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) ?: return + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.safety_notification_channel), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.safety_notification_channel_desc) + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + private fun buildNotification(): Notification { + val openApp = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE + ) + val stop = PendingIntent.getService( + this, + 1, + Intent(this, SafetyTrailService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentTitle(getString(R.string.safety_notification_title)) + .setContentText(getString(R.string.safety_notification_text)) + .setOngoing(true) + .setContentIntent(openApp) + .addAction(0, getString(R.string.safety_stop), stop) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + } + + companion object { + const val ACTION_START = "com.app.paperstow.safety.START" + const val ACTION_STOP = "com.app.paperstow.safety.STOP" + private const val TAG = "SafetyTrail" + private const val CHANNEL_ID = "family_safety_trail" + private const val NOTIFICATION_ID = 41 + private const val MIN_TIME_MS = 2 * 60 * 1000L + private const val MIN_DISTANCE_METERS = 100f + + fun start(context: Context) { + val intent = Intent(context, SafetyTrailService::class.java).setAction(ACTION_START) + ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + val intent = Intent(context, SafetyTrailService::class.java).setAction(ACTION_STOP) + context.startService(intent) + } + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/scanner/.gitkeep b/app/src/main/java/com/app/paperstow/data/scanner/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/data/scanner/.gitkeep rename to app/src/main/java/com/app/paperstow/data/scanner/.gitkeep diff --git a/app/src/main/java/com/app/paperstow/data/scanner/CameraDocumentScanner.kt b/app/src/main/java/com/app/paperstow/data/scanner/CameraDocumentScanner.kt new file mode 100644 index 0000000..83b5868 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/data/scanner/CameraDocumentScanner.kt @@ -0,0 +1,87 @@ +package com.app.paperstow.data.scanner + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.net.Uri +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.ImportedDocument +import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions +import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions.RESULT_FORMAT_JPEG +import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions.SCANNER_MODE_FULL +import com.google.mlkit.vision.documentscanner.GmsDocumentScanning +import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * On-device document scan via ML Kit / Google Play services. + * + * Edge detection, crop, filters, and the camera UI run in Play services. + * Paperstow does not need CAMERA permission for this path. JPEG pages are + * read locally and fed into the normal import pipeline. + */ +@Singleton +class CameraDocumentScanner @Inject constructor( + @ApplicationContext private val context: Context +) { + + private val options = GmsDocumentScannerOptions.Builder() + .setGalleryImportAllowed(false) + .setPageLimit(MAX_PAGES) + .setResultFormats(RESULT_FORMAT_JPEG) + .setScannerMode(SCANNER_MODE_FULL) + .build() + + fun getStartIntent( + activity: Activity, + onReady: (IntentSender) -> Unit, + onError: (Exception) -> Unit + ) { + GmsDocumentScanning.getClient(options) + .getStartScanIntent(activity) + .addOnSuccessListener { sender -> + DebugLogger.i(TAG, "Document scanner ready") + onReady(sender) + } + .addOnFailureListener { e -> + DebugLogger.e(TAG, "Document scanner unavailable", e) + onError(e) + } + } + + fun pagesFromResult(data: Intent?): List { + val result = GmsDocumentScanningResult.fromActivityResultIntent(data) ?: return emptyList() + val pages = result.pages.orEmpty() + if (pages.isEmpty()) return emptyList() + val stamp = System.currentTimeMillis() + return pages.mapIndexedNotNull { index, page -> + try { + ImportedDocument( + rawBytes = readBytes(page.imageUri), + format = DocumentFormat.JPG, + originalFileName = "scan_${stamp}_${index + 1}.jpg" + ) + } catch (e: Exception) { + DebugLogger.e(TAG, "Failed to read scanned page $index", e) + null + } + } + } + + private fun readBytes(uri: Uri): ByteArray { + return context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IOException("Unable to open scanned page: $uri") + } + + companion object { + const val MAX_PAGES = 4 + private const val TAG = "DocScanner" + } +} + +class ScannerException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/com/app/traveldocs/data/scanner/MlKitMetadataExtractor.kt b/app/src/main/java/com/app/paperstow/data/scanner/MlKitMetadataExtractor.kt similarity index 92% rename from app/src/main/java/com/app/traveldocs/data/scanner/MlKitMetadataExtractor.kt rename to app/src/main/java/com/app/paperstow/data/scanner/MlKitMetadataExtractor.kt index ae52ce8..36a5e67 100644 --- a/app/src/main/java/com/app/traveldocs/data/scanner/MlKitMetadataExtractor.kt +++ b/app/src/main/java/com/app/paperstow/data/scanner/MlKitMetadataExtractor.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.data.scanner +package com.app.paperstow.data.scanner -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.DebugLogger import android.graphics.BitmapFactory -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractionResult -import com.app.traveldocs.domain.model.ExtractedValue -import com.app.traveldocs.domain.model.MetadataField -import com.app.traveldocs.domain.repository.MetadataExtractor +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractionResult +import com.app.paperstow.domain.model.ExtractedValue +import com.app.paperstow.domain.model.MetadataField +import com.app.paperstow.domain.repository.MetadataExtractor import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.TextRecognizer @@ -43,7 +43,8 @@ class MlKitMetadataExtractor @Inject constructor() : MetadataExtractor { documentType = documentType, metadata = metadata, confidence = confidence, - requiresManualReview = confidence < 0.8f + requiresManualReview = confidence < 0.8f, + rawText = text ) ) } catch (e: Exception) { @@ -69,26 +70,36 @@ class MlKitMetadataExtractor @Inject constructor() : MetadataExtractor { * Runs ML Kit Text Recognition on the given image bytes and returns the full text. */ internal suspend fun recognizeText(imageData: ByteArray): String { - val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) + val bitmap = decodeForOcr(imageData) ?: throw ExtractionException("Unable to decode image data") val inputImage = InputImage.fromBitmap(bitmap, 0) + DebugLogger.d("OCR", "recognizeText: ${bitmap.width}x${bitmap.height}") return suspendCancellableCoroutine { continuation -> textRecognizer.process(inputImage) .addOnSuccessListener { result -> - continuation.resume(result.text) + DebugLogger.i("OCR", "ML Kit text chars=${result.text.length}") + if (continuation.isActive) continuation.resume(result.text) } .addOnFailureListener { exception -> - continuation.resume("") + DebugLogger.e("OCR", "ML Kit failed: ${exception.message}", exception) + if (continuation.isActive) continuation.resume("") } - - continuation.invokeOnCancellation { - // TextRecognizer doesn't support cancellation directly - } } } + private fun decodeForOcr(imageData: ByteArray): android.graphics.Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(imageData, 0, imageData.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + val longest = maxOf(bounds.outWidth, bounds.outHeight) + while (longest / sample > 2000) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + return BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opts) + } + /** * Classifies the document type based on keyword patterns in the extracted text. * diff --git a/app/src/main/java/com/app/traveldocs/data/scanner/SmartFileNamer.kt b/app/src/main/java/com/app/paperstow/data/scanner/SmartFileNamer.kt similarity index 96% rename from app/src/main/java/com/app/traveldocs/data/scanner/SmartFileNamer.kt rename to app/src/main/java/com/app/paperstow/data/scanner/SmartFileNamer.kt index e86b576..27bedf8 100644 --- a/app/src/main/java/com/app/traveldocs/data/scanner/SmartFileNamer.kt +++ b/app/src/main/java/com/app/paperstow/data/scanner/SmartFileNamer.kt @@ -1,6 +1,6 @@ -package com.app.traveldocs.data.scanner +package com.app.paperstow.data.scanner -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.DebugLogger import java.text.SimpleDateFormat import java.util.Date import java.util.Locale diff --git a/app/src/main/java/com/app/traveldocs/data/tags/AutoTagGeneratorImpl.kt b/app/src/main/java/com/app/paperstow/data/tags/AutoTagGeneratorImpl.kt similarity index 91% rename from app/src/main/java/com/app/traveldocs/data/tags/AutoTagGeneratorImpl.kt rename to app/src/main/java/com/app/paperstow/data/tags/AutoTagGeneratorImpl.kt index 491e6c9..cdbd583 100644 --- a/app/src/main/java/com/app/traveldocs/data/tags/AutoTagGeneratorImpl.kt +++ b/app/src/main/java/com/app/paperstow/data/tags/AutoTagGeneratorImpl.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.data.tags +package com.app.paperstow.data.tags -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractedValue -import com.app.traveldocs.domain.model.MetadataField -import com.app.traveldocs.domain.repository.AutoTagGenerator +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractedValue +import com.app.paperstow.domain.model.MetadataField +import com.app.paperstow.domain.repository.AutoTagGenerator import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/app/traveldocs/debug/CrashHandler.kt b/app/src/main/java/com/app/paperstow/debug/CrashHandler.kt similarity index 62% rename from app/src/main/java/com/app/traveldocs/debug/CrashHandler.kt rename to app/src/main/java/com/app/paperstow/debug/CrashHandler.kt index 529dee5..f3052d1 100644 --- a/app/src/main/java/com/app/traveldocs/debug/CrashHandler.kt +++ b/app/src/main/java/com/app/paperstow/debug/CrashHandler.kt @@ -1,23 +1,19 @@ -package com.app.traveldocs.debug +package com.app.paperstow.debug -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build -import androidx.core.app.NotificationCompat import java.io.PrintWriter import java.io.StringWriter /** * Global uncaught exception handler that: * 1. Logs crashes to DebugLogger (on-device debug log file) - * 2. Shows a notification offering to send crash feedback to the developer + * 2. Stores a local crash report the user can share later * 3. Delegates to the default handler (system crash dialog) * - * Play Store compliance: Google Play Console automatically collects crash reports - * from users who opt in. This handler adds a local notification as a secondary channel. + * Play Console collects crash reports from users who opt in. This handler does + * not show notifications (no POST_NOTIFICATIONS permission). */ class CrashHandler( private val context: Context, @@ -45,7 +41,7 @@ class CrashHandler( // Build crash report summary for feedback val crashSummary = buildString { - appendLine("App: Document Manager") + appendLine("App: Paperstow") appendLine("Version: ${getAppVersion()}") appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") @@ -56,12 +52,8 @@ class CrashHandler( append(stackTrace.take(2000)) } - // Store crash report for later retrieval (user can send it on next launch) storeCrashReport(crashSummary) - // Show notification offering to send feedback (non-blocking) - showCrashNotification(crashSummary) - } catch (_: Exception) { // Don't let logging crash the crash handler } @@ -84,38 +76,8 @@ class CrashHandler( } catch (_: Exception) { } } - private fun showCrashNotification(crashSummary: String) { - try { - val channelId = "crash_feedback" - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel(channelId, "Crash Reports", NotificationManager.IMPORTANCE_HIGH) - channel.description = "Notifications when the app crashes, offering to send feedback" - notificationManager.createNotificationChannel(channel) - } - - val emailIntent = createFeedbackIntent(crashSummary) - val pendingIntent = PendingIntent.getActivity( - context, 0, emailIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Document Manager crashed") - .setContentText("Tap to send crash report to developer") - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .build() - - notificationManager.notify(9999, notification) - } catch (_: Exception) { } - } - companion object { - private const val DEVELOPER_EMAIL = "support@docvault.app" + private const val ISSUES_URL = "https://github.com/sethusrinivasan/document-manager/issues" fun install(context: Context) { val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() @@ -130,10 +92,9 @@ class CrashHandler( */ fun createFeedbackIntent(crashReport: String): Intent { return Intent(Intent.ACTION_SEND).apply { - type = "message/rfc822" - putExtra(Intent.EXTRA_EMAIL, arrayOf(DEVELOPER_EMAIL)) - putExtra(Intent.EXTRA_SUBJECT, "Document Manager - Crash Report") - putExtra(Intent.EXTRA_TEXT, crashReport) + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Paperstow - Crash Report") + putExtra(Intent.EXTRA_TEXT, crashReport + "\n\nFile at: $ISSUES_URL") addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } } diff --git a/app/src/main/java/com/app/traveldocs/debug/DebugLogScreen.kt b/app/src/main/java/com/app/paperstow/debug/DebugLogScreen.kt similarity index 99% rename from app/src/main/java/com/app/traveldocs/debug/DebugLogScreen.kt rename to app/src/main/java/com/app/paperstow/debug/DebugLogScreen.kt index b2f738f..72fa018 100644 --- a/app/src/main/java/com/app/traveldocs/debug/DebugLogScreen.kt +++ b/app/src/main/java/com/app/paperstow/debug/DebugLogScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.debug +package com.app.paperstow.debug import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll diff --git a/app/src/main/java/com/app/traveldocs/debug/DebugLogger.kt b/app/src/main/java/com/app/paperstow/debug/DebugLogger.kt similarity index 84% rename from app/src/main/java/com/app/traveldocs/debug/DebugLogger.kt rename to app/src/main/java/com/app/paperstow/debug/DebugLogger.kt index 5f5de43..49e0c0c 100644 --- a/app/src/main/java/com/app/traveldocs/debug/DebugLogger.kt +++ b/app/src/main/java/com/app/paperstow/debug/DebugLogger.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.debug +package com.app.paperstow.debug import android.content.Context import android.util.Log @@ -18,10 +18,10 @@ import java.util.concurrent.ConcurrentLinkedDeque * Captures logs to: * 1. Android Logcat (standard, filtered by tag "TravelDocs") * 2. In-memory ring buffer (last 500 entries, viewable in-app) - * 3. File on device: /data/data/com.app.traveldocs/files/debug_logs/traveldocs_debug.log + * 3. File on device: /data/data/com.app.paperstow/files/debug_logs/traveldocs_debug.log * * To pull logs via adb: - * adb shell run-as com.app.traveldocs cat files/debug_logs/traveldocs_debug.log > local_debug.log + * adb shell run-as com.app.paperstow cat files/debug_logs/traveldocs_debug.log > local_debug.log * * Or from the app's "Debug Logs" screen accessible via the floating bug icon. */ @@ -64,7 +64,7 @@ object DebugLogger { * Initialize the file-based logger. Call from Application.onCreate(). */ fun init(context: Context) { - if (!com.app.traveldocs.BuildConfig.DEBUG) { + if (!com.app.paperstow.BuildConfig.DEBUG) { initialized = true return // No file logging in release builds } @@ -102,16 +102,22 @@ object DebugLogger { val throwableStr = throwable?.let { "${it::class.simpleName}: ${it.message}" } val entry = LogEntry(timestamp, level, component, message, throwableStr) - // 1. Logcat (fast, never blocks) + // 1. Logcat — errors only in release (no verbose or location-adjacent noise) val logcatMsg = "[$component] $message" - when (level) { - Level.DEBUG -> Log.d(TAG, logcatMsg, throwable) - Level.INFO -> Log.i(TAG, logcatMsg, throwable) - Level.WARN -> Log.w(TAG, logcatMsg, throwable) - Level.ERROR -> Log.e(TAG, logcatMsg, throwable) + if (com.app.paperstow.BuildConfig.DEBUG) { + when (level) { + Level.DEBUG -> Log.d(TAG, logcatMsg, throwable) + Level.INFO -> Log.i(TAG, logcatMsg, throwable) + Level.WARN -> Log.w(TAG, logcatMsg, throwable) + Level.ERROR -> Log.e(TAG, logcatMsg, throwable) + } + } else if (level == Level.ERROR) { + Log.e(TAG, logcatMsg, throwable) } - // 2. In-memory buffer (fast, lock-free ConcurrentLinkedDeque) + if (!com.app.paperstow.BuildConfig.DEBUG) return + + // 2. In-memory buffer (debug builds only) buffer.addLast(entry) while (buffer.size > MAX_BUFFER_SIZE) { buffer.pollFirst() diff --git a/app/src/main/java/com/app/traveldocs/debug/LoggingInterceptors.kt b/app/src/main/java/com/app/paperstow/debug/LoggingInterceptors.kt similarity index 98% rename from app/src/main/java/com/app/traveldocs/debug/LoggingInterceptors.kt rename to app/src/main/java/com/app/paperstow/debug/LoggingInterceptors.kt index f7fb690..03e95f3 100644 --- a/app/src/main/java/com/app/traveldocs/debug/LoggingInterceptors.kt +++ b/app/src/main/java/com/app/paperstow/debug/LoggingInterceptors.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.debug +package com.app.paperstow.debug /** * Extension functions and utilities for instrumenting key operations with debug logging. diff --git a/app/src/main/java/com/app/traveldocs/debug/TempFileCleanup.kt b/app/src/main/java/com/app/paperstow/debug/TempFileCleanup.kt similarity index 94% rename from app/src/main/java/com/app/traveldocs/debug/TempFileCleanup.kt rename to app/src/main/java/com/app/paperstow/debug/TempFileCleanup.kt index b771b75..f111d00 100644 --- a/app/src/main/java/com/app/traveldocs/debug/TempFileCleanup.kt +++ b/app/src/main/java/com/app/paperstow/debug/TempFileCleanup.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.debug +package com.app.paperstow.debug import android.content.Context import java.io.File diff --git a/app/src/main/java/com/app/traveldocs/debug/UsageTelemetry.kt b/app/src/main/java/com/app/paperstow/debug/UsageTelemetry.kt similarity index 96% rename from app/src/main/java/com/app/traveldocs/debug/UsageTelemetry.kt rename to app/src/main/java/com/app/paperstow/debug/UsageTelemetry.kt index 3259e95..f274751 100644 --- a/app/src/main/java/com/app/traveldocs/debug/UsageTelemetry.kt +++ b/app/src/main/java/com/app/paperstow/debug/UsageTelemetry.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.debug +package com.app.paperstow.debug import java.text.SimpleDateFormat import java.util.Date @@ -57,6 +57,7 @@ object UsageTelemetry { * Log when user leaves a screen. */ fun screenExit(screenName: String) { + if (!isEnabled()) return val entryTime = screenEntryTime[screenName] val durationMs = if (entryTime != null) System.currentTimeMillis() - entryTime else 0 DebugLogger.d(TAG, "SCREEN_EXIT: $screenName (duration=${durationMs}ms)") @@ -76,6 +77,7 @@ object UsageTelemetry { * Log when user completes a multi-step flow. */ fun funnelComplete(funnelName: String) { + if (!isEnabled()) return funnelCompletes.getOrPut(funnelName) { AtomicInteger(0) }.incrementAndGet() val starts = funnelStarts[funnelName]?.get() ?: 0 val completes = funnelCompletes[funnelName]?.get() ?: 0 @@ -87,6 +89,7 @@ object UsageTelemetry { * Log when user abandons a flow without completing. */ fun funnelAbandon(funnelName: String, reason: String = "") { + if (!isEnabled()) return val detail = if (reason.isNotBlank()) " reason=$reason" else "" DebugLogger.d(TAG, "FUNNEL_ABANDON: $funnelName$detail") } @@ -106,6 +109,7 @@ object UsageTelemetry { * Log an error encountered by the user. */ fun userError(feature: String, errorType: String) { + if (!isEnabled()) return DebugLogger.d(TAG, "USER_ERROR: [$feature] $errorType") } @@ -113,6 +117,7 @@ object UsageTelemetry { * Log a timing measurement for a user-facing operation. */ fun timing(feature: String, operation: String, durationMs: Long) { + if (!isEnabled()) return DebugLogger.d(TAG, "TIMING: [$feature] $operation took ${durationMs}ms") } @@ -120,6 +125,7 @@ object UsageTelemetry { * Emit session summary (call on app background or periodic). */ fun emitSessionSummary() { + if (!isEnabled()) return val sessionDuration = (System.currentTimeMillis() - sessionStart) / 1000 val totalActions = actionCount.get() @@ -173,7 +179,7 @@ object UsageTelemetry { fun getLocalSummary(): String { val sessionDuration = (System.currentTimeMillis() - sessionStart) / 1000 return buildString { - appendLine("=== Document Manager Telemetry Report ===") + appendLine("=== Paperstow Telemetry Report ===") appendLine("Generated: ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date())}") appendLine("Session duration: ${sessionDuration}s") appendLine("Total actions: ${actionCount.get()}") diff --git a/app/src/main/java/com/app/traveldocs/di/.gitkeep b/app/src/main/java/com/app/paperstow/di/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/di/.gitkeep rename to app/src/main/java/com/app/paperstow/di/.gitkeep diff --git a/app/src/main/java/com/app/traveldocs/di/AppModule.kt b/app/src/main/java/com/app/paperstow/di/AppModule.kt similarity index 59% rename from app/src/main/java/com/app/traveldocs/di/AppModule.kt rename to app/src/main/java/com/app/paperstow/di/AppModule.kt index 5c10bad..6d71b09 100644 --- a/app/src/main/java/com/app/traveldocs/di/AppModule.kt +++ b/app/src/main/java/com/app/paperstow/di/AppModule.kt @@ -1,33 +1,33 @@ -package com.app.traveldocs.di +package com.app.paperstow.di import android.content.Context import androidx.room.Room -import com.app.traveldocs.data.local.DocumentRepositoryImpl -import com.app.traveldocs.data.local.SearchEngineImpl -import com.app.traveldocs.data.local.TagRepositoryImpl -import com.app.traveldocs.data.local.TravelDocsDatabase -import com.app.traveldocs.data.local.auth.AuthRepositoryImpl -import com.app.traveldocs.data.local.auth.AuthSessionManager -import com.app.traveldocs.data.local.dao.DocumentDao -import com.app.traveldocs.data.local.dao.DocumentMetadataDao -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.dao.FamilyMemberDao -import com.app.traveldocs.data.local.dao.GpsTrackDao -import com.app.traveldocs.data.local.storage.DocumentFileStorageImpl -import com.app.traveldocs.data.nlp.BasicDocumentChecklistGenerator -import com.app.traveldocs.data.nlp.RegexNaturalLanguageParser -import com.app.traveldocs.data.scanner.MlKitMetadataExtractor -import com.app.traveldocs.data.tags.AutoTagGeneratorImpl -import com.app.traveldocs.domain.repository.AuthRepository -import com.app.traveldocs.domain.repository.AutoTagGenerator -import com.app.traveldocs.domain.repository.DocumentChecklistGenerator -import com.app.traveldocs.domain.repository.DocumentFileStorage -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.repository.MetadataExtractor -import com.app.traveldocs.domain.repository.NaturalLanguageParser -import com.app.traveldocs.domain.repository.SearchEngine -import com.app.traveldocs.domain.repository.SessionManager -import com.app.traveldocs.domain.repository.TagRepository +import com.app.paperstow.data.local.DocumentRepositoryImpl +import com.app.paperstow.data.local.SearchEngineImpl +import com.app.paperstow.data.local.TagRepositoryImpl +import com.app.paperstow.data.local.TravelDocsDatabase +import com.app.paperstow.data.local.auth.AuthRepositoryImpl +import com.app.paperstow.data.local.auth.AuthSessionManager +import com.app.paperstow.data.local.dao.DocumentDao +import com.app.paperstow.data.local.dao.DocumentMetadataDao +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.dao.FamilyMemberDao +import com.app.paperstow.data.local.dao.GpsTrackDao +import com.app.paperstow.data.local.storage.DocumentFileStorageImpl +import com.app.paperstow.data.nlp.BasicDocumentChecklistGenerator +import com.app.paperstow.data.nlp.RegexNaturalLanguageParser +import com.app.paperstow.data.scanner.MlKitMetadataExtractor +import com.app.paperstow.data.tags.AutoTagGeneratorImpl +import com.app.paperstow.domain.repository.AuthRepository +import com.app.paperstow.domain.repository.AutoTagGenerator +import com.app.paperstow.domain.repository.DocumentChecklistGenerator +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.repository.MetadataExtractor +import com.app.paperstow.domain.repository.NaturalLanguageParser +import com.app.paperstow.domain.repository.SearchEngine +import com.app.paperstow.domain.repository.SessionManager +import com.app.paperstow.domain.repository.TagRepository import dagger.Binds import dagger.Module import dagger.Provides @@ -61,7 +61,7 @@ object DatabaseModule { context, TravelDocsDatabase::class.java, "traveldocs.db" - ).build() + ).addMigrations(TravelDocsDatabase.MIGRATION_2_3, TravelDocsDatabase.MIGRATION_3_4).build() } @Provides fun provideDocumentDao(db: TravelDocsDatabase): DocumentDao = db.documentDao() diff --git a/app/src/main/java/com/app/traveldocs/domain/model/.gitkeep b/app/src/main/java/com/app/paperstow/domain/model/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/domain/model/.gitkeep rename to app/src/main/java/com/app/paperstow/domain/model/.gitkeep diff --git a/app/src/main/java/com/app/traveldocs/domain/model/AppError.kt b/app/src/main/java/com/app/paperstow/domain/model/AppError.kt similarity index 91% rename from app/src/main/java/com/app/traveldocs/domain/model/AppError.kt rename to app/src/main/java/com/app/paperstow/domain/model/AppError.kt index c02ff88..39caffb 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/AppError.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/AppError.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model sealed class AppError { data class StorageError(val message: String, val cause: Throwable?) : AppError() diff --git a/app/src/main/java/com/app/traveldocs/domain/model/Document.kt b/app/src/main/java/com/app/paperstow/domain/model/Document.kt similarity index 90% rename from app/src/main/java/com/app/traveldocs/domain/model/Document.kt rename to app/src/main/java/com/app/paperstow/domain/model/Document.kt index 0f71b87..7050a89 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/Document.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/Document.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model import java.time.Instant diff --git a/app/src/main/java/com/app/paperstow/domain/model/DocumentFormat.kt b/app/src/main/java/com/app/paperstow/domain/model/DocumentFormat.kt new file mode 100644 index 0000000..8674cce --- /dev/null +++ b/app/src/main/java/com/app/paperstow/domain/model/DocumentFormat.kt @@ -0,0 +1,25 @@ +package com.app.paperstow.domain.model + +/** + * File formats Paperstow can stow. + * + * Core travel papers: PDF, JPEG, PNG, WEBP, HEIC, BMP, GIF, plain text, markdown, and GPX. + * VIDEO / AUDIO / DICOM remain in the enum so older stored files still open; + * they are not offered as import types. + */ +enum class DocumentFormat { + PDF, + JPG, + PNG, + TEXT, + MARKDOWN, + GPX, + WEBP, + HEIC, + BMP, + GIF, + VIDEO, + AUDIO, + DICOM, + UNKNOWN, +} diff --git a/app/src/main/java/com/app/traveldocs/domain/model/DocumentType.kt b/app/src/main/java/com/app/paperstow/domain/model/DocumentType.kt similarity index 74% rename from app/src/main/java/com/app/traveldocs/domain/model/DocumentType.kt rename to app/src/main/java/com/app/paperstow/domain/model/DocumentType.kt index bab43bb..98d2c71 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/DocumentType.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/DocumentType.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model enum class DocumentType { PASSPORT, diff --git a/app/src/main/java/com/app/traveldocs/domain/model/ExtractionResult.kt b/app/src/main/java/com/app/paperstow/domain/model/ExtractionResult.kt similarity index 68% rename from app/src/main/java/com/app/traveldocs/domain/model/ExtractionResult.kt rename to app/src/main/java/com/app/paperstow/domain/model/ExtractionResult.kt index 5566722..64ca383 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/ExtractionResult.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/ExtractionResult.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class ExtractedValue( val value: String, @@ -9,5 +9,6 @@ data class ExtractionResult( val documentType: DocumentType, val metadata: Map, val confidence: Float, - val requiresManualReview: Boolean + val requiresManualReview: Boolean, + val rawText: String = "" ) diff --git a/app/src/main/java/com/app/traveldocs/domain/model/ImportedDocument.kt b/app/src/main/java/com/app/paperstow/domain/model/ImportedDocument.kt similarity index 94% rename from app/src/main/java/com/app/traveldocs/domain/model/ImportedDocument.kt rename to app/src/main/java/com/app/paperstow/domain/model/ImportedDocument.kt index 683623e..2d31929 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/ImportedDocument.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/ImportedDocument.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class ImportedDocument( val rawBytes: ByteArray, diff --git a/app/src/main/java/com/app/traveldocs/domain/model/LockoutState.kt b/app/src/main/java/com/app/paperstow/domain/model/LockoutState.kt similarity index 74% rename from app/src/main/java/com/app/traveldocs/domain/model/LockoutState.kt rename to app/src/main/java/com/app/paperstow/domain/model/LockoutState.kt index 4aea6e4..52a1470 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/LockoutState.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/LockoutState.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class LockoutState( val isLocked: Boolean, diff --git a/app/src/main/java/com/app/paperstow/domain/model/MarkdownChecklist.kt b/app/src/main/java/com/app/paperstow/domain/model/MarkdownChecklist.kt new file mode 100644 index 0000000..38636be --- /dev/null +++ b/app/src/main/java/com/app/paperstow/domain/model/MarkdownChecklist.kt @@ -0,0 +1,85 @@ +package com.app.paperstow.domain.model + +/** + * Tiny markdown checklist used for plans and action items. + * Understands headings, notes, `- [ ]` / `- [x]`, and plain `-` / `*` bullets. + */ +data class ChecklistItem( + val text: String, + val done: Boolean = false +) + +data class MarkdownChecklist( + val title: String = "", + val notes: String = "", + val items: List = emptyList() +) { + fun toMarkdown(): String { + val parts = mutableListOf() + val heading = title.trim() + if (heading.isNotEmpty()) parts += "# $heading" + val body = notes.trim() + if (body.isNotEmpty()) { + if (parts.isNotEmpty()) parts += "" + parts += body + } + if (items.isNotEmpty()) { + if (parts.isNotEmpty()) parts += "" + items.forEach { item -> + val mark = if (item.done) "x" else " " + parts += "- [$mark] ${item.text.trim()}" + } + } + return parts.joinToString("\n") + if (parts.isEmpty()) "" else "\n" + } + + companion object { + private val heading = Regex("""^\s{0,3}#{1,6}\s+(.*)$""") + private val checkbox = Regex("""^\s*[-*+]\s+\[([ xX])]\s+(.*)$""") + private val bullet = Regex("""^\s*[-*+]\s+(.*)$""") + + fun parse(markdown: String): MarkdownChecklist { + val title = StringBuilder() + val notes = StringBuilder() + val items = mutableListOf() + markdown.replace("\r\n", "\n").lines().forEach { raw -> + val line = raw.trimEnd() + when { + title.isEmpty() && items.isEmpty() && notes.isEmpty() && heading.matches(line) -> + title.append(heading.matchEntire(line)!!.groupValues[1].trim()) + checkbox.matches(line) -> { + val match = checkbox.matchEntire(line)!! + items += ChecklistItem( + text = match.groupValues[2].trim(), + done = match.groupValues[1].equals("x", ignoreCase = true) + ) + } + bullet.matches(line) -> + items += ChecklistItem(text = bullet.matchEntire(line)!!.groupValues[1].trim()) + line.isBlank() -> { + if (notes.isNotEmpty() && !notes.endsWith("\n\n")) notes.append('\n') + } + else -> { + if (notes.isNotEmpty() && !notes.endsWith("\n")) notes.append('\n') + notes.append(line) + } + } + } + return MarkdownChecklist( + title = title.toString().trim(), + notes = notes.toString().trim(), + items = items + ) + } + + fun starter(title: String = "Trip plans"): MarkdownChecklist = MarkdownChecklist( + title = title, + notes = "Tick items as you finish them. Add your own below.", + items = listOf( + ChecklistItem("Confirm tickets and seats"), + ChecklistItem("Pack medicines and chargers"), + ChecklistItem("Share the itinerary") + ) + ) + } +} diff --git a/app/src/main/java/com/app/traveldocs/domain/model/MetadataField.kt b/app/src/main/java/com/app/paperstow/domain/model/MetadataField.kt similarity index 85% rename from app/src/main/java/com/app/traveldocs/domain/model/MetadataField.kt rename to app/src/main/java/com/app/paperstow/domain/model/MetadataField.kt index ff0c7ab..cc28294 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/MetadataField.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/MetadataField.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model enum class MetadataField { ID_NUMBER, diff --git a/app/src/main/java/com/app/traveldocs/domain/model/ParseResult.kt b/app/src/main/java/com/app/paperstow/domain/model/ParseResult.kt similarity index 76% rename from app/src/main/java/com/app/traveldocs/domain/model/ParseResult.kt rename to app/src/main/java/com/app/paperstow/domain/model/ParseResult.kt index fc38ce8..fc823a6 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/ParseResult.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/ParseResult.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class ParseResult( val intent: QueryIntent, diff --git a/app/src/main/java/com/app/traveldocs/domain/model/QueryIntent.kt b/app/src/main/java/com/app/paperstow/domain/model/QueryIntent.kt similarity index 69% rename from app/src/main/java/com/app/traveldocs/domain/model/QueryIntent.kt rename to app/src/main/java/com/app/paperstow/domain/model/QueryIntent.kt index 2453d3f..ca26a65 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/QueryIntent.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/QueryIntent.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model enum class QueryIntent { DOCUMENT_SEARCH, diff --git a/app/src/main/java/com/app/traveldocs/domain/model/SearchQuery.kt b/app/src/main/java/com/app/paperstow/domain/model/SearchQuery.kt similarity index 78% rename from app/src/main/java/com/app/traveldocs/domain/model/SearchQuery.kt rename to app/src/main/java/com/app/paperstow/domain/model/SearchQuery.kt index ce87d87..2e6c702 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/SearchQuery.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/SearchQuery.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class SearchQuery( val tags: List = emptyList(), diff --git a/app/src/main/java/com/app/traveldocs/domain/model/SearchResult.kt b/app/src/main/java/com/app/paperstow/domain/model/SearchResult.kt similarity index 87% rename from app/src/main/java/com/app/traveldocs/domain/model/SearchResult.kt rename to app/src/main/java/com/app/paperstow/domain/model/SearchResult.kt index 523acf0..b9fc8c5 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/SearchResult.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/SearchResult.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model sealed class SearchResult { data class DocumentResults(val documents: List) : SearchResult() diff --git a/app/src/main/java/com/app/traveldocs/domain/model/Tag.kt b/app/src/main/java/com/app/paperstow/domain/model/Tag.kt similarity index 64% rename from app/src/main/java/com/app/traveldocs/domain/model/Tag.kt rename to app/src/main/java/com/app/paperstow/domain/model/Tag.kt index 820c9db..6dfd6ce 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/Tag.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/Tag.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class Tag( val name: String, diff --git a/app/src/main/java/com/app/traveldocs/domain/model/TravelDocumentChecklist.kt b/app/src/main/java/com/app/paperstow/domain/model/TravelDocumentChecklist.kt similarity index 88% rename from app/src/main/java/com/app/traveldocs/domain/model/TravelDocumentChecklist.kt rename to app/src/main/java/com/app/paperstow/domain/model/TravelDocumentChecklist.kt index 2e32c13..9cdc3d2 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/TravelDocumentChecklist.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/TravelDocumentChecklist.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class RequiredDocument( val type: DocumentType, diff --git a/app/src/main/java/com/app/traveldocs/domain/model/TravelParameters.kt b/app/src/main/java/com/app/paperstow/domain/model/TravelParameters.kt similarity index 80% rename from app/src/main/java/com/app/traveldocs/domain/model/TravelParameters.kt rename to app/src/main/java/com/app/paperstow/domain/model/TravelParameters.kt index 146e66b..18514b6 100644 --- a/app/src/main/java/com/app/traveldocs/domain/model/TravelParameters.kt +++ b/app/src/main/java/com/app/paperstow/domain/model/TravelParameters.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model data class TravelParameters( val familySize: Int?, diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/AuthRepository.kt b/app/src/main/java/com/app/paperstow/domain/repository/AuthRepository.kt similarity index 81% rename from app/src/main/java/com/app/traveldocs/domain/repository/AuthRepository.kt rename to app/src/main/java/com/app/paperstow/domain/repository/AuthRepository.kt index e611264..655c18f 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/AuthRepository.kt @@ -1,6 +1,6 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository -import com.app.traveldocs.domain.model.LockoutState +import com.app.paperstow.domain.model.LockoutState interface AuthRepository { suspend fun createPin(memberId: String, pin: String): Result diff --git a/app/src/main/java/com/app/paperstow/domain/repository/AutoTagGenerator.kt b/app/src/main/java/com/app/paperstow/domain/repository/AutoTagGenerator.kt new file mode 100644 index 0000000..befcc5f --- /dev/null +++ b/app/src/main/java/com/app/paperstow/domain/repository/AutoTagGenerator.kt @@ -0,0 +1,9 @@ +package com.app.paperstow.domain.repository + +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractedValue +import com.app.paperstow.domain.model.MetadataField + +interface AutoTagGenerator { + fun generateTags(documentType: DocumentType, metadata: Map): List +} diff --git a/app/src/main/java/com/app/paperstow/domain/repository/DocumentChecklistGenerator.kt b/app/src/main/java/com/app/paperstow/domain/repository/DocumentChecklistGenerator.kt new file mode 100644 index 0000000..5da966e --- /dev/null +++ b/app/src/main/java/com/app/paperstow/domain/repository/DocumentChecklistGenerator.kt @@ -0,0 +1,11 @@ +package com.app.paperstow.domain.repository + +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.MissingDocument +import com.app.paperstow.domain.model.TravelDocumentChecklist +import com.app.paperstow.domain.model.TravelParameters + +interface DocumentChecklistGenerator { + fun generateChecklist(params: TravelParameters): TravelDocumentChecklist + fun detectMissing(checklist: TravelDocumentChecklist, existingDocuments: List): List +} diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentFileStorage.kt b/app/src/main/java/com/app/paperstow/domain/repository/DocumentFileStorage.kt similarity index 59% rename from app/src/main/java/com/app/traveldocs/domain/repository/DocumentFileStorage.kt rename to app/src/main/java/com/app/paperstow/domain/repository/DocumentFileStorage.kt index 7ec732b..79dd5c0 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentFileStorage.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/DocumentFileStorage.kt @@ -1,9 +1,10 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository -import com.app.traveldocs.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentFormat interface DocumentFileStorage { suspend fun store(memberId: String, fileData: ByteArray, format: DocumentFormat): Result suspend fun retrieve(fileId: String): Result + suspend fun replace(fileId: String, fileData: ByteArray): Result suspend fun secureDelete(fileId: String): Result } diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentImporter.kt b/app/src/main/java/com/app/paperstow/domain/repository/DocumentImporter.kt similarity index 66% rename from app/src/main/java/com/app/traveldocs/domain/repository/DocumentImporter.kt rename to app/src/main/java/com/app/paperstow/domain/repository/DocumentImporter.kt index 41d6726..52d49ca 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentImporter.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/DocumentImporter.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository import android.net.Uri -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.ImportedDocument +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.ImportedDocument interface DocumentImporter { suspend fun importFromCamera(): Result diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentRepository.kt b/app/src/main/java/com/app/paperstow/domain/repository/DocumentRepository.kt similarity index 54% rename from app/src/main/java/com/app/traveldocs/domain/repository/DocumentRepository.kt rename to app/src/main/java/com/app/paperstow/domain/repository/DocumentRepository.kt index 800a4a7..c85c1b6 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentRepository.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/DocumentRepository.kt @@ -1,14 +1,17 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.SearchQuery +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.SearchQuery import kotlinx.coroutines.flow.Flow +import java.time.Instant interface DocumentRepository { suspend fun getAll(memberId: String): Flow> suspend fun getById(documentId: String): Document? suspend fun insert(document: Document): Result + suspend fun updateName(documentId: String, originalFileName: String, updatedAt: Instant): Result suspend fun delete(documentId: String): Result suspend fun getCount(memberId: String): Int suspend fun search(memberId: String, query: SearchQuery): List + suspend fun updateOcrText(documentId: String, ocrText: String): Result } diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/MetadataExtractor.kt b/app/src/main/java/com/app/paperstow/domain/repository/MetadataExtractor.kt similarity index 55% rename from app/src/main/java/com/app/traveldocs/domain/repository/MetadataExtractor.kt rename to app/src/main/java/com/app/paperstow/domain/repository/MetadataExtractor.kt index a3f34ec..5a87e08 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/MetadataExtractor.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/MetadataExtractor.kt @@ -1,7 +1,7 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractionResult +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractionResult interface MetadataExtractor { suspend fun extract(imageData: ByteArray): Result diff --git a/app/src/main/java/com/app/paperstow/domain/repository/NaturalLanguageParser.kt b/app/src/main/java/com/app/paperstow/domain/repository/NaturalLanguageParser.kt new file mode 100644 index 0000000..30a7c41 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/domain/repository/NaturalLanguageParser.kt @@ -0,0 +1,7 @@ +package com.app.paperstow.domain.repository + +import com.app.paperstow.domain.model.ParseResult + +interface NaturalLanguageParser { + fun parse(query: String): ParseResult +} diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/SearchEngine.kt b/app/src/main/java/com/app/paperstow/domain/repository/SearchEngine.kt similarity index 65% rename from app/src/main/java/com/app/traveldocs/domain/repository/SearchEngine.kt rename to app/src/main/java/com/app/paperstow/domain/repository/SearchEngine.kt index b379b04..e0da936 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/SearchEngine.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/SearchEngine.kt @@ -1,7 +1,7 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.SearchResult +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.SearchResult interface SearchEngine { suspend fun searchByTags(memberId: String, tags: List): List diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/SessionManager.kt b/app/src/main/java/com/app/paperstow/domain/repository/SessionManager.kt similarity index 84% rename from app/src/main/java/com/app/traveldocs/domain/repository/SessionManager.kt rename to app/src/main/java/com/app/paperstow/domain/repository/SessionManager.kt index 4271331..c823422 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/SessionManager.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/SessionManager.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/TagRepository.kt b/app/src/main/java/com/app/paperstow/domain/repository/TagRepository.kt similarity index 69% rename from app/src/main/java/com/app/traveldocs/domain/repository/TagRepository.kt rename to app/src/main/java/com/app/paperstow/domain/repository/TagRepository.kt index 59212de..f4273bb 100644 --- a/app/src/main/java/com/app/traveldocs/domain/repository/TagRepository.kt +++ b/app/src/main/java/com/app/paperstow/domain/repository/TagRepository.kt @@ -1,6 +1,6 @@ -package com.app.traveldocs.domain.repository +package com.app.paperstow.domain.repository -import com.app.traveldocs.domain.model.Tag +import com.app.paperstow.domain.model.Tag interface TagRepository { suspend fun getTagsForDocument(documentId: String): List @@ -8,4 +8,5 @@ interface TagRepository { suspend fun removeTag(documentId: String, tagName: String): Result suspend fun deleteTagGlobally(memberId: String, tagName: String): Result suspend fun getAllTags(memberId: String): List + suspend fun createStandaloneTag(memberId: String, tagName: String): Result } diff --git a/app/src/main/java/com/app/paperstow/domain/safety/GpxTrail.kt b/app/src/main/java/com/app/paperstow/domain/safety/GpxTrail.kt new file mode 100644 index 0000000..4a9b4d4 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/domain/safety/GpxTrail.kt @@ -0,0 +1,93 @@ +package com.app.paperstow.domain.safety + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * GPX 1.1 write/read for My Trail points. Keeps the file plain so a backup ZIP + * can be unzipped and opened in any map app. + */ +object GpxTrail { + fun toGpx(placesOldestFirst: List, name: String = "My Trail"): String { + val iso = isoFormatter() + val body = StringBuilder() + body.appendLine("") + body.appendLine("") + body.appendLine(" ") + body.appendLine(" ${escape(name)}") + body.appendLine(" ") + body.appendLine(" ") + body.appendLine(" ${escape(name)}") + body.appendLine(" ") + placesOldestFirst.forEach { place -> + body.appendLine( + " " + ) + body.appendLine(" ") + if (place.batteryPercent in 0..100) { + body.appendLine(" battery ${place.batteryPercent}%") + } + body.appendLine(" ") + } + body.appendLine(" ") + body.appendLine(" ") + body.appendLine("") + return body.toString() + } + + fun parse(xml: String): List { + val points = mutableListOf() + val point = Regex( + """<(?:trkpt|wpt)\s+lat="([+-]?\d+(?:\.\d+)?)"\s+lon="([+-]?\d+(?:\.\d+)?)"\s*>(.*?)""", + setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE) + ) + val timeTag = Regex("""""", RegexOption.IGNORE_CASE) + val batteryTag = Regex("""battery\s+(\d{1,3})\s*%""", RegexOption.IGNORE_CASE) + point.findAll(xml).forEach { match -> + val lat = match.groupValues[1].toDoubleOrNull() ?: return@forEach + val lon = match.groupValues[2].toDoubleOrNull() ?: return@forEach + val inner = match.groupValues[3] + val time = timeTag.find(inner)?.groupValues?.get(1)?.let { parseIso(it) } ?: 0L + val battery = batteryTag.find(inner)?.groupValues?.get(1)?.toIntOrNull() ?: -1 + points += SafetyPlace(lat, lon, time, battery) + } + return points + } + + fun looksLikeGpx(bytes: ByteArray): Boolean { + if (bytes.isEmpty()) return false + val head = String(bytes, 0, minOf(bytes.size, 800), Charsets.UTF_8) + return head.contains("", ">") + .replace("\"", """) + + private fun isoFormatter(): SimpleDateFormat = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private fun parseIso(value: String): Long { + val cleaned = value.trim() + val patterns = listOf( + "yyyy-MM-dd'T'HH:mm:ss'Z'", + "yyyy-MM-dd'T'HH:mm:ssX", + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + ) + for (pattern in patterns) { + try { + val fmt = SimpleDateFormat(pattern, Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return fmt.parse(cleaned)?.time ?: continue + } catch (_: Exception) { + // try next pattern + } + } + return 0L + } +} diff --git a/app/src/main/java/com/app/paperstow/domain/safety/UniqueLocations.kt b/app/src/main/java/com/app/paperstow/domain/safety/UniqueLocations.kt new file mode 100644 index 0000000..86cedb5 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/domain/safety/UniqueLocations.kt @@ -0,0 +1,105 @@ +package com.app.paperstow.domain.safety + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.math.asin +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +data class SafetyPlace( + val latitude: Double, + val longitude: Double, + val timestamp: Long, + val batteryPercent: Int = -1 +) + +/** + * Unique places for My Trail: keep a new point only when the device + * has moved far enough, and drop anything older than 24 hours. + */ +object UniqueLocations { + const val MIN_SEPARATION_METERS = 150.0 + const val WINDOW_MS = 24L * 60 * 60 * 1000 + const val MAX_ACCURACY_METERS = 500f + + fun metersBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val earthMeters = 6_371_000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2) + return 2 * earthMeters * asin(sqrt(a.coerceIn(0.0, 1.0))) + } + + fun isNewPlace( + lastLat: Double, + lastLon: Double, + nextLat: Double, + nextLon: Double, + minMeters: Double = MIN_SEPARATION_METERS + ): Boolean = metersBetween(lastLat, lastLon, nextLat, nextLon) >= minMeters + + fun inWindow( + places: List, + nowMs: Long = System.currentTimeMillis(), + windowMs: Long = WINDOW_MS + ): List = places.filter { nowMs - it.timestamp <= windowMs } + + fun mapsUrl(latitude: Double, longitude: Double): String = + "https://maps.google.com/?q=$latitude,$longitude" + + fun shareAll( + placesNewestFirst: List, + locale: Locale = Locale.getDefault(), + timeZone: TimeZone = TimeZone.getDefault() + ): String { + if (placesNewestFirst.isEmpty()) { + return "Paperstow — no unique places in My Trail on this phone." + } + val fmt = timeFormatter(locale, timeZone) + val last = placesNewestFirst.first() + val sb = StringBuilder() + sb.appendLine("Paperstow — My Trail (this phone only)") + sb.appendLine() + sb.appendLine("Last known (${fmt.format(Date(last.timestamp))})${batterySuffix(last)}:") + sb.appendLine(mapsUrl(last.latitude, last.longitude)) + val earlier = placesNewestFirst.drop(1) + if (earlier.isNotEmpty()) { + sb.appendLine() + sb.appendLine("Earlier:") + earlier.forEach { place -> + sb.appendLine("• ${fmt.format(Date(place.timestamp))}${batterySuffix(place)} — ${mapsUrl(place.latitude, place.longitude)}") + } + } + return sb.toString().trimEnd() + } + + fun shareLastKnown( + place: SafetyPlace, + locale: Locale = Locale.getDefault(), + timeZone: TimeZone = TimeZone.getDefault() + ): String { + val fmt = timeFormatter(locale, timeZone) + return buildString { + appendLine("Paperstow — last known place") + appendLine("${fmt.format(Date(place.timestamp))}${batterySuffix(place)}") + append(mapsUrl(place.latitude, place.longitude)) + }.trimEnd() + } + + fun batteryLabel(percent: Int): String? = + if (percent in 0..100) "Battery $percent%" else null + + private fun batterySuffix(place: SafetyPlace): String { + val label = batteryLabel(place.batteryPercent) ?: return "" + return " · $label" + } + + private fun timeFormatter(locale: Locale, timeZone: TimeZone): SimpleDateFormat { + return SimpleDateFormat("h:mm a", locale).apply { this.timeZone = timeZone } + } +} diff --git a/app/src/main/java/com/app/traveldocs/domain/usecase/.gitkeep b/app/src/main/java/com/app/paperstow/domain/usecase/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/domain/usecase/.gitkeep rename to app/src/main/java/com/app/paperstow/domain/usecase/.gitkeep diff --git a/app/src/main/java/com/app/traveldocs/domain/usecase/DocumentImportUseCase.kt b/app/src/main/java/com/app/paperstow/domain/usecase/DocumentImportUseCase.kt similarity index 63% rename from app/src/main/java/com/app/traveldocs/domain/usecase/DocumentImportUseCase.kt rename to app/src/main/java/com/app/paperstow/domain/usecase/DocumentImportUseCase.kt index 89bc786..4c683d7 100644 --- a/app/src/main/java/com/app/traveldocs/domain/usecase/DocumentImportUseCase.kt +++ b/app/src/main/java/com/app/paperstow/domain/usecase/DocumentImportUseCase.kt @@ -1,16 +1,18 @@ -package com.app.traveldocs.domain.usecase +package com.app.paperstow.domain.usecase -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractionResult -import com.app.traveldocs.domain.model.ImportedDocument -import com.app.traveldocs.domain.model.MetadataField -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.repository.AutoTagGenerator -import com.app.traveldocs.domain.repository.DocumentFileStorage -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.repository.MetadataExtractor +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractionResult +import com.app.paperstow.domain.model.ImportedDocument +import com.app.paperstow.domain.model.MetadataField +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.repository.AutoTagGenerator +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.repository.MetadataExtractor +import dagger.hilt.android.qualifiers.ApplicationContext +import android.content.Context import java.time.Instant import java.util.UUID import javax.inject.Inject @@ -28,6 +30,7 @@ import javax.inject.Inject * Pipeline steps are logged extensively for debugging large imports. */ class DocumentImportUseCase @Inject constructor( + @ApplicationContext private val context: Context, private val fileStorage: DocumentFileStorage, private val metadataExtractor: MetadataExtractor, private val documentRepository: DocumentRepository, @@ -53,11 +56,11 @@ class DocumentImportUseCase @Inject constructor( // Step 2: Extract metadata (graceful degradation on failure) DebugLogger.d("Import", "Step 2: Extracting metadata via ML Kit OCR...") val extractionResult = try { - val result = metadataExtractor.extract(importedDocument.rawBytes).getOrNull() + val result = ocrDocument(importedDocument.format, importedDocument.rawBytes) if (result != null) { - DebugLogger.i("Import", "Step 2 OK: type=${result.documentType}, confidence=${result.confidence}, fields=${result.metadata.size}, manualReview=${result.requiresManualReview}") + DebugLogger.i("Import", "Step 2 OK: type=${result.documentType}, confidence=${result.confidence}, fields=${result.metadata.size}, textChars=${result.rawText.length}") } else { - DebugLogger.w("Import", "Step 2: Extraction returned null (graceful degradation)") + DebugLogger.w("Import", "Step 2: No OCR text (format=${importedDocument.format})") } result } catch (e: Exception) { @@ -83,10 +86,10 @@ class DocumentImportUseCase @Inject constructor( } // Step 3b: Tag unsupported formats so they're easy to find - val formatTags = if (importedDocument.format == com.app.traveldocs.domain.model.DocumentFormat.AUDIO) { - autoTags + com.app.traveldocs.domain.model.Tag(name = "__AUDIO", isAutoGenerated = true) - } else if (importedDocument.format == com.app.traveldocs.domain.model.DocumentFormat.UNKNOWN) { - autoTags + com.app.traveldocs.domain.model.Tag(name = "__UNSUPPORTED", isAutoGenerated = true) + val formatTags = if (importedDocument.format == com.app.paperstow.domain.model.DocumentFormat.AUDIO) { + autoTags + com.app.paperstow.domain.model.Tag(name = "__AUDIO", isAutoGenerated = true) + } else if (importedDocument.format == com.app.paperstow.domain.model.DocumentFormat.UNKNOWN) { + autoTags + com.app.paperstow.domain.model.Tag(name = "__UNSUPPORTED", isAutoGenerated = true) } else autoTags // Step 4: Build and store the Document @@ -107,6 +110,16 @@ class DocumentImportUseCase @Inject constructor( ) val insertResult = documentRepository.insert(document) + insertResult.onSuccess { + val indexText = com.app.paperstow.data.local.SearchIndex.textForImport( + importedDocument.format, + importedDocument.rawBytes, + extractionResult?.rawText.orEmpty() + ) + if (indexText.isNotBlank()) { + documentRepository.updateOcrText(document.id, indexText) + } + } val elapsed = System.currentTimeMillis() - startTime insertResult.onSuccess { DebugLogger.i("Import", "═══ Pipeline COMPLETE in ${elapsed}ms: docId=${document.id}, type=${document.type}") @@ -116,4 +129,21 @@ class DocumentImportUseCase @Inject constructor( } return insertResult.map { document } } + + private suspend fun ocrDocument( + format: com.app.paperstow.domain.model.DocumentFormat, + bytes: ByteArray + ): ExtractionResult? { + val pages = com.app.paperstow.data.local.SearchIndex.imagesForOcr(format, bytes, context.cacheDir) + if (pages.isEmpty()) return null + val results = pages.mapNotNull { page -> + val outcome = metadataExtractor.extract(page) + outcome.onFailure { e -> DebugLogger.w("Import", "OCR page failed: ${e.message}") } + outcome.getOrNull() + } + if (results.isEmpty()) return null + val best = results.maxBy { it.confidence } + val combined = results.joinToString("\n\n") { it.rawText }.trim() + return best.copy(rawText = combined) + } } diff --git a/app/src/main/java/com/app/paperstow/presentation/MainActivity.kt b/app/src/main/java/com/app/paperstow/presentation/MainActivity.kt new file mode 100644 index 0000000..7f9e020 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/MainActivity.kt @@ -0,0 +1,727 @@ +package com.app.paperstow.presentation + +import java.io.File +import android.content.Context +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.FlightTakeoff +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.Hotel +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.AirplanemodeActive +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.LocalHospital +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Sell +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.debug.TempFileCleanup +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.presentation.documents.DocumentListScreen +import com.app.paperstow.presentation.documents.DocumentListViewModel +import com.app.paperstow.presentation.documents.DocumentViewerScreen +import com.app.paperstow.presentation.documents.ImportScreen +import com.app.paperstow.presentation.search.SearchScreen +import com.app.paperstow.presentation.settings.SettingsScreen +import com.app.paperstow.data.local.TagColorStore +import com.app.paperstow.presentation.tags.TagManagementScreen +import com.app.paperstow.presentation.tags.TagCover +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.filled.CheckCircle +import com.app.paperstow.presentation.demo.DemoDataViewModel +import com.app.paperstow.presentation.documents.ImportViewModel +import com.app.paperstow.BuildConfig +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : androidx.fragment.app.FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DebugLogger.i("MainActivity", "onCreate") + val telemetryConsented = getSharedPreferences("disclaimer_prefs", MODE_PRIVATE) + .getBoolean("telemetry_consented", false) + val settingsTelemetry = getSharedPreferences("app_settings", MODE_PRIVATE) + .getBoolean("telemetry_enabled", false) + com.app.paperstow.debug.UsageTelemetry.setConsent(telemetryConsented || settingsTelemetry) + com.app.paperstow.debug.UsageTelemetry.resetSession() + + setContent { + val eulaPrefs = remember { getSharedPreferences("eula_prefs", MODE_PRIVATE) } + val splashPrefs = remember { getSharedPreferences("splash_prefs", MODE_PRIVATE) } + val disclaimerPrefs = remember { getSharedPreferences("disclaimer_prefs", MODE_PRIVATE) } + // Check if launched via share intent (another app shared a doc to us) + val sharedUri = remember { + val action = intent?.action + if (action == android.content.Intent.ACTION_SEND) { + intent?.getParcelableExtra(android.content.Intent.EXTRA_STREAM) + } else if (action == android.content.Intent.ACTION_VIEW) { + intent?.data + } else null + } + + val initialScreen = when { + !eulaPrefs.getBoolean("eula_accepted", false) -> "eula" + !splashPrefs.getBoolean("trail_intro_shown", false) -> "splash" + !disclaimerPrefs.getBoolean("disclaimer_accepted", false) -> "disclaimer" + else -> "pin" + } + var screen by rememberSaveable { mutableStateOf(initialScreen) } + var selectedDocId by rememberSaveable { mutableStateOf(null) } + var noteDocId by rememberSaveable { mutableStateOf(null) } + var currentDocIds by rememberSaveable { mutableStateOf(listOf()) } + var viewerOrigin by rememberSaveable { mutableStateOf("main") } + var homeFolderTag by rememberSaveable { mutableStateOf(null) } + var documentListFilterTag by rememberSaveable { mutableStateOf(null) } + var noteBody by rememberSaveable { mutableStateOf("") } + val listVm: DocumentListViewModel = hiltViewModel() + val allDocs by listVm.documents.collectAsState() + val selectedDoc = selectedDocId?.let { id -> allDocs.find { it.id == id } } + val noteDoc = noteDocId?.let { id -> allDocs.find { it.id == id } } + val currentDocList = currentDocIds.mapNotNull { id -> allDocs.find { it.id == id } } + // Dynamic dark/light theme - reads from SharedPreferences, updates immediately + val darkMode = remember { mutableStateOf(getSharedPreferences("app_settings", MODE_PRIVATE).getBoolean("dark_mode", false)) } + val colorScheme = if (darkMode.value) androidx.compose.material3.darkColorScheme() else androidx.compose.material3.lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + when { + screen == "eula" -> com.app.paperstow.presentation.onboarding.EulaScreen( + onAccepted = { + val prefs = getSharedPreferences("eula_prefs", MODE_PRIVATE) + val timestamp = System.currentTimeMillis() + prefs.edit() + .putBoolean("eula_accepted", true) + .putLong("accepted_timestamp", timestamp) + .apply() + com.app.paperstow.debug.DebugLogger.i("EULA", "Accepted at $timestamp") + screen = if (!splashPrefs.getBoolean("trail_intro_shown", false)) "splash" + else if (!disclaimerPrefs.getBoolean("disclaimer_accepted", false)) "disclaimer" + else "pin" + }, + onDeclined = { + com.app.paperstow.debug.DebugLogger.w("EULA", "Declined — closing app") + finishAffinity() + } + ) + screen == "splash" -> com.app.paperstow.presentation.onboarding.SplashScreen(onContinue = { skipInFuture -> + getSharedPreferences("splash_prefs", MODE_PRIVATE).edit() + .putBoolean("trail_intro_shown", true) + .putBoolean("skip_splash", skipInFuture || splashPrefs.getBoolean("skip_splash", false)) + .apply() + screen = if (!disclaimerPrefs.getBoolean("disclaimer_accepted", false)) "disclaimer" + else "pin" + }) + screen == "disclaimer" -> com.app.paperstow.presentation.onboarding.DisclaimerScreen(onAccepted = { telemetryConsent -> + getSharedPreferences("disclaimer_prefs", MODE_PRIVATE).edit().putBoolean("disclaimer_accepted", true).putLong("accepted_timestamp", System.currentTimeMillis()).putBoolean("telemetry_consented", telemetryConsent).apply() + getSharedPreferences("app_settings", MODE_PRIVATE).edit().putBoolean("telemetry_enabled", telemetryConsent).apply() + com.app.paperstow.debug.UsageTelemetry.setConsent(telemetryConsent) + screen = "pin" + }) + + screen == "pin" -> { + val fa = this@MainActivity as androidx.fragment.app.FragmentActivity + com.app.paperstow.presentation.auth.BiometricAuthScreen( + activity = fa, + onAuthenticated = { screen = if (sharedUri != null) "import_shared" else "main" }, + onSkipped = { screen = if (sharedUri != null) "import_shared" else "main" } + ) + } + screen == "import_shared" -> { + // Received a doc from another app — auto-import it + val importVm: com.app.paperstow.presentation.documents.ImportViewModel = hiltViewModel() + LaunchedEffect(sharedUri) { + if (sharedUri != null) { + com.app.paperstow.debug.DebugLogger.i("ShareTarget", "Auto-importing shared doc: $sharedUri") + importVm.importFile(sharedUri) + } + } + val sState by importVm.state.collectAsState() + Column(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + when { + sState.isImporting -> { CircularProgressIndicator(Modifier.size(64.dp)); Spacer(Modifier.height(16.dp)); Text("Importing shared document...") } + sState.importedDocument != null -> { + Icon(Icons.Filled.CheckCircle, null, tint = Color(0xFF4CAF50), modifier = Modifier.size(64.dp)) + Spacer(Modifier.height(16.dp)); Text("Imported!", fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(24.dp)); Button(onClick = { importVm.clearState(); screen = "main" }) { Text("Done") } + } + sState.error != null -> { + Text("\u274C", fontSize = 48.sp) + Spacer(Modifier.height(16.dp)); Text("Import Failed", color = Color(0xFFF44336)) + Text(sState.error ?: "", fontSize = 13.sp, color = Color.Gray) + Spacer(Modifier.height(24.dp)); Button(onClick = { screen = "main" }) { Text("Go to Home") } + } + else -> { CircularProgressIndicator(); Text("Preparing...") } + } + } + } + screen == "import" -> ImportScreen( + onDone = { screen = "main" } + ) + screen == "note" -> com.app.paperstow.presentation.documents.TextNoteEditorScreen( + existing = noteDoc, + initialBody = noteBody, + extraTags = listOfNotNull(homeFolderTag?.takeUnless { it.startsWith("__") }), + onBack = { screen = if (noteDocId != null) "viewer" else "main" }, + onSaved = { saved -> + selectedDocId = saved.id + noteDocId = saved.id + screen = "viewer" + } + ) + screen == "checklist" -> com.app.paperstow.presentation.documents.ChecklistEditorScreen( + existing = noteDoc, + initialBody = noteBody, + extraTags = listOfNotNull(homeFolderTag?.takeUnless { it.startsWith("__") }), + onBack = { screen = if (noteDocId != null) "viewer" else "main" }, + onSaved = { saved -> + selectedDocId = saved.id + noteDocId = saved.id + screen = "viewer" + } + ) + screen == "documents" -> DocumentListScreen( + filterTag = documentListFilterTag, + onBack = { + screen = if (documentListFilterTag != null) "tags" else "main" + }, + onDocumentClick = { doc -> selectedDocId = doc.id; viewerOrigin = "documents"; screen = "viewer" }, + onSetDocList = { currentDocIds = it.map { d -> d.id } } + ) + screen == "viewer" && selectedDoc != null -> { + val currentIdx = currentDocList.indexOfFirst { it.id == selectedDoc!!.id } + DocumentViewerScreen( + document = selectedDoc!!, + onBack = { screen = viewerOrigin }, + onPrev = if (currentIdx > 0) { { selectedDocId = currentDocList[currentIdx - 1].id } } else null, + onNext = if (currentIdx < currentDocList.size - 1) { { selectedDocId = currentDocList[currentIdx + 1].id } } else null, + onEditNote = { text -> + noteDocId = selectedDocId + noteBody = text + screen = if (selectedDoc?.format == DocumentFormat.MARKDOWN) "checklist" else "note" + } + ) + } + screen == "viewer" -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + screen == "search" -> SearchScreen(onBack = { screen = "main" }, onDocumentClick = { doc -> selectedDocId = doc.id; viewerOrigin = "search"; screen = "viewer" }) + screen == "tags" -> TagManagementScreen( + onBack = { screen = "main" }, + onViewDocs = { tag -> + documentListFilterTag = tag + viewerOrigin = "documents" + screen = "documents" + } + ) + screen == "settings" -> SettingsScreen(onBack = { screen = "main" }, onReset = { resetApp() }) + screen == "about" -> com.app.paperstow.presentation.about.AboutScreen(onBack = { screen = "main" }) + screen == "eula_view" -> com.app.paperstow.presentation.onboarding.EulaViewScreen(onBack = { screen = "main" }) + screen == "review" -> com.app.paperstow.presentation.review.ReviewScreen(onBack = { screen = "main" }) + screen == "feedback" -> com.app.paperstow.presentation.feedback.FeedbackScreen( + onBack = { screen = "main" }, + onNoteSaved = { saved -> + selectedDocId = saved.id + viewerOrigin = "main" + homeFolderTag = com.app.paperstow.presentation.feedback.APP_FEEDBACK_TAG + screen = "viewer" + } + ) + screen == "backup" -> com.app.paperstow.presentation.backup.BackupOnlyScreen(onBack = { screen = "main" }) + screen == "restore" -> com.app.paperstow.presentation.backup.RestoreOnlyScreen(onBack = { screen = "main" }) + screen == "safety" -> com.app.paperstow.presentation.safety.SafetyLocationsScreen(onBack = { screen = "main" }) + else -> { + com.app.paperstow.presentation.safety.AutoStartTrailIfWanted() + MainScreen( + onImport = { com.app.paperstow.debug.UsageTelemetry.action("Main", "tap_import"); screen = "import" }, + onNewNote = { noteDocId = null; noteBody = ""; screen = "note" }, + onNewChecklist = { noteDocId = null; noteBody = ""; screen = "checklist" }, + onAllDocs = { + com.app.paperstow.debug.UsageTelemetry.action("Main", "tap_documents") + documentListFilterTag = null + screen = "documents" + }, + onSearch = { com.app.paperstow.debug.UsageTelemetry.action("Main", "tap_search"); screen = "search" }, + onTags = { screen = "tags" }, + onSettings = { screen = "settings" }, + onBackup = { screen = "backup" }, + onRestore = { screen = "restore" }, + onAbout = { screen = "about" }, + onFeedback = { screen = "feedback" }, + onReview = { screen = "review" }, + onSafety = { screen = "safety" }, + selectedFolder = homeFolderTag, + onFolderChange = { homeFolderTag = it }, + onDocClick = { doc, folderDocs -> + selectedDocId = doc.id + currentDocIds = folderDocs.map { it.id } + viewerOrigin = "main" + screen = "viewer" + }, + ) + } + } + } + } + } + + override fun onResume() { super.onResume(); Thread { TempFileCleanup.cleanSharedDocs(applicationContext) }.start() } + override fun onPause() { super.onPause(); com.app.paperstow.debug.UsageTelemetry.emitSessionSummary() } + + private fun resetApp(): String { + DebugLogger.w("App", "!!! FACTORY RESET triggered by user") + val report = StringBuilder() + + // Checkpoint WAL before any operations + val dbPath = getDatabasePath("traveldocs.db") + try { + if (dbPath.exists()) { + val flushDb = android.database.sqlite.SQLiteDatabase.openDatabase(dbPath.path, null, android.database.sqlite.SQLiteDatabase.OPEN_READWRITE) + flushDb.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).close() + flushDb.close() + report.appendLine("WAL checkpoint: done") + } + } catch (e: Exception) { report.appendLine("WAL checkpoint: skipped (${e.message})") } + + // Archive old database + if (dbPath.exists()) { + val archiveDir = java.io.File(filesDir, "db_archive") + archiveDir.mkdirs() + for (i in 10 downTo 2) { + val older = java.io.File(archiveDir, "traveldocs.db.${String.format("%03d", i - 1)}") + val newer = java.io.File(archiveDir, "traveldocs.db.${String.format("%03d", i)}") + if (older.exists()) older.renameTo(newer) + } + val archive = java.io.File(archiveDir, "traveldocs.db.001") + dbPath.copyTo(archive, overwrite = true) + report.appendLine("DB archived: ${archive.name} (${archive.length()/1024}KB)") + } + + // Delete database + journals + deleteDatabase("traveldocs.db") + java.io.File(dbPath.path + "-wal").delete() + java.io.File(dbPath.path + "-shm").delete() + report.appendLine("Database deleted: traveldocs.db + WAL/SHM") + + // Clear preferences + val prefsToWipe = listOf("traveldocs_stats", "encryption_consent", "disclaimer_prefs", + "splash_prefs", "eula_prefs", "location_tracking_prefs", "safety_trail_prefs", "feature_flags", + "app_settings", "tag_colors", "secure_doc_pins", "security_alert_prefs") + prefsToWipe.forEach { getSharedPreferences(it, MODE_PRIVATE).edit().clear().commit() } + report.appendLine("Preferences cleared: ${prefsToWipe.size} stores") + + // Delete document files + val docsDir = java.io.File(filesDir, "docs") + val docCount = if (docsDir.exists()) docsDir.walkTopDown().filter { it.isFile }.count() else 0 + docsDir.deleteRecursively() + report.appendLine("Document files deleted: $docCount files") + + // Delete debug logs + val logsDir = java.io.File(filesDir, "debug_logs") + logsDir.deleteRecursively() + report.appendLine("Debug logs deleted") + + // Delete crash report + java.io.File(filesDir, "last_crash_report.txt").delete() + report.appendLine("Crash report deleted") + + // Delete cache + cacheDir.deleteRecursively() + report.appendLine("Cache cleared") + + report.appendLine("") + report.appendLine("Reset complete. App needs restart to initialize fresh.") + return report.toString() + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + onImport: () -> Unit, onNewNote: () -> Unit, onNewChecklist: () -> Unit, onAllDocs: () -> Unit, onSearch: () -> Unit, + onTags: () -> Unit, onSettings: () -> Unit, onBackup: () -> Unit, onRestore: () -> Unit, onAbout: () -> Unit, onFeedback: () -> Unit, onReview: () -> Unit, + onSafety: () -> Unit, + selectedFolder: String? = null, + onFolderChange: (String?) -> Unit = {}, + onDocClick: (Document, List) -> Unit +) { + val viewModel: DocumentListViewModel = hiltViewModel() + val demoViewModel: DemoDataViewModel = hiltViewModel() + val context = androidx.compose.ui.platform.LocalContext.current + val tagLooks = remember { TagColorStore(context) } + val documents by viewModel.documents.collectAsState() + val demo by demoViewModel.state.collectAsState() + var isRefreshing by remember { mutableStateOf(false) } + val refreshScope = rememberCoroutineScope() + var showGearMenu by remember { mutableStateOf(false) } + var showLoadDemo by remember { mutableStateOf(false) } + var showRemoveDemo by remember { mutableStateOf(false) } + var showTips by remember { + mutableStateOf(context.getSharedPreferences("app_settings", 0).getBoolean("show_tips", true)) + } + + if (showLoadDemo) { + AlertDialog( + onDismissRequest = { showLoadDemo = false }, + title = { Text("Load sample trip?") }, + text = { + Text( + "Adds a fictional trip for John Doe and Jane Doe — papers, health records, a checklist, and a sample trail. They stay on this phone. Remove them anytime from the menu.", + fontSize = 14.sp + ) + }, + confirmButton = { + TextButton(onClick = { + showLoadDemo = false + demoViewModel.importSampleTrip() + }) { Text("Load sample") } + }, + dismissButton = { TextButton(onClick = { showLoadDemo = false }) { Text("Not now") } } + ) + } + if (showRemoveDemo) { + AlertDialog( + onDismissRequest = { showRemoveDemo = false }, + title = { Text("Remove sample trip?") }, + text = { Text("This deletes only the sample papers. Anything you imported yourself stays.", fontSize = 14.sp) }, + confirmButton = { + TextButton(onClick = { + showRemoveDemo = false + demoViewModel.removeSampleTrip() + }) { Text("Remove samples") } + }, + dismissButton = { TextButton(onClick = { showRemoveDemo = false }) { Text("Cancel") } } + ) + } + Scaffold( + topBar = { + TopAppBar( + title = { val customTitle = remember { context.getSharedPreferences("app_settings", 0).getString("home_title", "Paperstow") ?: "Paperstow" }; Text(customTitle) }, + actions = { + IconButton(onClick = { isRefreshing = true; viewModel.forceRefresh(); refreshScope.launch { kotlinx.coroutines.delay(800); isRefreshing = false } }) { Icon(Icons.Filled.Refresh, "Refresh") } + Box { + IconButton(onClick = { showGearMenu = true }) { Icon(Icons.Filled.Settings, "More") } + DropdownMenu(expanded = showGearMenu, onDismissRequest = { showGearMenu = false }) { + DropdownMenuItem(text = { Text("Tags") }, onClick = { showGearMenu = false; onTags() }) + DropdownMenuItem(text = { Text("Review & Classify") }, onClick = { showGearMenu = false; onReview() }) + DropdownMenuItem(text = { Text("My Trail") }, onClick = { showGearMenu = false; onSafety() }) + HorizontalDivider() + DropdownMenuItem(text = { Text("Backup") }, onClick = { showGearMenu = false; onBackup() }) + DropdownMenuItem(text = { Text("Restore") }, onClick = { showGearMenu = false; onRestore() }) + HorizontalDivider() + if (!demo.loaded) { + DropdownMenuItem(text = { Text("Load sample trip") }, onClick = { showGearMenu = false; showLoadDemo = true }) + } else { + DropdownMenuItem(text = { Text("Remove sample trip") }, onClick = { showGearMenu = false; showRemoveDemo = true }) + } + HorizontalDivider() + DropdownMenuItem(text = { Text("Settings") }, onClick = { showGearMenu = false; onSettings() }) + DropdownMenuItem(text = { Text("Feedback") }, onClick = { showGearMenu = false; onFeedback() }) + DropdownMenuItem(text = { Text("About") }, onClick = { showGearMenu = false; onAbout() }) + } + } } + ) + } + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + // Pull to refresh indicator + if (isRefreshing) { androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) } + androidx.compose.material3.TextButton(onClick = { isRefreshing = true; refreshScope.launch { delay(1000); isRefreshing = false } }, modifier = Modifier.fillMaxWidth().height(4.dp)) {} + val landscapeHome = androidx.compose.ui.platform.LocalConfiguration.current.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + if (landscapeHome) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onImport, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1565C0))) { + Icon(Icons.Filled.Add, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Import", fontSize = 13.sp) + } + OutlinedButton(onClick = onAllDocs, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Folder, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("All Docs", fontSize = 13.sp) + } + OutlinedButton(onClick = onSearch, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Search, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Search", fontSize = 13.sp) + } + OutlinedButton(onClick = onNewNote, modifier = Modifier.weight(1f)) { + Text("Note", fontSize = 13.sp) + } + OutlinedButton(onClick = onNewChecklist, modifier = Modifier.weight(1f)) { + Text("Checklist", fontSize = 13.sp) + } + } + } else { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onImport, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1565C0))) { + Icon(Icons.Filled.Add, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Import", fontSize = 13.sp) + } + OutlinedButton(onClick = onAllDocs, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Folder, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("All Docs", fontSize = 13.sp) + } + OutlinedButton(onClick = onSearch, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Search, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Search", fontSize = 13.sp) + } + } + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onNewNote, modifier = Modifier.weight(1f)) { + Text("Write a note", fontSize = 13.sp) + } + OutlinedButton(onClick = onNewChecklist, modifier = Modifier.weight(1f)) { + Text("Checklist", fontSize = 13.sp) + } + } + } + if (demo.busy) { + androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) + Text("Updating sample trip…", fontSize = 12.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp)) + } + // Folder view by tags. Internal/system tags (__ prefix) stay hidden. + val allTags = documents.flatMap { it.tags }.map { it.name }.filter { !it.startsWith("__") }.distinct().sorted() + .filter { tagLooks.isShownOnHome(it) } + val untaggedDocs = documents.filter { it.tags.none { t -> !t.name.startsWith("__") } } + val selectedTag = selectedFolder + if (demo.message != null && selectedTag == null) { + Card( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFE3F2FD)) + ) { + Column(Modifier.padding(12.dp)) { + Text(demo.message!!, fontSize = 13.sp) + TextButton(onClick = { demoViewModel.dismissMessage() }) { Text("Got it") } + } + } + } + + if (demo.loaded && showTips && selectedTag == null && demo.message == null) { + Card( + Modifier.fillMaxWidth().padding(bottom = 10.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFE8F5E9)) + ) { + Column(Modifier.padding(12.dp)) { + Text("Walk through the sample trip", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + Text("1. Open Passport, Health, or Plans", fontSize = 13.sp) + Text("2. Tap a PDF, checklist, or trail file to preview", fontSize = 13.sp) + Text("3. Search for “John Doe” or “Jane Doe”", fontSize = 13.sp) + Text("When you are ready, menu → Remove sample trip.", fontSize = 12.sp, color = Color.Gray) + TextButton(onClick = { + showTips = false + context.getSharedPreferences("app_settings", 0).edit().putBoolean("show_tips", false).apply() + }) { Text("Hide tips") } + } + } + } + + if (selectedTag != null) { + // Show documents in selected folder + val folderDocs = when (selectedTag) { + "__untagged__" -> untaggedDocs + else -> documents.filter { doc -> doc.tags.any { it.name == selectedTag } } + } + val folderTitle = when (selectedTag) { + "__untagged__" -> "Untagged" + else -> selectedTag!! + } + Row(Modifier.fillMaxWidth().padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { onFolderChange(null) }) { Icon(Icons.Filled.ArrowBack, "Back to folders") } + Text(folderTitle, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.weight(1f)) + Text("${folderDocs.size} docs", fontSize = 12.sp, color = Color.Gray) + IconButton(onClick = { viewModel.share(context, folderDocs) }) { Icon(Icons.Filled.Share, "Share this folder") } + } + LazyColumn(Modifier.weight(1f)) { + items(folderDocs.take(50)) { doc -> + DocCard(doc = doc, onClick = { onDocClick(doc, folderDocs) }) + Spacer(Modifier.height(6.dp)) + } + } + } else { + // Show folder grid + Text("Organized by tags", fontWeight = FontWeight.Bold, fontSize = 16.sp, modifier = Modifier.padding(bottom = 8.dp)) + val widthDp = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp + val columns = when { + widthDp >= 840 -> 4 + widthDp >= 600 -> 3 + else -> 2 + } + val folders = allTags + (if (untaggedDocs.isNotEmpty()) listOf("__untagged__") else emptyList()) + if (folders.isEmpty()) { + Column(Modifier.fillMaxWidth().padding(vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Filled.FlightTakeoff, null, Modifier.size(56.dp), tint = Color(0xFF1565C0)) + Spacer(Modifier.height(12.dp)) + Text("Nothing stowed yet", fontSize = 16.sp, color = Color.Gray) + Text("Try a sample family trip, or add your own papers.", fontSize = 13.sp, color = Color.Gray, modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(16.dp)) + Button(onClick = { showLoadDemo = true }, enabled = !demo.busy) { Text("Load sample trip") } + Spacer(Modifier.height(8.dp)) + Text("PDFs, photos, and notes — remove them from the menu anytime.", fontSize = 12.sp, color = Color.LightGray) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(folders, key = { it }) { tag -> + val label = if (tag == "__untagged__") "Untagged" else tag + val count = if (tag == "__untagged__") untaggedDocs.size else documents.count { d -> d.tags.any { it.name == tag } } + FolderTile( + tag = tag, + label = label, + count = count, + store = tagLooks, + onClick = { onFolderChange(tag) } + ) + } + } + } + } } + } +} + +@Composable +private fun FolderTile( + tag: String, + label: String, + count: Int, + store: TagColorStore, + onClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(1.dp) + ) { + Column(Modifier.fillMaxWidth().aspectRatio(0.92f)) { + TagCover( + tagName = tag, + store = store, + untagged = tag == "__untagged__", + modifier = Modifier.fillMaxWidth().weight(1f) + ) + Text( + "$label($count)", + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun DocCard(doc: Document, onClick: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), shape = RoundedCornerShape(10.dp), elevation = CardDefaults.cardElevation(1.dp)) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(iconFor(doc.type), doc.type.name, tint = colorFor(doc.type), modifier = Modifier.size(36.dp)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(doc.originalFileName ?: "Document", fontWeight = FontWeight.Medium, fontSize = 14.sp, maxLines = 1) + Text(doc.type.name, fontSize = 12.sp, color = Color.Gray) + if (doc.tags.isNotEmpty()) Text(doc.tags.joinToString(", ") { it.name }, fontSize = 11.sp, color = Color(0xFF1565C0), maxLines = 1) + } + } + } +} + +private fun iconFor(type: DocumentType): ImageVector = when (type) { + DocumentType.PASSPORT -> Icons.Filled.AccountBox + DocumentType.VISA -> Icons.Filled.CreditCard + DocumentType.TICKET -> Icons.Filled.AirplanemodeActive + DocumentType.HOTEL_BOOKING -> Icons.Filled.Hotel + DocumentType.HEALTH_INSURANCE -> Icons.Filled.LocalHospital + DocumentType.UNKNOWN -> Icons.Filled.InsertDriveFile +} + +private fun colorFor(type: DocumentType): Color = when (type) { + DocumentType.PASSPORT -> Color(0xFF1565C0) + DocumentType.VISA -> Color(0xFF4CAF50) + DocumentType.TICKET -> Color(0xFFFF9800) + DocumentType.HOTEL_BOOKING -> Color(0xFF9C27B0) + DocumentType.HEALTH_INSURANCE -> Color(0xFFF44336) + DocumentType.UNKNOWN -> Color.Gray +} + +private fun tagIcon(tag: String): androidx.compose.ui.graphics.vector.ImageVector { + return when (tag.lowercase()) { + "passport" -> androidx.compose.material.icons.Icons.Filled.AccountBox + "visa" -> androidx.compose.material.icons.Icons.Filled.CreditCard + "ticket", "flight" -> androidx.compose.material.icons.Icons.Filled.AirplanemodeActive + "accommodation", "hotel" -> androidx.compose.material.icons.Icons.Filled.Hotel + "health", "insurance" -> androidx.compose.material.icons.Icons.Filled.LocalHospital + "travel", "trip" -> androidx.compose.material.icons.Icons.Filled.FlightTakeoff + "family" -> androidx.compose.material.icons.Icons.Filled.Public + "business", "work" -> androidx.compose.material.icons.Icons.Filled.Description + else -> androidx.compose.material.icons.Icons.Filled.Sell + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/about/AboutScreen.kt b/app/src/main/java/com/app/paperstow/presentation/about/AboutScreen.kt new file mode 100644 index 0000000..ac64b59 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/about/AboutScreen.kt @@ -0,0 +1,170 @@ +package com.app.paperstow.presentation.about + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.sp +import com.app.paperstow.BuildConfig +import com.app.paperstow.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen(onBack: () -> Unit) { + val colors = MaterialTheme.colorScheme + val ctx = LocalContext.current + val privacyUrl = stringResource(R.string.privacy_policy_url) + var showSbom by remember { mutableStateOf(false) } + if (showSbom) { + SbomScreen(onBack = { showSbom = false }) + return + } + Scaffold(topBar = { TopAppBar(title = { Text("About") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> + Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { + Text("Paperstow", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.primary) + Text("Version ${BuildConfig.VERSION_NAME}", fontSize = 14.sp, color = colors.onSurfaceVariant) + Text("Package ${ctx.packageName}", fontSize = 13.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + Text( + "Keep a copy of your family travel papers on this device.", + fontSize = 14.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(16.dp)) + + Text("Passports, visas, tickets, hotel bookings, and insurance stay encrypted here. The app may suggest a name or tag after a scan — treat that as a helper and always check the original page. Nothing is uploaded unless you share a file or export an archive.", fontSize = 13.sp) + Spacer(Modifier.height(20.dp)) + + SectionTitle("Security") + InfoRow("File Encryption", "AES-256-GCM (Android KeyStore)") + InfoRow("Authentication", "This device's fingerprint, face, or screen lock") + InfoRow("Archive", "Password-protected ZIP to a folder you pick") + Spacer(Modifier.height(16.dp)) + + SectionTitle("Privacy") + InfoRow("Storage", "Documents stay encrypted on this phone. Only you can open them.") + InfoRow("Reading pages", "On-device helper only. Results can be wrong or incomplete. Always check the original.") + InfoRow("Scan", "On-device ML Kit document scanner (edge detect, crop, enhance). Play services camera; Paperstow does not need camera permission for this.") + InfoRow("My Trail", "Optional unique places for 24 hours on this phone, with battery level at each save. Share Maps links yourself.") + InfoRow("Telemetry", "Optional, local usage counts. Off by default.") + InfoRow("Tag pictures", "Optional folder photo you pick. The photo stays on this phone.") + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(privacyUrl))) }, + modifier = Modifier.fillMaxWidth() + ) { Text("Privacy Policy") } + Spacer(Modifier.height(16.dp)) + + SectionTitle("Permissions") + Text("Permissions are requested only when a feature needs them. Denying a permission does not break core import, view, search, or share.", fontSize = 12.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + PermRow("Camera", "Fallback only if the on-device scanner is unavailable") + PermRow("Location", "Optional My Trail. Requested when you start it. Not used in the background after you stop.") + PermRow("Notifications", "Shown while My Trail is recording") + PermRow("Internet", "Optional ML Kit OCR / scanner model refresh. Documents are not uploaded.") + Spacer(Modifier.height(16.dp)) + + SectionTitle("License") + Text("Apache License 2.0", fontSize = 13.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(4.dp)) + Text("Copyright 2026 Paperstow Contributors.", fontSize = 11.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text( + "Third-party libraries keep their own terms: Apache 2.0 (AndroidX, Compose, Hilt, Room, Zip4j, Gson), Bouncy Castle Licence, and Google ML Kit / Play services terms for on-device OCR and scanning.", + fontSize = 11.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { + ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/sethusrinivasan/document-manager/blob/main/LICENSE"))) + }, + modifier = Modifier.fillMaxWidth() + ) { Text("View License", fontSize = 13.sp) } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { + ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/sethusrinivasan/document-manager/blob/main/NOTICE"))) + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Third-party notices", fontSize = 13.sp) } + Spacer(Modifier.height(16.dp)) + + SectionTitle("Software bill of materials") + Text( + "Libraries compiled into this build. Recalculated every time the app is built and stored on this device.", + fontSize = 12.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { showSbom = true }, + modifier = Modifier.fillMaxWidth() + ) { Text("View SBOM", fontSize = 13.sp) } + Spacer(Modifier.height(16.dp)) + + SectionTitle("Contact") + Text("Questions and issues: https://github.com/sethusrinivasan/document-manager/issues", fontSize = 12.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(24.dp)) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text(text, fontWeight = FontWeight.Bold, fontSize = 15.sp) + Spacer(Modifier.height(6.dp)) +} + +@Composable +private fun InfoRow(label: String, value: String) { + val colors = MaterialTheme.colorScheme + Card(Modifier.fillMaxWidth().padding(vertical = 2.dp), colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant)) { + Column(Modifier.padding(10.dp)) { + Text(label, fontSize = 12.sp, color = colors.onSurfaceVariant) + Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + } +} + +@Composable +private fun PermRow(permission: String, purpose: String) { + val colors = MaterialTheme.colorScheme + Card(Modifier.fillMaxWidth().padding(vertical = 2.dp), colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant)) { + Row(Modifier.padding(10.dp)) { + Text(permission, fontWeight = FontWeight.Medium, fontSize = 12.sp, modifier = Modifier.width(120.dp)) + Text(purpose, fontSize = 12.sp, color = colors.onSurfaceVariant) + } + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/about/SbomScreen.kt b/app/src/main/java/com/app/paperstow/presentation/about/SbomScreen.kt new file mode 100644 index 0000000..a0afca9 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/about/SbomScreen.kt @@ -0,0 +1,218 @@ +package com.app.paperstow.presentation.about + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.json.JSONObject + +data class SbomComponent( + val group: String, + val name: String, + val version: String, + val purl: String +) + +data class SbomDocument( + val specVersion: String, + val timestamp: String, + val appName: String, + val appVersion: String, + val variant: String, + val components: List, + val rawJson: String +) + +fun loadBundledSbom(context: Context): SbomDocument? { + return try { + val json = context.assets.open("sbom.json").bufferedReader().use { it.readText() } + parseSbom(json) + } catch (_: Exception) { + null + } +} + +fun parseSbom(json: String): SbomDocument { + val root = JSONObject(json) + val metadata = root.optJSONObject("metadata") + val component = metadata?.optJSONObject("component") + val list = root.optJSONArray("components") + val components = buildList { + if (list != null) { + for (i in 0 until list.length()) { + val c = list.getJSONObject(i) + add( + SbomComponent( + group = c.optString("group"), + name = c.optString("name"), + version = c.optString("version"), + purl = c.optString("purl") + ) + ) + } + } + } + return SbomDocument( + specVersion = root.optString("specVersion", "1.5"), + timestamp = metadata?.optString("timestamp").orEmpty(), + appName = component?.optString("name").orEmpty(), + appVersion = component?.optString("version").orEmpty(), + variant = metadata?.optJSONObject("properties")?.optString("variant") + ?: metadata?.optJSONArray("properties")?.let { props -> + (0 until props.length()).firstNotNullOfOrNull { i -> + val p = props.getJSONObject(i) + if (p.optString("name") == "build:variant") p.optString("value") else null + } + }.orEmpty(), + components = components, + rawJson = json + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SbomScreen(onBack: () -> Unit) { + val context = LocalContext.current + val colors = MaterialTheme.colorScheme + val sbom = remember { loadBundledSbom(context) } + var query by remember { mutableStateOf("") } + var showRaw by remember { mutableStateOf(false) } + val filtered = remember(sbom, query) { + val q = query.trim().lowercase() + sbom?.components.orEmpty().filter { c -> + q.isEmpty() || + c.name.lowercase().contains(q) || + c.group.lowercase().contains(q) || + c.version.lowercase().contains(q) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("SBOM") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }, + actions = { + if (sbom != null) { + IconButton(onClick = { + val send = Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_SUBJECT, "Paperstow SBOM ${sbom.appVersion}") + putExtra(Intent.EXTRA_TEXT, sbom.rawJson) + } + context.startActivity(Intent.createChooser(send, "Share SBOM")) + }) { Icon(Icons.Filled.Share, "Share") } + } + } + ) + } + ) { padding -> + if (sbom == null) { + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + Text( + "No SBOM is bundled in this install. It is generated at compile time and packaged with the app.", + fontSize = 14.sp, + color = colors.onSurfaceVariant + ) + } + return@Scaffold + } + + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + item { + Text("CycloneDX ${sbom.specVersion}", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.height(4.dp)) + Text( + "${sbom.appName} ${sbom.appVersion}" + if (sbom.variant.isNotBlank()) " · ${sbom.variant}" else "", + fontSize = 13.sp, + color = colors.onSurfaceVariant + ) + if (sbom.timestamp.isNotBlank()) { + Text("Generated ${sbom.timestamp}", fontSize = 12.sp, color = colors.onSurfaceVariant) + } + Spacer(Modifier.height(4.dp)) + Text( + "${sbom.components.size} libraries in this build. This list is recalculated every compile.", + fontSize = 12.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Filter libraries") } + ) + TextButton(onClick = { showRaw = !showRaw }) { + Text(if (showRaw) "Hide JSON" else "Show JSON") + } + } + if (showRaw) { + item { + Card( + Modifier.fillMaxWidth().padding(bottom = 12.dp), + colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant) + ) { + SelectionContainer { + Text( + sbom.rawJson, + modifier = Modifier.padding(10.dp), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } + } + items(filtered, key = { "${it.group}:${it.name}:${it.version}" }) { c -> + Card( + Modifier.fillMaxWidth().padding(vertical = 3.dp), + colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant) + ) { + Column(Modifier.padding(10.dp)) { + Text(c.name, fontSize = 13.sp, fontWeight = FontWeight.Medium) + Text( + listOf(c.group, c.version).filter { it.isNotBlank() }.joinToString(" · "), + fontSize = 11.sp, + color = colors.onSurfaceVariant + ) + } + } + } + item { Spacer(Modifier.height(24.dp)) } + } + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/auth/.gitkeep b/app/src/main/java/com/app/paperstow/presentation/auth/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/presentation/auth/.gitkeep rename to app/src/main/java/com/app/paperstow/presentation/auth/.gitkeep diff --git a/app/src/main/java/com/app/traveldocs/presentation/auth/BiometricAuthScreen.kt b/app/src/main/java/com/app/paperstow/presentation/auth/BiometricAuthScreen.kt similarity index 94% rename from app/src/main/java/com/app/traveldocs/presentation/auth/BiometricAuthScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/auth/BiometricAuthScreen.kt index 8933ca0..74b2572 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/auth/BiometricAuthScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/auth/BiometricAuthScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.auth +package com.app.paperstow.presentation.auth import android.content.Context import androidx.biometric.BiometricManager @@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.debug.DebugLogger @Composable fun BiometricAuthScreen(activity: FragmentActivity, onAuthenticated: () -> Unit, onSkipped: () -> Unit) { @@ -78,8 +78,8 @@ private fun launchBiometric(activity: FragmentActivity, onSuccess: () -> Unit, o val prompt = BiometricPrompt(activity, executor, callback) val info = BiometricPrompt.PromptInfo.Builder() - .setTitle("Document Manager") - .setSubtitle("Authenticate to access your documents") + .setTitle("Paperstow") + .setSubtitle("Unlock to open the family's travel papers") .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL) .build() diff --git a/app/src/main/java/com/app/traveldocs/presentation/backup/BackupOnlyScreen.kt b/app/src/main/java/com/app/paperstow/presentation/backup/BackupOnlyScreen.kt similarity index 60% rename from app/src/main/java/com/app/traveldocs/presentation/backup/BackupOnlyScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/backup/BackupOnlyScreen.kt index 1fcc788..470324f 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/backup/BackupOnlyScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/backup/BackupOnlyScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.backup +package com.app.paperstow.presentation.backup import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult @@ -16,9 +16,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.documentfile.provider.DocumentFile -import com.app.traveldocs.data.backup.BackupManager -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.debug.UsageTelemetry +import com.app.paperstow.data.backup.BackupManager +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.debug.UsageTelemetry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -29,32 +29,55 @@ fun BackupOnlyScreen(onBack: () -> Unit) { val context = LocalContext.current val scope = rememberCoroutineScope() val backupManager = remember { BackupManager() } - var state by remember { mutableStateOf("choose") } // choose, pin, running, done + var state by remember { mutableStateOf("choose") } // choose, password, running, done var resultMessage by remember { mutableStateOf("") } var isError by remember { mutableStateOf(false) } var backupPin by remember { mutableStateOf("") } + var includeTrail by remember { mutableStateOf(false) } var pendingUri by remember { mutableStateOf(null) } val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - if (uri != null) { pendingUri = uri; state = "pin" } + if (uri != null) { pendingUri = uri; state = "password" } } - // PIN dialog - if (state == "pin") { + if (state == "password") { var pinInput by remember { mutableStateOf("") } + val pinReady = pinInput.isBlank() || pinInput.length >= 4 + fun startBackup(pin: String) { + backupPin = pin + val trail = includeTrail + state = "running" + scope.launch { doBackup(context, backupManager, pendingUri!!, backupPin, trail) { msg, err -> resultMessage = msg; isError = err; state = "done" } } + } AlertDialog( onDismissRequest = { state = "choose" }, - title = { Text("Set Backup Password") }, + title = { Text("Backup password") }, text = { Column { - Text("Protect your backup with a password (min 4 characters).", fontSize = 13.sp, color = Color.Gray) + Text("A password is optional. If you set one, you will need it to restore. Leave it blank to skip.", fontSize = 13.sp, color = Color.Gray) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = pinInput, + onValueChange = { pinInput = it }, + label = { Text("Password (optional)") }, + supportingText = { + if (pinInput.isNotBlank() && pinInput.length < 4) Text("At least 4 characters if you set one") + }, + isError = pinInput.isNotBlank() && pinInput.length < 4, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation() + ) Spacer(Modifier.height(12.dp)) - OutlinedTextField(value = pinInput, onValueChange = { pinInput = it }, label = { Text("Password") }, singleLine = true, modifier = Modifier.fillMaxWidth(), - visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = includeTrail, onCheckedChange = { includeTrail = it }) + Text("Also include My Trail as a .gpx file", fontSize = 13.sp) + } } }, - confirmButton = { TextButton(onClick = { - backupPin = pinInput; state = "running" - scope.launch { doBackup(context, backupManager, pendingUri!!, backupPin) { msg, err -> resultMessage = msg; isError = err; state = "done" } } - }, enabled = pinInput.length >= 4) { Text("Create Backup") } }, + confirmButton = { + TextButton(onClick = { startBackup(pinInput.trim()) }, enabled = pinReady) { + Text(if (pinInput.isBlank()) "Create without password" else "Create Backup") + } + }, dismissButton = { TextButton(onClick = { state = "choose" }) { Text("Cancel") } } ) } @@ -66,11 +89,11 @@ fun BackupOnlyScreen(onBack: () -> Unit) { Spacer(Modifier.height(32.dp)) Text("Backup Documents", fontSize = 20.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(8.dp)) - Text("Creates an encrypted ZIP file with all your documents and metadata.", fontSize = 14.sp, color = Color.Gray) + Text("Saves a ZIP of your papers to a folder you pick. You can also add My Trail as a .gpx file.", fontSize = 14.sp, color = Color.Gray) Spacer(Modifier.height(24.dp)) Button(onClick = { folderPicker.launch(null) }, modifier = Modifier.fillMaxWidth()) { Text("Choose Backup Location") } Spacer(Modifier.height(12.dp)) - Text("The backup will be password-protected. Keep your password safe — it cannot be recovered.", fontSize = 12.sp, color = Color(0xFFF44336)) + Text("A password is optional. If you set one, keep it safe — it cannot be recovered.", fontSize = 12.sp, color = Color.Gray) } "running" -> { Spacer(Modifier.height(64.dp)) @@ -91,18 +114,20 @@ fun BackupOnlyScreen(onBack: () -> Unit) { } } -private suspend fun doBackup(context: android.content.Context, mgr: BackupManager, uri: Uri, pin: String, done: (String, Boolean) -> Unit) = withContext(Dispatchers.IO) { +private suspend fun doBackup(context: android.content.Context, mgr: BackupManager, uri: Uri, pin: String, includeTrail: Boolean, done: (String, Boolean) -> Unit) = withContext(Dispatchers.IO) { try { DebugLogger.i("Backup", "=== BACKUP STARTED ===") UsageTelemetry.funnelStart("backup_local") - val r = mgr.createBackupZip(context, pin) + val password = pin.trim().ifEmpty { null } + val r = mgr.createBackupZip(context, password, includeTrailGpx = includeTrail) val folder = DocumentFile.fromTreeUri(context, uri) val out = folder?.createFile("application/zip", mgr.suggestedFileName()) if (out != null) { context.contentResolver.openOutputStream(out.uri)?.use { os -> r.zipFile.inputStream().use { it.copyTo(os) } } r.zipFile.delete() UsageTelemetry.funnelComplete("backup_local") - val report = "Backed up ${r.fileCount} documents (${r.totalBytes / 1024}KB)\nSaved to: ${folder.name}/" + val protection = if (password == null) "No password" else "Password-protected" + val report = "Backed up ${r.fileCount} documents (${r.totalBytes / 1024}KB)\n$protection\nSaved to: ${folder.name}/" DebugLogger.i("Backup", report) done(report, false) } else { done("Failed to create file in folder", true) } diff --git a/app/src/main/java/com/app/traveldocs/presentation/backup/RestoreOnlyScreen.kt b/app/src/main/java/com/app/paperstow/presentation/backup/RestoreOnlyScreen.kt similarity index 52% rename from app/src/main/java/com/app/traveldocs/presentation/backup/RestoreOnlyScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/backup/RestoreOnlyScreen.kt index db75b40..32991c8 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/backup/RestoreOnlyScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/backup/RestoreOnlyScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.backup +package com.app.paperstow.presentation.backup import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult @@ -18,8 +18,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.app.traveldocs.data.backup.BackupRestore -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.data.backup.BackupRestore +import com.app.paperstow.debug.DebugLogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,23 +43,30 @@ fun RestoreOnlyScreen(onBack: () -> Unit) { if (showPinDialog) { AlertDialog( onDismissRequest = { showPinDialog = false }, - title = { Text("Backup Password") }, + title = { Text("Backup password") }, text = { Column { - Text("Enter the password used when creating this backup.", fontSize = 13.sp, color = Color.Gray) + Text("Only needed if this backup was created with a password. Leave it blank to skip.", fontSize = 13.sp, color = Color.Gray) Spacer(Modifier.height(12.dp)) - OutlinedTextField(value = restorePin, onValueChange = { restorePin = it }, label = { Text("Password (required)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), - visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()) + OutlinedTextField( + value = restorePin, + onValueChange = { restorePin = it }, + label = { Text("Password (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation() + ) } }, confirmButton = { TextButton(onClick = { showPinDialog = false; state = "running"; logLines = listOf("Starting restore...") - val pin = restorePin; restorePin = "" + val pin = restorePin.trim().ifEmpty { null }; restorePin = "" scope.launch { - doRestoreVerbose(context, pendingUri!!, pin) { line -> + val ok = doRestoreVerbose(context, pendingUri!!, pin) { line -> logLines = logLines + line } + isError = !ok state = "done" } - }, enabled = restorePin.isNotBlank()) { Text("Restore") } }, + }) { Text(if (restorePin.isBlank()) "Restore without password" else "Restore") } }, dismissButton = { TextButton(onClick = { showPinDialog = false }) { Text("Cancel") } } ) } @@ -75,12 +82,20 @@ fun RestoreOnlyScreen(onBack: () -> Unit) { Spacer(Modifier.height(24.dp)) Button(onClick = { filePicker.launch("application/zip") }, modifier = Modifier.fillMaxWidth()) { Text("Choose Backup File") } Spacer(Modifier.height(12.dp)) - Text("You will need the password that was set during backup.", fontSize = 12.sp, color = Color.Gray) + Text("A password is only needed if one was set when the backup was created.", fontSize = 12.sp, color = Color.Gray) } "running", "done" -> { // Verbose log output - Text(if (state == "running") "Restoring..." else "Restore Complete", fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = if (state == "done" && logLines.any { it.startsWith("ERROR") }) Color(0xFFF44336) else if (state == "done") Color(0xFF4CAF50) else Color.Unspecified) + Text( + if (state == "running") "Restoring..." + else if (isError || logLines.any { it.startsWith("ERROR") || it.contains("STATUS: FAILED") }) "Restore Failed" + else "Restore Complete", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = if (state == "done" && (isError || logLines.any { it.startsWith("ERROR") || it.contains("STATUS: FAILED") })) Color(0xFFF44336) + else if (state == "done") Color(0xFF4CAF50) + else Color.Unspecified + ) Spacer(Modifier.height(8.dp)) if (state == "running") { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)) } @@ -101,12 +116,18 @@ fun RestoreOnlyScreen(onBack: () -> Unit) { if (state == "done") { Spacer(Modifier.height(12.dp)) - Text("Restart required: The app must restart to load restored documents. This is a technical requirement — the database connection must be re-established.", fontSize = 12.sp, color = Color(0xFFF44336), fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(8.dp)) - Button(onClick = { android.os.Process.killProcess(android.os.Process.myPid()) }, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))) { Text("Restart App Now") } - Spacer(Modifier.height(8.dp)) - OutlinedButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Go Back (documents won't show until restart)") } + if (isError) { + Text("Nothing was marked complete. If the backup itself is empty or corrupt, this phone’s current papers were left as they were.", fontSize = 13.sp, color = Color(0xFFF44336)) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Go Back") } + } else { + Text("Restart the app so it can open the restored papers.", fontSize = 12.sp, color = Color(0xFFF44336), fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Button(onClick = { android.os.Process.killProcess(android.os.Process.myPid()) }, modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))) { Text("Restart App Now") } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Go Back (papers won’t show until restart)") } + } } } } @@ -114,11 +135,11 @@ fun RestoreOnlyScreen(onBack: () -> Unit) { } } -private suspend fun doRestoreVerbose(context: android.content.Context, uri: Uri, password: String, log: (String) -> Unit) = withContext(Dispatchers.IO) { +private suspend fun doRestoreVerbose(context: android.content.Context, uri: Uri, password: String?, log: (String) -> Unit): Boolean = withContext(Dispatchers.IO) { try { log("Reading backup file...") val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - if (bytes == null) { log("ERROR: Cannot read backup file"); return@withContext } + if (bytes == null) { log("ERROR: Cannot read backup file"); return@withContext false } log("OK: Read ${bytes.size / 1024}KB from source") val tempZip = java.io.File(context.cacheDir, "restore_temp.zip") @@ -132,16 +153,15 @@ private suspend fun doRestoreVerbose(context: android.content.Context, uri: Uri, if (!inspection.valid) { log("ERROR: ${inspection.errorMessage}") tempZip.delete() - return@withContext + return@withContext false } log("OK: Schema version ${inspection.schemaVersion}") log("OK: Backup timestamp: ${inspection.timestamp}") log("OK: Expected files: ${inspection.fileCount}") log("OK: Total size: ${inspection.totalSizeBytes / 1024}KB") - log("OK: PIN-protected docs: ${inspection.pinProtectedCount}") if (inspection.files.isNotEmpty()) { log("Files in backup:") - inspection.files.take(20).forEach { f -> log(" ${f.path} (${f.size/1024}KB)${if (f.hasPinProtection) " [PIN]" else ""}") } + inspection.files.take(20).forEach { f -> log(" ${f.path} (${f.size/1024}KB)") } if (inspection.files.size > 20) log(" ... and ${inspection.files.size - 20} more") } @@ -163,76 +183,28 @@ private suspend fun doRestoreVerbose(context: android.content.Context, uri: Uri, log("WARN: ${result.filesProcessed - result.filesRestored} files not restored") } - // Step 4: Metadata consistency check log("") - log("--- STEP 4: Metadata Consistency ---") - try { - val dbFile = context.getDatabasePath("traveldocs.db") - if (dbFile.exists()) { - val db = android.database.sqlite.SQLiteDatabase.openDatabase(dbFile.path, null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY) - - // Check tables exist before querying - val tableCheck = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='documents'", null) - if (tableCheck.count == 0) { tableCheck.close(); db.close(); log("ERROR: documents table not found in restored DB — backup DB may be empty/corrupted"); log("Solution: Restart the app (Room will initialize tables from the restored file)") } else { tableCheck.close() - // Count documents table - val docCursor = db.rawQuery("SELECT COUNT(*) FROM documents", null) - val docCount = if (docCursor.moveToFirst()) docCursor.getInt(0) else -1 - docCursor.close() - log("documents table: $docCount entries") - - // Count metadata entries - val metaCursor = db.rawQuery("SELECT COUNT(*) FROM document_metadata", null) - val metaCount = if (metaCursor.moveToFirst()) metaCursor.getInt(0) else -1 - metaCursor.close() - log("document_metadata table: $metaCount entries") - - // Count tags - val tagCursor = db.rawQuery("SELECT COUNT(*) FROM document_tags", null) - val tagCount = if (tagCursor.moveToFirst()) tagCursor.getInt(0) else -1 - tagCursor.close() - log("document_tags table: $tagCount entries") - - // Check docs with no metadata (potential issue) - val orphanCursor = db.rawQuery("SELECT COUNT(*) FROM documents d WHERE NOT EXISTS (SELECT 1 FROM document_metadata m WHERE m.documentId = d.id)", null) - val orphanCount = if (orphanCursor.moveToFirst()) orphanCursor.getInt(0) else 0 - orphanCursor.close() - if (orphanCount > 0) log("WARN: $orphanCount documents have no metadata entries") - else log("OK: All documents have metadata") - - // Check that encrypted files on disk match DB entries - val docsDir = java.io.File(context.filesDir, "docs") - val diskFileCount = if (docsDir.exists()) docsDir.walkTopDown().filter { it.isFile }.count() else 0 - log("Encrypted files on disk: $diskFileCount") - if (diskFileCount == docCount) { - log("OK: File count matches DB entries") - } else if (diskFileCount > docCount) { - log("WARN: ${diskFileCount - docCount} orphan files on disk (no DB entry)") - } else { - log("WARN: ${docCount - diskFileCount} DB entries missing files on disk") - } - - // DB version - val vCursor = db.rawQuery("PRAGMA user_version", null) - val ver = if (vCursor.moveToFirst()) vCursor.getInt(0) else 0 - vCursor.close() - log("DB version: $ver (app expects: 2)") - if (ver != 2) log("WARN: Version mismatch may trigger schema migration on next Room access") - - db.close() - } // end table exists check - } else { - log("ERROR: Database file not found after restore") - } - } catch (e: Exception) { - log("ERROR: Metadata check failed — ${e.message}") + log("--- STEP 4: Database check ---") + val dbCheck = com.app.paperstow.data.backup.RoomDbFiles.inspect( + com.app.paperstow.data.backup.RoomDbFiles.liveFile(context) + ) + if (!dbCheck.hasDocumentsTable) { + log("ERROR: documents table not found — ${dbCheck.error ?: dbCheck.tables}") + log("STATUS: FAILED — backup database is empty or corrupt") + DebugLogger.e("Restore", "Post-restore DB missing documents table") + return@withContext false } + log("OK: documents table present (${dbCheck.documentCount} rows, v${dbCheck.userVersion})") + log("Tables: ${dbCheck.tables.joinToString()}") + val ok = result.success log("") - log(if (result.success) "STATUS: COMPLETE" else "STATUS: FAILED — ${result.message}") - - DebugLogger.i("Restore", "Verbose restore complete: ${result.filesRestored}/${result.filesProcessed}") + log(if (ok) "STATUS: COMPLETE" else "STATUS: FAILED — ${result.message}") + DebugLogger.i("Restore", "Verbose restore finished ok=$ok ${result.filesRestored}/${result.filesProcessed}") + return@withContext ok } catch (e: Exception) { log("ERROR: ${e.message}") DebugLogger.e("Restore", "Verbose restore failed", e) + return@withContext false } } diff --git a/app/src/main/java/com/app/traveldocs/presentation/common/CountryCodePicker.kt b/app/src/main/java/com/app/paperstow/presentation/common/CountryCodePicker.kt similarity index 98% rename from app/src/main/java/com/app/traveldocs/presentation/common/CountryCodePicker.kt rename to app/src/main/java/com/app/paperstow/presentation/common/CountryCodePicker.kt index b9531b8..b964064 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/common/CountryCodePicker.kt +++ b/app/src/main/java/com/app/paperstow/presentation/common/CountryCodePicker.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.common +package com.app.paperstow.presentation.common import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/com/app/paperstow/presentation/demo/DemoDataViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/demo/DemoDataViewModel.kt new file mode 100644 index 0000000..d5bb10a --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/demo/DemoDataViewModel.kt @@ -0,0 +1,70 @@ +package com.app.paperstow.presentation.demo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.app.paperstow.data.demo.DemoDataRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class DemoUiState( + val busy: Boolean = false, + val loaded: Boolean = false, + val message: String? = null +) + +@HiltViewModel +class DemoDataViewModel @Inject constructor( + private val repository: DemoDataRepository +) : ViewModel() { + + private val _state = MutableStateFlow(DemoUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _state.value = _state.value.copy(loaded = repository.isLoaded()) + } + } + + fun importSampleTrip() { + viewModelScope.launch { + _state.value = _state.value.copy(busy = true, message = null) + val result = repository.importSampleTrip() + _state.value = result.fold( + onSuccess = { count -> + DemoUiState(busy = false, loaded = true, message = "Loaded $count sample papers. Open a folder to preview, search, or share. Remove the sample trip from the menu when you are done.") + }, + onFailure = { e -> + _state.value.copy(busy = false, message = e.message ?: "Could not load sample trip") + } + ) + } + } + + fun removeSampleTrip() { + viewModelScope.launch { + _state.value = _state.value.copy(busy = true, message = null) + val result = repository.removeSampleTrip() + _state.value = result.fold( + onSuccess = { count -> + DemoUiState(busy = false, loaded = false, message = "Removed $count sample papers. Your own documents were not touched.") + }, + onFailure = { e -> + _state.value.copy(busy = false, loaded = _state.value.loaded, message = e.message ?: "Could not remove sample trip") + } + ) + } + } + + fun dismissMessage() { + _state.value = _state.value.copy(message = null) + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/diagnostics/DiagnosticsScreen.kt b/app/src/main/java/com/app/paperstow/presentation/diagnostics/DiagnosticsScreen.kt similarity index 84% rename from app/src/main/java/com/app/traveldocs/presentation/diagnostics/DiagnosticsScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/diagnostics/DiagnosticsScreen.kt index 6c464d5..80f52c6 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/diagnostics/DiagnosticsScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/diagnostics/DiagnosticsScreen.kt @@ -1,9 +1,6 @@ -package com.app.traveldocs.presentation.diagnostics +package com.app.paperstow.presentation.diagnostics -import android.Manifest import android.content.Context -import android.content.pm.PackageManager -import android.location.LocationManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager @@ -23,7 +20,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Battery4Bar import androidx.compose.material.icons.filled.BatteryChargingFull -import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.SignalWifi4Bar import androidx.compose.material.icons.filled.SignalWifiOff import androidx.compose.material3.Button @@ -52,8 +48,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import com.app.traveldocs.debug.TrackingSettingsPanel import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @@ -62,9 +56,6 @@ fun DiagnosticsScreen(onBack: () -> Unit, onViewLogs: () -> Unit) { val context = LocalContext.current var batteryLevel by remember { mutableIntStateOf(-1) } var isCharging by remember { mutableStateOf(false) } - var latitude by remember { mutableStateOf("--") } - var longitude by remember { mutableStateOf("--") } - var accuracy by remember { mutableStateOf("--") } var isOnline by remember { mutableStateOf(false) } var networkType by remember { mutableStateOf("--") } @@ -78,13 +69,6 @@ fun DiagnosticsScreen(onBack: () -> Unit, onViewLogs: () -> Unit) { val caps = cm?.activeNetwork?.let { cm.getNetworkCapabilities(it) } isOnline = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) ?: false networkType = when { caps == null -> "Offline"; caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WiFi"; caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Cellular"; else -> "Other" } - try { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { - val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val loc = lm.getLastKnownLocation(LocationManager.FUSED_PROVIDER) ?: lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) - if (loc != null) { latitude = "%.6f".format(loc.latitude); longitude = "%.6f".format(loc.longitude); accuracy = "%.0fm".format(loc.accuracy) } - } - } catch (_: Exception) {} delay(5000) } } @@ -95,14 +79,8 @@ fun DiagnosticsScreen(onBack: () -> Unit, onViewLogs: () -> Unit) { Spacer(Modifier.height(12.dp)) StatusRow(if (isCharging) Icons.Filled.BatteryChargingFull else Icons.Filled.Battery4Bar, when { batteryLevel > 50 -> Color(0xFF4CAF50); batteryLevel > 20 -> Color(0xFFFFC107); else -> Color(0xFFF44336) }, "Battery", "${batteryLevel}%", if (isCharging) "Charging" else "Discharging") Spacer(Modifier.height(8.dp)) - StatusRow(Icons.Filled.LocationOn, Color(0xFF2196F3), "GPS", "$latitude, $longitude", "Accuracy: $accuracy") - Spacer(Modifier.height(8.dp)) StatusRow(if (isOnline) Icons.Filled.SignalWifi4Bar else Icons.Filled.SignalWifiOff, if (isOnline) Color(0xFF4CAF50) else Color(0xFFF44336), "Network", networkType, if (isOnline) "Connected" else "Offline") Spacer(Modifier.height(20.dp)) - Text("GPS Tracking", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(Modifier.height(8.dp)) - TrackingSettingsPanel() - Spacer(Modifier.height(20.dp)) Text("Debug Logs", fontWeight = FontWeight.Bold, fontSize = 16.sp) Spacer(Modifier.height(8.dp)) Button(onClick = onViewLogs, modifier = Modifier.fillMaxWidth()) { Text("View Debug Logs") } @@ -176,7 +154,7 @@ private fun shareLogsAsZip(context: android.content.Context) { try { val logDir = java.io.File(context.filesDir, "debug_logs") val logFile = java.io.File(logDir, "traveldocs_debug.log") - if (!logFile.exists()) { com.app.traveldocs.debug.DebugLogger.w("Diagnostics", "No log file to share"); return } + if (!logFile.exists()) { com.app.paperstow.debug.DebugLogger.w("Diagnostics", "No log file to share"); return } // Create ZIP in cache val cacheDir = java.io.File(context.cacheDir, "shared_docs") @@ -187,7 +165,7 @@ private fun shareLogsAsZip(context: android.content.Context) { logFile.inputStream().use { it.copyTo(zip) } zip.closeEntry() // Also include telemetry summary if available - val telemetry = com.app.traveldocs.debug.UsageTelemetry.getLocalSummary() + val telemetry = com.app.paperstow.debug.UsageTelemetry.getLocalSummary() zip.putNextEntry(java.util.zip.ZipEntry("telemetry_summary.txt")) zip.write(telemetry.toByteArray()) zip.closeEntry() @@ -203,9 +181,9 @@ private fun shareLogsAsZip(context: android.content.Context) { val resInfoList = context.packageManager.queryIntentActivities(intent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY) for (ri in resInfoList) { context.grantUriPermission(ri.activityInfo.packageName, uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(android.content.Intent.createChooser(intent, "Share logs via").addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) - com.app.traveldocs.debug.DebugLogger.i("Diagnostics", "Logs ZIP shared (${zipFile.length() / 1024}KB)") + com.app.paperstow.debug.DebugLogger.i("Diagnostics", "Logs ZIP shared (${zipFile.length() / 1024}KB)") } catch (e: Exception) { - com.app.traveldocs.debug.DebugLogger.e("Diagnostics", "Failed to share logs ZIP", e) + com.app.paperstow.debug.DebugLogger.e("Diagnostics", "Failed to share logs ZIP", e) } } @@ -279,21 +257,21 @@ private fun runDiagnostics(context: android.content.Context): Pair) { - DebugLogger.i("BatchImport", "Drive folder: ${files.size} files to import") - startBatchImport(files) - } - fun cancel() { importJob?.cancel() _state.value = _state.value.copy(isRunning = false, isCancelled = true) @@ -154,7 +150,7 @@ class BatchImportViewModel @Inject constructor( try { val bytes = context.contentResolver.openInputStream(file.uri)?.use { it.readBytes() } if (bytes == null || bytes.isEmpty()) { skipped++; failedList.add("${file.name} (empty/unreadable)"); continue } - val format = mimeToFormat(file.mimeType) + val format = formatValidator.detect(bytes, file.mimeType, file.name) val doc = ImportedDocument(rawBytes = bytes, format = format, originalFileName = file.name) val result = importUseCase.importAndProcess(doc, "default-member") if (result.isSuccess && file.folderTags.isNotEmpty()) { @@ -179,7 +175,7 @@ class BatchImportViewModel @Inject constructor( } } _state.value = _state.value.copy(isRunning = false, isComplete = true, processedCount = files.size, importedCount = imported, skippedCount = skipped, failedFiles = failedList) - com.app.traveldocs.debug.UsageTelemetry.funnelComplete("batch_import") + com.app.paperstow.debug.UsageTelemetry.funnelComplete("batch_import") DebugLogger.i("BatchImport", "Complete: $imported imported, $skipped skipped") } } @@ -203,19 +199,13 @@ class BatchImportViewModel @Inject constructor( collectFilesRecursive(child, files, pathTags) } } else { - val mime = child.type ?: "" - if (isSupportedMime(mime)) { - files.add(FileToImport(child.uri, child.name ?: "file", mime)) + if (formatValidator.isSupportedForImport(child.type, child.name)) { + files.add(FileToImport(child.uri, child.name ?: "file", child.type)) } } } return files } - - /** - * Recursively collect files, accumulating each folder level as a tag. - * pathTags represents the subfolder names from root to current directory. - */ private fun collectFilesRecursive(dir: DocumentFile, out: MutableList, pathTags: List) { for (child in dir.listFiles()) { if (child.isDirectory) { @@ -223,29 +213,14 @@ class BatchImportViewModel @Inject constructor( val deeperTags = if (folderName != null) pathTags + folderName else pathTags collectFilesRecursive(child, out, deeperTags) } else { - val mime = child.type ?: "" - if (isSupportedMime(mime)) { - out.add(FileToImport(child.uri, child.name ?: "file", mime, folderTags = pathTags)) + if (formatValidator.isSupportedForImport(child.type, child.name)) { + out.add(FileToImport(child.uri, child.name ?: "file", child.type, folderTags = pathTags)) } } } } - private fun isSupportedMime(mime: String): Boolean { - return mime.contains("pdf") || mime.contains("jpeg") || mime.contains("jpg") || mime.contains("png") || mime.contains("video") || mime.contains("webp") || mime.contains("heic") || mime.contains("heif") || mime.contains("bmp") || mime.contains("gif") || mime.contains("dicom") || mime.contains(".dcm") - } - - private fun mimeToFormat(mime: String?): DocumentFormat = when { - mime?.contains("pdf") == true -> DocumentFormat.PDF - mime?.contains("png") == true -> DocumentFormat.PNG - mime?.contains("jpeg") == true || mime?.contains("jpg") == true -> DocumentFormat.JPG - mime?.contains("video") == true -> DocumentFormat.VIDEO - mime?.contains("webp") == true -> DocumentFormat.WEBP - mime?.contains("heic") == true || mime?.contains("heif") == true -> DocumentFormat.HEIC - mime?.contains("bmp") == true -> DocumentFormat.BMP - mime?.contains("gif") == true -> DocumentFormat.GIF - mime?.contains("dicom") == true -> DocumentFormat.DICOM - mime?.contains("audio") == true || mime?.contains("mp3") == true -> DocumentFormat.AUDIO - else -> DocumentFormat.UNKNOWN + companion object { + const val MAX_FOLDER_FILES = 500 } } diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/CameraImportViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/documents/CameraImportViewModel.kt similarity index 91% rename from app/src/main/java/com/app/traveldocs/presentation/documents/CameraImportViewModel.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/CameraImportViewModel.kt index 41d892d..d19e5f3 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/CameraImportViewModel.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/CameraImportViewModel.kt @@ -1,15 +1,15 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import android.content.Context import android.graphics.BitmapFactory import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.app.traveldocs.data.scanner.SmartFileNamer -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.ImportedDocument -import com.app.traveldocs.domain.usecase.DocumentImportUseCase +import com.app.paperstow.data.scanner.SmartFileNamer +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.ImportedDocument +import com.app.paperstow.domain.usecase.DocumentImportUseCase import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/CameraPreviewScreen.kt b/app/src/main/java/com/app/paperstow/presentation/documents/CameraPreviewScreen.kt similarity index 98% rename from app/src/main/java/com/app/traveldocs/presentation/documents/CameraPreviewScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/CameraPreviewScreen.kt index 6553874..da4e6be 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/CameraPreviewScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/CameraPreviewScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import android.graphics.BitmapFactory import androidx.compose.foundation.Image diff --git a/app/src/main/java/com/app/paperstow/presentation/documents/ChecklistEditorScreen.kt b/app/src/main/java/com/app/paperstow/presentation/documents/ChecklistEditorScreen.kt new file mode 100644 index 0000000..7890f2b --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/documents/ChecklistEditorScreen.kt @@ -0,0 +1,160 @@ +package com.app.paperstow.presentation.documents + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.app.paperstow.domain.model.ChecklistItem +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.MarkdownChecklist + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChecklistEditorScreen( + existing: Document? = null, + initialBody: String = "", + extraTags: List = emptyList(), + onBack: () -> Unit, + onSaved: (Document) -> Unit, + viewModel: TextNoteViewModel = hiltViewModel() +) { + val saving by viewModel.saving.collectAsState() + val error by viewModel.error.collectAsState() + val catalog by viewModel.catalog.collectAsState() + val parsed = remember(existing?.id, initialBody) { + if (existing == null && initialBody.isBlank()) MarkdownChecklist.starter() + else MarkdownChecklist.parse(initialBody) + } + var title by remember { + mutableStateOf( + existing?.originalFileName + ?.removeSuffix(".md")?.removeSuffix(".MD") + ?.ifBlank { parsed.title } + ?: parsed.title.ifBlank { "Trip plans" } + ) + } + var notes by remember { mutableStateOf(parsed.notes) } + var tags by remember { mutableStateOf(seedEditorTags(existing, extraTags, "Plans")) } + val items = remember { + mutableStateListOf().also { list -> + list.addAll(parsed.items.ifEmpty { listOf(ChecklistItem("")) }) + } + } + val colors = MaterialTheme.colorScheme + + fun persist() { + val doc = MarkdownChecklist(title = title, notes = notes, items = items.toList()) + val body = doc.toMarkdown() + if (existing == null) viewModel.createChecklist(title, body, tags, onSaved) + else viewModel.updateDocument(existing, title, body, tags, onSaved) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (existing == null) "New checklist" else "Edit checklist") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } + }, + actions = { + if (saving) { + CircularProgressIndicator(Modifier.size(22.dp).padding(end = 4.dp), strokeWidth = 2.dp) + } else { + IconButton(onClick = { persist() }) { Icon(Icons.Filled.Check, "Save") } + } + } + ) + } + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + NoteTagPicker(selected = tags, catalog = catalog, onChange = { tags = it }) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2 + ) + Spacer(Modifier.height(16.dp)) + Text("Items", fontSize = 13.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + items.forEachIndexed { index, item -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox( + checked = item.done, + onCheckedChange = { checked -> items[index] = item.copy(done = checked) } + ) + OutlinedTextField( + value = item.text, + onValueChange = { items[index] = item.copy(text = it) }, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { Text("Action item") } + ) + IconButton(onClick = { if (items.size > 1) items.removeAt(index) }) { + Icon(Icons.Filled.Close, "Remove") + } + } + Spacer(Modifier.height(8.dp)) + } + OutlinedButton(onClick = { items += ChecklistItem("") }, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Add, null, Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text("Add item") + } + if (error != null) { + Spacer(Modifier.height(12.dp)) + Text(error!!, color = colors.error, fontSize = 13.sp) + } + } + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentListScreen.kt b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentListScreen.kt similarity index 88% rename from app/src/main/java/com/app/traveldocs/presentation/documents/DocumentListScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/DocumentListScreen.kt index 16170e6..3fceda3 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentListScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentListScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RestoreFromTrash import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox @@ -56,16 +57,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentType +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentType @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun DocumentListScreen(onBack: () -> Unit, onDocumentClick: (Document) -> Unit, onSetDocList: (List) -> Unit = {}, viewModel: DocumentListViewModel = hiltViewModel()) { +fun DocumentListScreen( + onBack: () -> Unit, + onDocumentClick: (Document) -> Unit, + onSetDocList: (List) -> Unit = {}, + filterTag: String? = null, + viewModel: DocumentListViewModel = hiltViewModel() +) { + val context = LocalContext.current val documents by viewModel.documents.collectAsState() val recycleBin by viewModel.recycleBin.collectAsState() val showingTrash by viewModel.showingTrash.collectAsState() @@ -73,6 +82,9 @@ fun DocumentListScreen(onBack: () -> Unit, onDocumentClick: (Document) -> Unit, val selectionMode by viewModel.selectionMode.collectAsState() val bulkState by viewModel.bulkDeleteState.collectAsState() var showBulkConfirm by remember { mutableStateOf(false) } + val visibleDocs = if (filterTag.isNullOrBlank()) documents + else documents.filter { doc -> doc.tags.any { it.name == filterTag } } + val filtered = !filterTag.isNullOrBlank() // Bulk delete confirmation if (showBulkConfirm) { @@ -104,7 +116,7 @@ fun DocumentListScreen(onBack: () -> Unit, onDocumentClick: (Document) -> Unit, Scaffold(topBar = { TopAppBar( - title = { Text(if (selectionMode) "${selectedIds.size} selected" else if (showingTrash) "Recycle Bin" else "My Documents") }, + title = { Text(if (selectionMode) "${selectedIds.size} selected" else if (showingTrash) "Recycle Bin" else filterTag ?: "My Documents") }, navigationIcon = { IconButton(onClick = { if (selectionMode) viewModel.clearSelection() else if (showingTrash) viewModel.toggleTrashView() else onBack() }) { Icon(if (selectionMode) Icons.Filled.Close else Icons.Filled.ArrowBack, "Back") @@ -113,8 +125,9 @@ fun DocumentListScreen(onBack: () -> Unit, onDocumentClick: (Document) -> Unit, actions = { if (selectionMode) { IconButton(onClick = { viewModel.selectAll() }) { Icon(Icons.Filled.SelectAll, "Select all") } + IconButton(onClick = { viewModel.shareSelected(context) }) { Icon(Icons.Filled.Share, "Share selected") } IconButton(onClick = { showBulkConfirm = true }) { Icon(Icons.Filled.Delete, "Delete selected", tint = Color(0xFFF44336)) } - } else { + } else if (!filtered) { if (showingTrash && recycleBin.isNotEmpty()) IconButton(onClick = { viewModel.emptyTrash() }) { Icon(Icons.Filled.DeleteForever, "Empty", tint = Color(0xFFF44336)) } BadgedBox(badge = { if (recycleBin.isNotEmpty()) Badge { Text("${recycleBin.size}") } }) { IconButton(onClick = { viewModel.toggleTrashView() }) { Icon(if (showingTrash) Icons.Filled.Description else Icons.Filled.DeleteSweep, "Trash") } @@ -145,20 +158,21 @@ fun DocumentListScreen(onBack: () -> Unit, onDocumentClick: (Document) -> Unit, } } } else { - if (documents.isEmpty()) { + if (visibleDocs.isEmpty()) { Column(Modifier.fillMaxSize().padding(padding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Icon(Icons.Filled.Description, null, Modifier.size(64.dp), tint = Color.LightGray) - Spacer(Modifier.height(16.dp)); Text("No documents yet", fontSize = 18.sp, color = Color.Gray) - Text("Import a document to get started", fontSize = 13.sp, color = Color.LightGray) + Spacer(Modifier.height(16.dp)) + Text(if (filtered) "No papers with this tag" else "Nothing stowed yet", fontSize = 18.sp, color = Color.Gray) + Text(if (filtered) "Tag a document with “$filterTag” to see it here" else "Import a passport, visa, or ticket to get started", fontSize = 13.sp, color = Color.LightGray) } } else { LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { item { Text("Long-press to select multiple", fontSize = 11.sp, color = Color.LightGray, modifier = Modifier.padding(vertical = 4.dp)) } - items(documents) { doc -> + items(visibleDocs, key = { it.id }) { doc -> val isSelected = doc.id in selectedIds Card( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - .combinedClickable(onClick = { if (selectionMode) viewModel.toggleSelection(doc.id) else { onSetDocList(documents); onDocumentClick(doc) } }, onLongClick = { viewModel.toggleSelection(doc.id) }), + .combinedClickable(onClick = { if (selectionMode) viewModel.toggleSelection(doc.id) else { onSetDocList(visibleDocs); onDocumentClick(doc) } }, onLongClick = { viewModel.toggleSelection(doc.id) }), shape = RoundedCornerShape(10.dp), colors = CardDefaults.cardColors(containerColor = if (isSelected) Color(0xFFE3F2FD) else Color.White), elevation = CardDefaults.cardElevation(1.dp) diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentListViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentListViewModel.kt similarity index 86% rename from app/src/main/java/com/app/traveldocs/presentation/documents/DocumentListViewModel.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/DocumentListViewModel.kt index 984ef58..75ad25d 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentListViewModel.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentListViewModel.kt @@ -1,12 +1,13 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.data.local.TravelDocsDatabase -import com.app.traveldocs.domain.repository.DocumentRepository +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.data.local.TravelDocsDatabase +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job @@ -27,6 +28,7 @@ data class BulkDeleteState( @HiltViewModel class DocumentListViewModel @Inject constructor( private val documentRepository: DocumentRepository, + private val fileStorage: DocumentFileStorage, private val database: TravelDocsDatabase, @ApplicationContext private val context: Context ) : ViewModel() { @@ -64,7 +66,7 @@ class DocumentListViewModel @Inject constructor( _documents.value = docs.sortedByDescending { it.createdAt } } } catch (e: Exception) { - com.app.traveldocs.debug.DebugLogger.e("DocList", "Database query failed (may need app restart after failed restore)", e) + com.app.paperstow.debug.DebugLogger.e("DocList", "Database query failed (may need app restart after failed restore)", e) _documents.value = emptyList() } } @@ -75,7 +77,7 @@ class DocumentListViewModel @Inject constructor( * Use after restore or any operation that replaces the database file. */ fun forceRefresh() { - com.app.traveldocs.debug.DebugLogger.i("DocList", "Force refresh triggered — invalidating Room cache") + com.app.paperstow.debug.DebugLogger.i("DocList", "Force refresh triggered — invalidating Room cache") // Invalidate all tables so Room re-reads from disk database.invalidationTracker.refreshVersionsAsync() startCollecting() @@ -152,10 +154,14 @@ class DocumentListViewModel @Inject constructor( fun toggleTrashView() { _showingTrash.value = !_showingTrash.value } fun shareSelected(context: Context) { - val docs = _documents.value.filter { it.id in _selectedIds.value } + share(context, _documents.value.filter { it.id in _selectedIds.value }) + } + + fun share(context: Context, docs: List) { if (docs.isEmpty()) return viewModelScope.launch { - ShareHelper.shareDocuments(context, docs, documentRepository as com.app.traveldocs.domain.repository.DocumentFileStorage) + ShareHelper.shareDocuments(context, docs, fileStorage) clearSelection() } - }} + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentViewerScreen.kt b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentViewerScreen.kt similarity index 57% rename from app/src/main/java/com/app/traveldocs/presentation/documents/DocumentViewerScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/DocumentViewerScreen.kt index 3793cb3..a25f842 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentViewerScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentViewerScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import android.content.Context import android.content.Intent @@ -32,21 +32,19 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip -import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -76,16 +74,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel -import com.app.traveldocs.data.local.SecureDocumentManager -import com.app.traveldocs.data.local.TagColorStore -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat +import com.app.paperstow.data.local.TagColorStore +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.MarkdownChecklist +import com.app.paperstow.domain.safety.GpxTrail +import com.app.paperstow.domain.safety.UniqueLocations import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.withContext import java.io.File -import java.util.concurrent.Executors +import java.text.DateFormat +import java.util.Date @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -94,6 +94,7 @@ fun DocumentViewerScreen( onBack: () -> Unit, onPrev: (() -> Unit)? = null, onNext: (() -> Unit)? = null, + onEditNote: ((String) -> Unit)? = null, viewModel: DocumentViewerViewModel = hiltViewModel() ) { val fileBytes by viewModel.fileBytes.collectAsState() @@ -106,17 +107,11 @@ fun DocumentViewerScreen( var showAddTagDialog by remember { mutableStateOf(false) } var docName by remember { mutableStateOf(document.originalFileName ?: "Document") } var detailsExpanded by remember { mutableStateOf(false) } - val secureDocMgr = remember { SecureDocumentManager(context) } - var hasPin by remember { mutableStateOf(secureDocMgr.hasPinSet(document.id)) } - var showSetPinDialog by remember { mutableStateOf(false) } - var showRemovePinDialog by remember { mutableStateOf(false) } - var pinUnlocked by remember { mutableStateOf(!secureDocMgr.hasPinSet(document.id)) } - var showPinUnlockDialog by remember { mutableStateOf(false) } var boundaryMsg by remember { mutableStateOf(null) } // Auto-dismiss boundary message LaunchedEffect(boundaryMsg) { if (boundaryMsg != null) { kotlinx.coroutines.delay(1500); boundaryMsg = null } } - LaunchedEffect(document.id) { if (pinUnlocked) viewModel.loadFile(document.id) } + LaunchedEffect(document.id, document.updatedAt) { viewModel.loadFile(document.id) } // Dialogs if (showRenameDialog) { @@ -130,40 +125,22 @@ fun DocumentViewerScreen( var tagInput by remember { mutableStateOf("") } AlertDialog(onDismissRequest = { showAddTagDialog = false }, title = { Text("Add Tag") }, text = { OutlinedTextField(value = tagInput, onValueChange = { tagInput = it }, label = { Text("Tag name") }, singleLine = true, modifier = Modifier.fillMaxWidth()) }, - confirmButton = { TextButton(onClick = { viewModel.addTag(com.app.traveldocs.data.local.InputSanitizer.sanitizeTag(tagInput)); showAddTagDialog = false }) { Text("Add") } }, + confirmButton = { TextButton(onClick = { viewModel.addTag(com.app.paperstow.data.local.InputSanitizer.sanitizeTag(tagInput)); showAddTagDialog = false }) { Text("Add") } }, dismissButton = { TextButton(onClick = { showAddTagDialog = false }) { Text("Cancel") } }) } - if (showPinUnlockDialog) { - var pin by remember { mutableStateOf("") } - var pinError by remember { mutableStateOf(null) } - AlertDialog(onDismissRequest = { showPinUnlockDialog = false }, title = { Text("Enter PIN") }, - text = { Column { Text("This document is PIN-protected.", fontSize = 13.sp); Spacer(Modifier.height(12.dp)) - OutlinedTextField(value = pin, onValueChange = { pin = it; pinError = null }, label = { Text("PIN") }, singleLine = true, modifier = Modifier.fillMaxWidth(), visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()) - if (pinError != null) { Spacer(Modifier.height(4.dp)); Text(pinError!!, color = Color(0xFFF44336), fontSize = 12.sp) } } }, - confirmButton = { TextButton(onClick = { if (secureDocMgr.verifyPin(document.id, pin) != null) { pinUnlocked = true; showPinUnlockDialog = false; viewModel.loadFile(document.id) } else pinError = "Incorrect PIN" }) { Text("Unlock") } }, - dismissButton = { TextButton(onClick = { showPinUnlockDialog = false; onBack() }) { Text("Cancel") } }) - } - if (showSetPinDialog) { - var pin by remember { mutableStateOf("") }; var confirmPin by remember { mutableStateOf("") }; var pinError by remember { mutableStateOf(null) } - AlertDialog(onDismissRequest = { showSetPinDialog = false }, title = { Text("Lock with PIN") }, - text = { Column { Text("PIN cannot be recovered if forgotten.", fontSize = 12.sp, color = Color(0xFFF44336)); Spacer(Modifier.height(12.dp)) - OutlinedTextField(value = pin, onValueChange = { pin = it; pinError = null }, label = { Text("PIN (4+)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()); Spacer(Modifier.height(8.dp)) - OutlinedTextField(value = confirmPin, onValueChange = { confirmPin = it; pinError = null }, label = { Text("Confirm") }, singleLine = true, modifier = Modifier.fillMaxWidth(), visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()) - if (pinError != null) { Spacer(Modifier.height(4.dp)); Text(pinError!!, color = Color(0xFFF44336), fontSize = 12.sp) } } }, - confirmButton = { TextButton(onClick = { when { pin.length < 4 -> pinError = "Min 4 characters"; pin != confirmPin -> pinError = "PINs don't match"; else -> { secureDocMgr.setPin(document.id, pin); viewModel.addTag(SecureDocumentManager.SECURE_TAG); hasPin = true; showSetPinDialog = false } } }) { Text("Lock") } }, - dismissButton = { TextButton(onClick = { showSetPinDialog = false }) { Text("Cancel") } }) - } - if (showRemovePinDialog) { - var pin by remember { mutableStateOf("") }; var pinError by remember { mutableStateOf(null) } - AlertDialog(onDismissRequest = { showRemovePinDialog = false }, title = { Text("Remove PIN") }, - text = { Column { OutlinedTextField(value = pin, onValueChange = { pin = it; pinError = null }, label = { Text("Current PIN") }, singleLine = true, modifier = Modifier.fillMaxWidth(), visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation()) - if (pinError != null) { Spacer(Modifier.height(4.dp)); Text(pinError!!, color = Color(0xFFF44336), fontSize = 12.sp) } } }, - confirmButton = { TextButton(onClick = { if (secureDocMgr.verifyPin(document.id, pin) != null) { secureDocMgr.removePin(document.id); viewModel.removeTag(SecureDocumentManager.SECURE_TAG); hasPin = false; showRemovePinDialog = false } else pinError = "Incorrect PIN" }) { Text("Remove") } }, - dismissButton = { TextButton(onClick = { showRemovePinDialog = false }) { Text("Cancel") } }) - } Scaffold(topBar = { TopAppBar(title = { Text(docName, maxLines = 1) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }, - actions = { if (fileBytes != null && !loading && pinUnlocked) { IconButton(onClick = { shareDocument(context, fileBytes!!, docName, document.format) }) { Icon(Icons.Filled.Share, "Share") } }; IconButton(onClick = { showRenameDialog = true }) { Icon(Icons.Filled.Edit, "Rename") } }) } + actions = { + if (document.format in listOf(DocumentFormat.TEXT, DocumentFormat.MARKDOWN) && fileBytes != null && onEditNote != null) { + IconButton(onClick = { + val text = try { String(fileBytes!!, Charsets.UTF_8) } catch (_: Exception) { "" } + onEditNote(text) + }) { Icon(Icons.Filled.Edit, "Edit note") } + } else { + IconButton(onClick = { showRenameDialog = true }) { Icon(Icons.Filled.Edit, "Rename") } + } + if (fileBytes != null && !loading) { IconButton(onClick = { shareDocument(context, fileBytes!!, docName, document.format) }) { Icon(Icons.Filled.Share, "Share") } } + }) } ) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding)) { Box(modifier = Modifier.fillMaxWidth().weight(1f).background(Color.White) @@ -181,7 +158,6 @@ fun DocumentViewerScreen( } }, contentAlignment = Alignment.Center) { when { - !pinUnlocked -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Filled.Lock, null, tint = Color(0xFFE65100), modifier = Modifier.size(64.dp)); Spacer(Modifier.height(12.dp)); Text("PIN Protected", fontSize = 18.sp, fontWeight = FontWeight.Bold); Spacer(Modifier.height(8.dp)); Text("Enter PIN to view", fontSize = 13.sp, color = Color.Gray); Spacer(Modifier.height(16.dp)); Button(onClick = { showPinUnlockDialog = true }) { Text("Unlock") } } } loading -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator(modifier = Modifier.size(48.dp)); Spacer(Modifier.height(12.dp)); Text("Loading...", fontSize = 13.sp, color = Color.Gray) } } fileBytes != null && document.format in listOf(DocumentFormat.JPG, DocumentFormat.PNG, DocumentFormat.WEBP, DocumentFormat.HEIC, DocumentFormat.BMP, DocumentFormat.GIF) -> { val bitmapState = produceState(null, fileBytes) { value = withContext(Dispatchers.Default) { try { val b = fileBytes!!; val o = BitmapFactory.Options().apply { inJustDecodeBounds = true }; BitmapFactory.decodeByteArray(b, 0, b.size, o); val s = if (maxOf(o.outWidth, o.outHeight) > 4096) { var x = 1; while (maxOf(o.outWidth, o.outHeight) / x > 4096) x *= 2; x } else 1; BitmapFactory.decodeByteArray(b, 0, b.size, BitmapFactory.Options().apply { inSampleSize = s }) } catch (e: Exception) { null } } } @@ -191,32 +167,85 @@ fun DocumentViewerScreen( } else { CircularProgressIndicator(Modifier.size(36.dp)) } } fileBytes != null && document.format == DocumentFormat.PDF -> { - val pdfInfoState = produceState?>(null, fileBytes) { value = withContext(Dispatchers.IO) { - try { - val f = File.createTempFile("p", ".pdf", context.cacheDir); f.writeBytes(fileBytes!!); val fd = android.os.ParcelFileDescriptor.open(f, android.os.ParcelFileDescriptor.MODE_READ_ONLY); Triple(f, fd, android.graphics.pdf.PdfRenderer(fd)) - } catch (e: Exception) { - DebugLogger.e("PDF", "PDFRenderer creation failed: ${e.message}", e); null - } - } } - val pdfInfo = pdfInfoState.value - if (pdfInfo != null) { val (pdfFile, fd, renderer) = pdfInfo; val pageCount = renderer.pageCount; val pdfDispatcher = remember { Executors.newSingleThreadExecutor().asCoroutineDispatcher() }; var pdfScale by remember { mutableFloatStateOf(1f) }; var ox by remember { mutableFloatStateOf(0f) }; var oy by remember { mutableFloatStateOf(0f) } - Box(Modifier.fillMaxSize().pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> pdfScale = (pdfScale * zoom).coerceIn(1f, 5f); if (pdfScale > 1f) { ox += pan.x; oy += pan.y } else { ox = 0f; oy = 0f } } }) { - LazyColumn(Modifier.fillMaxSize().background(Color.White).graphicsLayer(scaleX = pdfScale, scaleY = pdfScale, translationX = ox, translationY = oy)) { - items(pageCount) { idx -> - val bmpState = produceState(null, idx) { - value = withContext(pdfDispatcher) { - try { - val page = renderer.openPage(idx); val bmp = Bitmap.createBitmap(page.width * 2, page.height * 2, Bitmap.Config.ARGB_8888); bmp.eraseColor(android.graphics.Color.WHITE); page.render(bmp, null, null, android.graphics.pdf.PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY); page.close(); bmp - } catch (e: Exception) { - DebugLogger.e("PDF", "Page render failed: idx=$idx, err=${e.message}", e); null - } - } + PdfPreview(fileBytes!!, Modifier.fillMaxSize()) + } + fileBytes != null && document.format == DocumentFormat.MARKDOWN -> { + val text = remember(fileBytes) { + try { String(fileBytes!!, Charsets.UTF_8) } catch (_: Exception) { "" } + } + val list = remember(text) { MarkdownChecklist.parse(text) } + LazyColumn(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).padding(16.dp)) { + if (list.title.isNotBlank()) { + item { Text(list.title, fontWeight = FontWeight.SemiBold, fontSize = 18.sp); Spacer(Modifier.height(8.dp)) } + } + if (list.notes.isNotBlank()) { + item { Text(list.notes, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant); Spacer(Modifier.height(12.dp)) } + } + items(list.items.size) { idx -> + val item = list.items[idx] + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Checkbox( + checked = item.done, + onCheckedChange = { checked -> + val updated = list.items.toMutableList() + updated[idx] = item.copy(done = checked) + viewModel.replaceBytes(list.copy(items = updated).toMarkdown().toByteArray(Charsets.UTF_8)) } - val bmp = bmpState.value; if (bmp != null) { Image(bitmap = bmp.asImageBitmap(), contentDescription = "Page ${idx+1}", modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), contentScale = ContentScale.FillWidth) } else { Box(Modifier.fillMaxWidth().height(300.dp).background(Color(0xFFF5F5F5)), contentAlignment = Alignment.Center) { Column { CircularProgressIndicator(Modifier.size(24.dp)); Text("Page ${idx+1} loading", fontSize = 11.sp, color = Color.Gray) } } } - } + ) + Text(item.text, fontSize = 16.sp) } } - } else { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Filled.PictureAsPdf, null, tint = Color(0xFFF44336), modifier = Modifier.size(64.dp)); Spacer(Modifier.height(8.dp)); Text("PDF preview failed", fontSize = 13.sp, color = Color.Gray); Text("File may be corrupted or not a valid PDF", fontSize = 11.sp, color = Color(0xFF9E9E9E)) } } + if (list.items.isEmpty()) { + item { Text(text.ifBlank { "Empty checklist" }, fontFamily = FontFamily.SansSerif, fontSize = 14.sp) } + } + } + } + fileBytes != null && document.format == DocumentFormat.GPX -> { + val places = remember(fileBytes) { + try { GpxTrail.parse(String(fileBytes!!, Charsets.UTF_8)) } catch (_: Exception) { emptyList() } + } + if (places.isEmpty()) { + Text("No trail points in this GPX file", color = Color.Gray, fontSize = 14.sp) + } else { + LazyColumn(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).padding(16.dp)) { + item { + Text("Trail · ${places.size} places", fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + Spacer(Modifier.height(8.dp)) + } + items(places.size) { idx -> + val place = places[idx] + val time = if (place.timestamp > 0) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(place.timestamp)) + } else "Time unknown" + Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Column(Modifier.padding(12.dp).clickable { + val geo = android.net.Uri.parse("geo:${place.latitude},${place.longitude}?q=${place.latitude},${place.longitude}") + try { + context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, geo)) + } catch (_: Exception) { + context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(UniqueLocations.mapsUrl(place.latitude, place.longitude)))) + } + }) { + Text(time, fontWeight = FontWeight.Medium, fontSize = 14.sp) + Text("%.5f, %.5f".format(place.latitude, place.longitude), fontSize = 12.sp, color = Color.Gray) + UniqueLocations.batteryLabel(place.batteryPercent)?.let { Text(it, fontSize = 12.sp, color = Color.Gray) } + } + } + } + } + } + } + fileBytes != null && document.format == DocumentFormat.TEXT -> { + val text = remember(fileBytes) { + try { String(fileBytes!!, Charsets.UTF_8) } catch (_: Exception) { null } + } + if (text != null) { + LazyColumn(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface).padding(16.dp)) { + item { Text(text, fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface) } + } + } else { + Text("Could not read as text", color = Color.Gray, fontSize = 14.sp) + } } fileBytes != null && (document.format == DocumentFormat.VIDEO || document.format == DocumentFormat.AUDIO) -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Filled.OpenInNew, null, tint = Color(0xFF2196F3), modifier = Modifier.size(64.dp)); Spacer(Modifier.height(12.dp)); Text(if (document.format == DocumentFormat.AUDIO) "Audio" else "Video", fontSize = 16.sp); Text("${fileBytes!!.size / 1024} KB", color = Color.Gray, fontSize = 13.sp); Spacer(Modifier.height(16.dp)); Button(onClick = { val mime = if (document.format == DocumentFormat.AUDIO) "audio/mpeg" else "video/mp4" @@ -235,19 +264,26 @@ fun DocumentViewerScreen( textAlign = androidx.compose.ui.text.style.TextAlign.Center) } // Bottom bar - if (fileBytes != null && !loading && pinUnlocked) { Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { val mime = when (document.format) { DocumentFormat.PDF -> "application/pdf"; DocumentFormat.JPG -> "image/jpeg"; DocumentFormat.PNG -> "image/png"; DocumentFormat.AUDIO -> "audio/*"; else -> "application/octet-stream" }; openExternally(context, fileBytes!!, docName, mime) }, modifier = Modifier.weight(1f)) { Icon(Icons.Filled.OpenInNew, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Open External", fontSize = 12.sp) } } } + if (fileBytes != null && !loading) { Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (document.format in listOf(DocumentFormat.TEXT, DocumentFormat.MARKDOWN) && onEditNote != null) { + Button(onClick = { + val text = try { String(fileBytes!!, Charsets.UTF_8) } catch (_: Exception) { "" } + onEditNote(text) + }, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Edit, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Edit", fontSize = 12.sp) + } + } + Button(onClick = { openExternally(context, fileBytes!!, docName, mimeFor(document.format)) }, modifier = Modifier.weight(1f)) { Icon(Icons.Filled.OpenInNew, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Open External", fontSize = 12.sp) } + } } // Properties toggle TextButton(onClick = { detailsExpanded = !detailsExpanded }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { Icon(if (detailsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(if (detailsExpanded) "Hide Properties" else "Show Properties", fontSize = 13.sp) } if (detailsExpanded) { Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp)) { // Tags Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Tags", fontWeight = FontWeight.SemiBold, fontSize = 14.sp, modifier = Modifier.weight(1f)); IconButton(onClick = { showAddTagDialog = true }, modifier = Modifier.size(32.dp)) { Icon(Icons.Filled.Add, "Add", tint = Color(0xFF1565C0)) } } - if (tags.isEmpty()) { Text("No tags", fontSize = 12.sp, color = Color.Gray) } else { FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { tags.filter { !it.name.startsWith("__") }.forEach { tag -> InputChip(selected = false, onClick = {}, label = { Text(tag.name, fontSize = 12.sp) }, trailingIcon = { Icon(Icons.Filled.Close, "Remove", modifier = Modifier.size(16.dp).clickable { viewModel.removeTag(tag.name) }) }, colors = InputChipDefaults.inputChipColors(containerColor = Color(TagColorStore(context).getColor(tag.name)))) } } } } } - Spacer(Modifier.height(8.dp)) - // PIN - Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = if (hasPin) Color(0xFFFFF3E0) else Color.White)) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(if (hasPin) "PIN-locked" else "No PIN", modifier = Modifier.weight(1f), fontSize = 13.sp); if (hasPin) { TextButton(onClick = { showRemovePinDialog = true }) { Text("Remove", fontSize = 12.sp) } } else { TextButton(onClick = { showSetPinDialog = true }) { Text("Set PIN", fontSize = 12.sp, color = Color(0xFF1565C0)) } } } } + if (tags.isEmpty()) { Text("No tags", fontSize = 12.sp, color = Color.Gray) } else { FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { tags.filter { !it.name.startsWith("__") }.forEach { tag -> val store = TagColorStore(context); InputChip(selected = false, onClick = {}, label = { Text(tag.name, fontSize = 12.sp) }, leadingIcon = { com.app.paperstow.presentation.tags.TagMark(tag.name, store, size = 18.dp) }, trailingIcon = { Icon(Icons.Filled.Close, "Remove", modifier = Modifier.size(16.dp).clickable { viewModel.removeTag(tag.name) }) }) } } } } } Spacer(Modifier.height(8.dp)) // Properties - Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Column(Modifier.padding(12.dp)) { Text("Properties", fontWeight = FontWeight.Bold, fontSize = 14.sp); Spacer(Modifier.height(6.dp)); MetaRow("Type", document.type.name); MetaRow("Format", document.format.name); MetaRow("Confidence", "${((document.extractionConfidence ?: 0f) * 100).toInt()}%"); MetaRow("Doc ID", document.id.take(8) + "..."); Text(filePath.ifEmpty { "" }, fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF616161)) + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Column(Modifier.padding(12.dp)) { Text("Properties", fontWeight = FontWeight.Bold, fontSize = 14.sp); Spacer(Modifier.height(6.dp)); MetaRow("Type", document.type.name); MetaRow("Format", document.format.name); MetaRow("Confidence", "${((document.extractionConfidence ?: 0f) * 100).toInt()}%"); Text(filePath.ifEmpty { "" }, fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF616161)) if (document.metadata.isNotEmpty()) { Spacer(Modifier.height(8.dp)); Text("Extracted", fontWeight = FontWeight.SemiBold, fontSize = 13.sp); document.metadata.forEach { (f, v) -> MetaRow(f.name.replace("_", " "), v) } } } } } } } @@ -257,13 +293,13 @@ fun DocumentViewerScreen( private fun shareDocument(context: Context, bytes: ByteArray, fileName: String, format: DocumentFormat) { try { val cacheDir = File(context.cacheDir, "shared_docs"); cacheDir.mkdirs() - val ext = when (format) { DocumentFormat.PDF -> ".pdf"; DocumentFormat.JPG -> ".jpg"; DocumentFormat.PNG -> ".png"; DocumentFormat.VIDEO -> ".mp4"; DocumentFormat.AUDIO -> ".mp3"; else -> "" } + val ext = extensionFor(format) val baseName = if (fileName.contains('.')) fileName.substringBeforeLast('.') else fileName val safeName = baseName + ext val file = File(cacheDir, safeName); file.writeBytes(bytes) DebugLogger.i("Viewer", "Share file written: ${file.absolutePath} (${file.length()} bytes)") val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) - val mime = when (format) { DocumentFormat.PDF -> "application/pdf"; DocumentFormat.JPG -> "image/jpeg"; DocumentFormat.PNG -> "image/png"; DocumentFormat.VIDEO -> "video/mp4"; DocumentFormat.AUDIO -> "audio/mpeg"; else -> "application/octet-stream" } + val mime = mimeFor(format) val intent = Intent(Intent.ACTION_SEND).apply { type = mime; putExtra(Intent.EXTRA_STREAM, uri); addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } val resInfoList = context.packageManager.queryIntentActivities(intent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY) for (ri in resInfoList) { context.grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -299,6 +335,39 @@ private fun openExternally(context: Context, bytes: ByteArray, fileName: String, } catch (e: Exception) { DebugLogger.e("Viewer", "Open failed", e) } } +private fun extensionFor(format: DocumentFormat): String = when (format) { + DocumentFormat.PDF -> ".pdf" + DocumentFormat.JPG -> ".jpg" + DocumentFormat.PNG -> ".png" + DocumentFormat.TEXT -> ".txt" + DocumentFormat.MARKDOWN -> ".md" + DocumentFormat.GPX -> ".gpx" + DocumentFormat.WEBP -> ".webp" + DocumentFormat.HEIC -> ".heic" + DocumentFormat.BMP -> ".bmp" + DocumentFormat.GIF -> ".gif" + DocumentFormat.VIDEO -> ".mp4" + DocumentFormat.AUDIO -> ".mp3" + else -> "" +} + +private fun mimeFor(format: DocumentFormat): String = when (format) { + DocumentFormat.PDF -> "application/pdf" + DocumentFormat.JPG -> "image/jpeg" + DocumentFormat.PNG -> "image/png" + DocumentFormat.TEXT -> "text/plain" + DocumentFormat.MARKDOWN -> "text/markdown" + DocumentFormat.GPX -> "application/gpx+xml" + DocumentFormat.WEBP -> "image/webp" + DocumentFormat.HEIC -> "image/heic" + DocumentFormat.BMP -> "image/bmp" + DocumentFormat.GIF -> "image/gif" + DocumentFormat.VIDEO -> "video/mp4" + DocumentFormat.AUDIO -> "audio/mpeg" + DocumentFormat.DICOM -> "application/dicom" + DocumentFormat.UNKNOWN -> "application/octet-stream" +} + @Composable private fun MetaRow(label: String, value: String) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { Text(label, fontSize = 11.sp, color = Color.Gray); Text(value, fontSize = 11.sp, fontWeight = FontWeight.Medium) } diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentViewerViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentViewerViewModel.kt similarity index 86% rename from app/src/main/java/com/app/traveldocs/presentation/documents/DocumentViewerViewModel.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/DocumentViewerViewModel.kt index 0c9b45b..682c165 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/DocumentViewerViewModel.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/DocumentViewerViewModel.kt @@ -1,14 +1,14 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.repository.DocumentFileStorage -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.repository.TagRepository +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.repository.TagRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.isActive @@ -123,9 +123,20 @@ class DocumentViewerViewModel @Inject constructor( } } + fun replaceBytes(bytes: ByteArray) { + if (currentDocId.isEmpty()) return + viewModelScope.launch { + fileStorage.replace(currentDocId, bytes).onSuccess { + _fileBytes.value = bytes + }.onFailure { e -> + DebugLogger.e("ViewerVM", "Replace failed", e) + } + } + } + fun renameDocument(document: Document, newName: String) { if (newName.isBlank()) return - val sanitizedName = com.app.traveldocs.data.local.InputSanitizer.sanitizeFilename(newName) + val sanitizedName = com.app.paperstow.data.local.InputSanitizer.sanitizeFilename(newName) viewModelScope.launch { DebugLogger.i("ViewerVM", "Renaming doc ${document.id} to '$newName'") val updated = document.copy(originalFileName = sanitizedName, updatedAt = java.time.Instant.now()) diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/ImportScreen.kt b/app/src/main/java/com/app/paperstow/presentation/documents/ImportScreen.kt similarity index 74% rename from app/src/main/java/com/app/traveldocs/presentation/documents/ImportScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/ImportScreen.kt index 6b82953..f6ea1f1 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/ImportScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/ImportScreen.kt @@ -1,9 +1,11 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents +import android.app.Activity +import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts -import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -19,11 +21,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.LaunchedEffect import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Button @@ -70,17 +76,11 @@ fun ImportScreen(onDone: () -> Unit, importViewModel: ImportViewModel = hiltView val folderPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> if (uri != null) { pendingFolderUri = uri; showSubfolderDialog = true } } - val drivePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris: List -> - if (uris.isNotEmpty()) { - val files = uris.map { uri -> FileToImport(uri, uri.lastPathSegment ?: "file", context.contentResolver.getType(uri)) } - batchViewModel.importFromDriveFiles(files) - mode = "batch_progress" - } - } - var capturedBytes by remember { mutableStateOf(null) } var showTagDialog by remember { mutableStateOf(false) } var cameraTags by remember { mutableStateOf("") } + var cameraHelp by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> if (bitmap != null) { val baos = java.io.ByteArrayOutputStream() @@ -89,8 +89,48 @@ fun ImportScreen(onDone: () -> Unit, importViewModel: ImportViewModel = hiltView showTagDialog = true } } + val scannerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + importViewModel.importScanResult(result.data) + mode = "single" + } + } val cameraPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { try { cameraLauncher.launch(null) } catch (e: Exception) { com.app.traveldocs.debug.DebugLogger.e("Import", "Camera launch failed", e) } } + if (granted) { + try { cameraLauncher.launch(null) } catch (e: Exception) { + com.app.paperstow.debug.DebugLogger.e("Import", "Camera launch failed", e) + cameraHelp = "Camera is not available. You can re-enable it in system settings." + } + } else { + cameraHelp = "Camera permission is required for the fallback camera. Open settings to grant it, or try Scan document again." + } + } + fun startScan() { + val activity = context as? Activity + if (activity == null) { + cameraPermission.launch(android.Manifest.permission.CAMERA) + return + } + importViewModel.startDocumentScan( + activity = activity, + onReady = { sender -> + scannerLauncher.launch(IntentSenderRequest.Builder(sender).build()) + }, + onUnavailable = { + cameraHelp = "On-device scanner is downloading or unavailable. Using the system camera instead." + cameraPermission.launch(android.Manifest.permission.CAMERA) + } + ) + } + LaunchedEffect(cameraHelp) { + val msg = cameraHelp ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar(message = msg, actionLabel = "Settings") + if (result == SnackbarResult.ActionPerformed) { + val intent = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = android.net.Uri.parse("package:${context.packageName}") + context.startActivity(intent) + } + cameraHelp = null } // Duplicate dialog if (singleState.duplicateFound != null) { @@ -102,11 +142,11 @@ fun ImportScreen(onDone: () -> Unit, importViewModel: ImportViewModel = hiltView } // Tag dialog after camera capture if (showTagDialog && capturedBytes != null) { - AlertDialog(onDismissRequest = { showTagDialog = false; val fn = "camera_" + System.currentTimeMillis() + ".jpg"; importViewModel.importFromBytes(capturedBytes!!, com.app.traveldocs.domain.model.DocumentFormat.JPG, fn); mode = "single" }, + AlertDialog(onDismissRequest = { showTagDialog = false; val fn = "camera_" + System.currentTimeMillis() + ".jpg"; importViewModel.importFromBytes(capturedBytes!!, com.app.paperstow.domain.model.DocumentFormat.JPG, fn); mode = "single" }, title = { Text("Add Tags") }, text = { Column { Text("Add comma-separated tags:", fontSize = 13.sp, color = Color.Gray); Spacer(Modifier.height(8.dp)); OutlinedTextField(value = cameraTags, onValueChange = { cameraTags = it }, label = { Text("Tags") }, singleLine = true, modifier = Modifier.fillMaxWidth()) } }, - confirmButton = { TextButton(onClick = { showTagDialog = false; val fn = "camera_" + System.currentTimeMillis() + ".jpg"; importViewModel.importFromBytes(capturedBytes!!, com.app.traveldocs.domain.model.DocumentFormat.JPG, fn); mode = "single" }) { Text("Import") } }, - dismissButton = { TextButton(onClick = { showTagDialog = false; val fn = "camera_" + System.currentTimeMillis() + ".jpg"; importViewModel.importFromBytes(capturedBytes!!, com.app.traveldocs.domain.model.DocumentFormat.JPG, fn); mode = "single" }) { Text("Skip") } } + confirmButton = { TextButton(onClick = { showTagDialog = false; val fn = "camera_" + System.currentTimeMillis() + ".jpg"; importViewModel.importFromBytes(capturedBytes!!, com.app.paperstow.domain.model.DocumentFormat.JPG, fn); mode = "single" }) { Text("Import") } }, + dismissButton = { TextButton(onClick = { showTagDialog = false; val fn = "camera_" + System.currentTimeMillis() + ".jpg"; importViewModel.importFromBytes(capturedBytes!!, com.app.paperstow.domain.model.DocumentFormat.JPG, fn); mode = "single" }) { Text("Skip") } } ) } // Subfolder scan dialog @@ -124,31 +164,30 @@ fun ImportScreen(onDone: () -> Unit, importViewModel: ImportViewModel = hiltView ) } - Scaffold(topBar = { TopAppBar(title = { Text("Import") }, navigationIcon = { IconButton(onClick = { importViewModel.clearState(); batchViewModel.reset(); onDone() }) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + Scaffold( + topBar = { TopAppBar(title = { Text("Import") }, navigationIcon = { IconButton(onClick = { importViewModel.clearState(); batchViewModel.reset(); onDone() }) { Icon(Icons.Filled.ArrowBack, "Back") } }) }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { when (mode) { - "choose" -> ImportChooser(onSingleFile = { filePickerLauncher.launch("*/*") }, onLocalFolder = { folderPickerLauncher.launch(null) }, onCamera = { cameraPermission.launch(android.Manifest.permission.CAMERA) }, onDriveFolder = { drivePickerLauncher.launch(arrayOf("application/pdf", "image/*", "video/*")) }, onUrl = { mode = "url_import" }) + "choose" -> ImportChooser(onSingleFile = { filePickerLauncher.launch("*/*") }, onLocalFolder = { folderPickerLauncher.launch(null) }, onCamera = { startScan() }) "single" -> SingleImportResult(state = singleState, onDone = { importViewModel.clearState(); onDone() }, onRetry = { importViewModel.clearState(); mode = "choose" }) "batch_progress" -> BatchImportProgressScreen(viewModel = batchViewModel, onDone = { batchViewModel.reset(); onDone() }) - "url_import" -> UrlImportSection(importViewModel = importViewModel, onDone = { mode = "single" }, onBack = { mode = "choose" }) } } } } @Composable -private fun ImportChooser(onSingleFile: () -> Unit, onLocalFolder: () -> Unit, onDriveFolder: () -> Unit, onCamera: () -> Unit, onUrl: () -> Unit) { +private fun ImportChooser(onSingleFile: () -> Unit, onLocalFolder: () -> Unit, onCamera: () -> Unit) { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Text("Choose Import Method", fontSize = 20.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(24.dp)) - ImportOptionCard(icon = Icons.Filled.UploadFile, title = "Single File", subtitle = "Pick one PDF, image, or video", onClick = onSingleFile) + ImportOptionCard(icon = Icons.Filled.UploadFile, title = "Single File", subtitle = "Pick a PDF, image, text, markdown, or GPX file", onClick = onSingleFile) Spacer(Modifier.height(12.dp)) ImportOptionCard(icon = Icons.Filled.Folder, title = "Local Folder", subtitle = "Import all files from a phone folder", onClick = onLocalFolder) - val ctx = androidx.compose.ui.platform.LocalContext.current - if (com.app.traveldocs.data.local.FeatureFlags.isExperimentalEnabled(ctx) && com.app.traveldocs.data.local.FeatureFlags.isGoogleDriveEnabled(ctx)) { ImportOptionCard(icon = Icons.Filled.Cloud, title = "Google Drive Folder", subtitle = "Browse and import from Drive", onClick = onDriveFolder); Spacer(Modifier.height(12.dp)) } - Spacer(Modifier.height(12.dp)) - ImportOptionCard(icon = Icons.Filled.Cloud, title = "Take Photo", subtitle = "Capture document with camera", onClick = onCamera) Spacer(Modifier.height(12.dp)) - ImportOptionCard(icon = Icons.Filled.Cloud, title = "Import from URL", subtitle = "Download a file from a web address", onClick = onUrl) + ImportOptionCard(icon = Icons.Filled.PhotoCamera, title = "Scan document", subtitle = "On-device edge detect, crop, and enhance", onClick = onCamera) } } @@ -167,13 +206,13 @@ private fun ImportOptionCard(icon: ImageVector, title: String, subtitle: String, private fun SingleImportResult(state: ImportUiState, onDone: () -> Unit, onRetry: () -> Unit) { Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { when { - state.isImporting -> { CircularProgressIndicator(Modifier.size(64.dp)); Spacer(Modifier.height(16.dp)); Text("Importing..."); Text("Running OCR", fontSize = 13.sp, color = Color.Gray) } + state.isImporting -> { CircularProgressIndicator(Modifier.size(64.dp)); Spacer(Modifier.height(16.dp)); Text("Importing..."); Text("Trying to read the page…", fontSize = 13.sp, color = Color.Gray) } state.importedDocument != null -> { val doc = state.importedDocument!! Icon(Icons.Filled.CheckCircle, null, tint = Color(0xFF4CAF50), modifier = Modifier.size(64.dp)) Spacer(Modifier.height(16.dp)); Text("Imported!", fontSize = 20.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(16.dp)) - Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5))) { + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = androidx.compose.material3.MaterialTheme.colorScheme.surfaceVariant)) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("Type", fontSize = 12.sp, color = Color.Gray); Text(doc.type.name, fontSize = 12.sp) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("File", fontSize = 12.sp, color = Color.Gray); Text(doc.originalFileName ?: "?", fontSize = 12.sp) } diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/ImportViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/documents/ImportViewModel.kt similarity index 67% rename from app/src/main/java/com/app/traveldocs/presentation/documents/ImportViewModel.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/ImportViewModel.kt index ceb9276..c59b430 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/ImportViewModel.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/ImportViewModel.kt @@ -1,16 +1,18 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import android.content.Context import android.net.Uri import android.provider.OpenableColumns import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.ImportedDocument -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.usecase.DocumentImportUseCase +import com.app.paperstow.data.importer.DocumentFormatValidator +import com.app.paperstow.data.scanner.CameraDocumentScanner +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.ImportedDocument +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.usecase.DocumentImportUseCase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -34,6 +36,8 @@ data class ImportUiState( class ImportViewModel @Inject constructor( private val importUseCase: DocumentImportUseCase, private val documentRepository: DocumentRepository, + private val formatValidator: DocumentFormatValidator, + private val documentScanner: CameraDocumentScanner, @ApplicationContext private val context: Context ) : ViewModel() { @@ -43,7 +47,7 @@ class ImportViewModel @Inject constructor( fun importFile(uri: Uri) { viewModelScope.launch { _state.value = ImportUiState(isImporting = true) - com.app.traveldocs.debug.UsageTelemetry.funnelStart("single_import") + com.app.paperstow.debug.UsageTelemetry.funnelStart("single_import") DebugLogger.i("ImportVM", "Starting import from URI: $uri") try { val contentResolver = context.contentResolver @@ -53,20 +57,8 @@ class ImportViewModel @Inject constructor( return@launch } val mimeType = contentResolver.getType(uri) - val format = when { - mimeType?.contains("pdf") == true -> DocumentFormat.PDF - mimeType?.contains("video") == true -> DocumentFormat.VIDEO - mimeType?.contains("png") == true -> DocumentFormat.PNG - mimeType?.contains("jpeg") == true || mimeType?.contains("jpg") == true -> DocumentFormat.JPG - mimeType?.contains("webp") == true -> DocumentFormat.WEBP - mimeType?.contains("heic") == true || mimeType?.contains("heif") == true -> DocumentFormat.HEIC - mimeType?.contains("bmp") == true -> DocumentFormat.BMP - mimeType?.contains("gif") == true -> DocumentFormat.GIF - mimeType?.contains("dicom") == true -> DocumentFormat.DICOM - mimeType?.contains("audio") == true || mimeType?.contains("mp3") == true -> if (com.app.traveldocs.data.local.FeatureFlags.isAudioPlaybackEnabled(context)) DocumentFormat.AUDIO else DocumentFormat.UNKNOWN - else -> DocumentFormat.UNKNOWN // Unsupported format, import anyway - } val fileName = getFileName(uri) ?: "imported_document" + val format = formatValidator.detect(bytes, mimeType, fileName) val fileHash = computeSha256(bytes) DebugLogger.d("ImportVM", "File: $fileName, hash=${fileHash.take(16)}..., size=${bytes.size}") @@ -110,7 +102,7 @@ class ImportViewModel @Inject constructor( _state.value = ImportUiState(importedDocument = doc) } result.onFailure { e -> - com.app.traveldocs.debug.UsageTelemetry.userError("Import", "import_failed") + com.app.paperstow.debug.UsageTelemetry.userError("Import", "import_failed") DebugLogger.e("ImportVM", "Import failed", e) _state.value = ImportUiState(error = e.message ?: "Import failed") } @@ -132,12 +124,25 @@ class ImportViewModel @Inject constructor( return MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } } - fun importFromBytes(bytes: ByteArray, format: com.app.traveldocs.domain.model.DocumentFormat, fileName: String) { + fun startDocumentScan( + activity: android.app.Activity, + onReady: (android.content.IntentSender) -> Unit, + onUnavailable: () -> Unit + ) { + documentScanner.getStartIntent(activity, onReady) { onUnavailable() } + } + + fun importScanResult(data: android.content.Intent?) { + val pages = documentScanner.pagesFromResult(data) + importScannedPages(pages) + } + + fun importFromBytes(bytes: ByteArray, format: com.app.paperstow.domain.model.DocumentFormat, fileName: String) { viewModelScope.launch { _state.value = ImportUiState(isImporting = true) DebugLogger.i("ImportVM", "Importing from bytes: $fileName (${bytes.size} bytes)") try { - val result = importUseCase.importAndProcess(com.app.traveldocs.domain.model.ImportedDocument(bytes, format, fileName), "default-member") + val result = importUseCase.importAndProcess(com.app.paperstow.domain.model.ImportedDocument(bytes, format, fileName), "default-member") result.onSuccess { doc -> DebugLogger.i("ImportVM", "Camera import OK: type=${doc.type}") val prefs = context.getSharedPreferences("traveldocs_stats", android.content.Context.MODE_PRIVATE) @@ -148,4 +153,36 @@ class ImportViewModel @Inject constructor( } catch (e: Exception) { _state.value = ImportUiState(error = e.message ?: "Error") } } } + + fun importScannedPages(pages: List) { + if (pages.isEmpty()) { + _state.value = ImportUiState(error = "No pages were captured") + return + } + viewModelScope.launch { + _state.value = ImportUiState(isImporting = true) + var last: Document? = null + var imported = 0 + var lastError: String? = null + for (page in pages) { + val result = importUseCase.importAndProcess(page, "default-member") + result.onSuccess { doc -> + last = doc + imported++ + } + result.onFailure { e -> + lastError = e.message + DebugLogger.e("ImportVM", "Scan page failed: ${page.originalFileName}", e) + } + } + val prefs = context.getSharedPreferences("traveldocs_stats", Context.MODE_PRIVATE) + prefs.edit().putInt("doc_count", prefs.getInt("doc_count", 0) + imported).apply() + if (last != null) { + DebugLogger.i("ImportVM", "Scanned $imported page(s)") + _state.value = ImportUiState(importedDocument = last) + } else { + _state.value = ImportUiState(error = lastError ?: "Scan import failed") + } + } + } } diff --git a/app/src/main/java/com/app/paperstow/presentation/documents/NoteTagPicker.kt b/app/src/main/java/com/app/paperstow/presentation/documents/NoteTagPicker.kt new file mode 100644 index 0000000..be8b868 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/documents/NoteTagPicker.kt @@ -0,0 +1,128 @@ +package com.app.paperstow.presentation.documents + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.app.paperstow.data.local.InputSanitizer +import com.app.paperstow.data.local.TagColorStore +import com.app.paperstow.domain.model.Document + +fun seedEditorTags(existing: Document?, extra: List, defaultTag: String): List { + val fromDoc = existing?.tags?.map { it.name }?.filter { !it.startsWith("__") }.orEmpty() + if (fromDoc.isNotEmpty()) return fromDoc.distinct() + return (listOf(defaultTag) + extra).map { InputSanitizer.sanitizeTag(it) } + .filter { it.isNotBlank() && !it.startsWith("__") } + .distinct() +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun NoteTagPicker( + selected: List, + catalog: List, + onChange: (List) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val store = remember { TagColorStore(context) } + var draft by remember { mutableStateOf("") } + val colors = MaterialTheme.colorScheme + val suggestions = catalog.filter { name -> + !name.startsWith("__") && selected.none { it.equals(name, ignoreCase = true) } + }.take(12) + + fun add(raw: String) { + val name = InputSanitizer.sanitizeTag(raw) + if (name.isBlank() || name.startsWith("__")) return + if (selected.any { it.equals(name, ignoreCase = true) }) return + onChange(selected + name) + } + + Column(modifier.fillMaxWidth()) { + Text("Tags", fontSize = 12.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(6.dp)) + if (selected.isEmpty()) { + Text("No tags — this paper will sit in Untagged.", fontSize = 12.sp, color = colors.onSurfaceVariant) + } else { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + selected.forEach { name -> + InputChip( + selected = false, + onClick = {}, + label = { Text(name, fontSize = 12.sp) }, + leadingIcon = { + com.app.paperstow.presentation.tags.TagMark(name, store, size = 18.dp) + }, + trailingIcon = { + Icon( + Icons.Filled.Close, + "Remove $name", + modifier = Modifier.size(16.dp).clickable { onChange(selected - name) } + ) + } + ) + } + } + } + if (suggestions.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("Add from your tags", fontSize = 11.sp, color = colors.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + suggestions.forEach { name -> + FilterChip( + selected = false, + onClick = { add(name) }, + label = { Text(name, fontSize = 12.sp) }, + leadingIcon = { Icon(Icons.Filled.Add, null, Modifier.size(14.dp)) } + ) + } + } + } + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + label = { Text("New tag") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { + add(draft) + draft = "" + }, + enabled = InputSanitizer.sanitizeTag(draft).isNotBlank() + ) { Text("Add") } + } + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/documents/PdfPreview.kt b/app/src/main/java/com/app/paperstow/presentation/documents/PdfPreview.kt new file mode 100644 index 0000000..df73390 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/documents/PdfPreview.kt @@ -0,0 +1,165 @@ +package com.app.paperstow.presentation.documents + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.app.paperstow.debug.DebugLogger +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.Executors + +private sealed class PdfUi { + data object Loading : PdfUi() + data class Ready(val pages: List) : PdfUi() + data class Failed(val reason: String) : PdfUi() +} + +/** + * Renders a PDF on one background thread. PdfRenderer is not thread-safe; + * creating it on IO and paging it on another thread is what broke preview. + */ +@Composable +fun PdfPreview(bytes: ByteArray, modifier: Modifier = Modifier) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var ui by remember(bytes) { mutableStateOf(PdfUi.Loading) } + var scale by remember(bytes) { mutableFloatStateOf(1f) } + var ox by remember(bytes) { mutableFloatStateOf(0f) } + var oy by remember(bytes) { mutableFloatStateOf(0f) } + + DisposableEffect(bytes) { + val executor = Executors.newSingleThreadExecutor() + val dispatcher = executor.asCoroutineDispatcher() + val job = scope.launch { + ui = PdfUi.Loading + ui = withContext(dispatcher) { + try { + val head = if (bytes.size >= 5) String(bytes, 0, 5, Charsets.ISO_8859_1) else "" + if (head != "%PDF-") { + return@withContext PdfUi.Failed("Not a PDF") + } + val file = File.createTempFile("preview", ".pdf", context.cacheDir) + file.writeBytes(bytes) + val fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(fd) + val pages = ArrayList(renderer.pageCount) + try { + for (i in 0 until renderer.pageCount) { + val page = renderer.openPage(i) + val srcW = page.width.coerceAtLeast(1) + val srcH = page.height.coerceAtLeast(1) + val maxW = 1200 + val factor = maxW.toFloat() / srcW + val w = (srcW * factor).toInt().coerceAtLeast(1) + val h = (srcH * factor).toInt().coerceAtLeast(1) + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + bmp.eraseColor(android.graphics.Color.WHITE) + page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + page.close() + pages += bmp + } + PdfUi.Ready(pages) + } finally { + renderer.close() + fd.close() + file.delete() + } + } catch (e: Exception) { + DebugLogger.e("PDF", "Preview failed: ${e.message}", e) + PdfUi.Failed(e.message ?: "Could not open PDF") + } + } + } + onDispose { + job.cancel() + dispatcher.close() + executor.shutdownNow() + } + } + + when (val state = ui) { + PdfUi.Loading -> Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + CircularProgressIndicator(Modifier.size(40.dp)) + Spacer(Modifier.height(8.dp)) + Text("Opening PDF…", fontSize = 13.sp, color = Color.Gray) + } + is PdfUi.Failed -> Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + Icon(Icons.Filled.PictureAsPdf, null, tint = Color(0xFFF44336), modifier = Modifier.size(64.dp)) + Spacer(Modifier.height(8.dp)) + Text("PDF preview failed", fontSize = 13.sp, color = Color.Gray) + Text(state.reason, fontSize = 11.sp, color = Color(0xFF9E9E9E)) + Text("Use Open External to try another viewer.", fontSize = 11.sp, color = Color(0xFF9E9E9E)) + } + is PdfUi.Ready -> Box( + modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + ox += pan.x + oy += pan.y + } else { + ox = 0f + oy = 0f + } + } + } + ) { + LazyColumn( + Modifier + .fillMaxSize() + .background(Color.White) + .graphicsLayer(scaleX = scale, scaleY = scale, translationX = ox, translationY = oy) + ) { + itemsIndexed(state.pages) { idx, bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Page ${idx + 1}", + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + contentScale = ContentScale.FillWidth + ) + } + } + } + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/ShareHelper.kt b/app/src/main/java/com/app/paperstow/presentation/documents/ShareHelper.kt similarity index 88% rename from app/src/main/java/com/app/traveldocs/presentation/documents/ShareHelper.kt rename to app/src/main/java/com/app/paperstow/presentation/documents/ShareHelper.kt index 69c59b3..89fc738 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/ShareHelper.kt +++ b/app/src/main/java/com/app/paperstow/presentation/documents/ShareHelper.kt @@ -1,14 +1,14 @@ -package com.app.traveldocs.presentation.documents +package com.app.paperstow.presentation.documents import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.debug.UsageTelemetry -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.repository.DocumentFileStorage +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.debug.UsageTelemetry +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.repository.DocumentFileStorage import java.io.File /** @@ -82,13 +82,16 @@ object ShareHelper { DocumentFormat.PDF -> "application/pdf" DocumentFormat.JPG -> "image/jpeg" DocumentFormat.PNG -> "image/png" - DocumentFormat.VIDEO -> "video/*" + DocumentFormat.TEXT -> "text/plain" + DocumentFormat.MARKDOWN -> "text/markdown" + DocumentFormat.GPX -> "application/gpx+xml" DocumentFormat.WEBP -> "image/webp" DocumentFormat.HEIC -> "image/heic" DocumentFormat.BMP -> "image/bmp" DocumentFormat.GIF -> "image/gif" - DocumentFormat.DICOM -> "application/dicom" + DocumentFormat.VIDEO -> "video/*" DocumentFormat.AUDIO -> "audio/mpeg" + DocumentFormat.DICOM -> "application/dicom" DocumentFormat.UNKNOWN -> "application/octet-stream" } } diff --git a/app/src/main/java/com/app/paperstow/presentation/documents/TextNoteEditorScreen.kt b/app/src/main/java/com/app/paperstow/presentation/documents/TextNoteEditorScreen.kt new file mode 100644 index 0000000..2c06f85 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/documents/TextNoteEditorScreen.kt @@ -0,0 +1,118 @@ +package com.app.paperstow.presentation.documents + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.app.paperstow.domain.model.Document + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TextNoteEditorScreen( + existing: Document? = null, + initialBody: String = "", + extraTags: List = emptyList(), + onBack: () -> Unit, + onSaved: (Document) -> Unit, + viewModel: TextNoteViewModel = hiltViewModel() +) { + val saving by viewModel.saving.collectAsState() + val error by viewModel.error.collectAsState() + val catalog by viewModel.catalog.collectAsState() + var title by remember { + mutableStateOf(existing?.originalFileName?.removeSuffix(".txt")?.removeSuffix(".TXT").orEmpty()) + } + var body by remember { mutableStateOf(initialBody) } + var tags by remember { mutableStateOf(seedEditorTags(existing, extraTags, "Notes")) } + val colors = MaterialTheme.colorScheme + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (existing == null) "New note" else "Edit note") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } + }, + actions = { + if (saving) { + CircularProgressIndicator(Modifier.size(22.dp).padding(end = 4.dp), strokeWidth = 2.dp) + } else { + IconButton(onClick = { + if (existing == null) viewModel.createNote(title, body, tags, onSaved) + else viewModel.updateNote(existing, title, body, tags, onSaved) + }) { Icon(Icons.Filled.Check, "Save") } + } + } + ) + } + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + NoteTagPicker(selected = tags, catalog = catalog, onChange = { tags = it }) + Spacer(Modifier.height(12.dp)) + if (error != null) { + Text(error!!, color = colors.error, fontSize = 13.sp) + Spacer(Modifier.height(8.dp)) + } + Text("Notes", fontSize = 12.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(6.dp)) + BasicTextField( + value = body, + onValueChange = { body = it }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(top = 4.dp), + textStyle = TextStyle( + fontFamily = FontFamily.SansSerif, + fontSize = 16.sp, + color = colors.onSurface, + lineHeight = 22.sp + ), + cursorBrush = SolidColor(colors.primary), + decorationBox = { inner -> + if (body.isEmpty()) { + Text("Write your note…", color = colors.onSurfaceVariant, fontSize = 16.sp) + } + inner() + } + ) + } + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/documents/TextNoteViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/documents/TextNoteViewModel.kt new file mode 100644 index 0000000..e0b3050 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/documents/TextNoteViewModel.kt @@ -0,0 +1,147 @@ +package com.app.paperstow.presentation.documents + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.app.paperstow.data.local.InputSanitizer +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.repository.TagRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class TextNoteViewModel @Inject constructor( + private val fileStorage: DocumentFileStorage, + private val documentRepository: DocumentRepository, + private val tagRepository: TagRepository +) : ViewModel() { + + private val _saving = MutableStateFlow(false) + val saving: StateFlow = _saving.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _catalog = MutableStateFlow>(emptyList()) + val catalog: StateFlow> = _catalog.asStateFlow() + + init { + viewModelScope.launch { + _catalog.value = tagRepository.getAllTags("default-member") + .map { it.name } + .filter { !it.startsWith("__") } + .distinct() + .sorted() + } + } + + fun createNote(title: String, body: String, tags: List, onSaved: (Document) -> Unit) { + create(title, body, DocumentFormat.TEXT, tags, onSaved) + } + + fun createChecklist(title: String, body: String, tags: List, onSaved: (Document) -> Unit) { + create(title, body, DocumentFormat.MARKDOWN, tags, onSaved) + } + + fun updateNote(document: Document, title: String, body: String, tags: List, onSaved: (Document) -> Unit) { + updateDocument(document, title, body, tags, onSaved) + } + + fun updateDocument(document: Document, title: String, body: String, tags: List, onSaved: (Document) -> Unit) { + viewModelScope.launch { + _saving.value = true + _error.value = null + try { + val name = displayName(title, document.format, document.originalFileName) + fileStorage.replace(document.id, body.toByteArray(Charsets.UTF_8)).getOrThrow() + val updatedAt = Instant.now() + documentRepository.updateName(document.id, name, updatedAt).getOrThrow() + documentRepository.updateOcrText(document.id, body) + val applied = applyTags(document.id, document.tags, tags) + val updated = document.copy(originalFileName = name, updatedAt = updatedAt, tags = applied) + DebugLogger.i("Note", "Updated ${document.format} ${document.id}") + onSaved(updated) + } catch (e: Exception) { + DebugLogger.e("Note", "Update failed", e) + _error.value = e.message ?: "Could not save" + } finally { + _saving.value = false + } + } + } + + private fun create( + title: String, + body: String, + format: DocumentFormat, + tags: List, + onSaved: (Document) -> Unit + ) { + viewModelScope.launch { + _saving.value = true + _error.value = null + try { + val name = displayName(title, format) + val bytes = body.toByteArray(Charsets.UTF_8) + val fileId = fileStorage.store("default-member", bytes, format).getOrThrow() + val now = Instant.now() + val chosen = sanitizeTagNames(tags) + val document = Document( + id = fileId, + memberId = "default-member", + type = DocumentType.UNKNOWN, + format = format, + originalFileName = name, + metadata = emptyMap(), + tags = chosen.map { Tag(name = it, isAutoGenerated = false) }, + createdAt = now, + updatedAt = now, + extractionConfidence = 1f, + requiresManualReview = false + ) + documentRepository.insert(document).getOrThrow() + documentRepository.updateOcrText(document.id, body) + DebugLogger.i("Note", "Created ${format.name} ${document.id} ($name) tags=$chosen") + onSaved(document) + } catch (e: Exception) { + DebugLogger.e("Note", "Create failed", e) + _error.value = e.message ?: "Could not save" + } finally { + _saving.value = false + } + } + } + + private suspend fun applyTags(documentId: String, previous: List, desiredRaw: List): List { + val desired = sanitizeTagNames(desiredRaw) + val keepSystem = previous.filter { it.name.startsWith("__") } + val currentUser = previous.filter { !it.name.startsWith("__") }.map { it.name } + currentUser.filter { it !in desired }.forEach { tagRepository.removeTag(documentId, it) } + desired.filter { it !in currentUser }.forEach { tagRepository.addTag(documentId, it) } + return keepSystem + desired.map { Tag(name = it, isAutoGenerated = false) } + } + + private fun sanitizeTagNames(tags: List): List = + tags.map { InputSanitizer.sanitizeTag(it) } + .filter { it.isNotBlank() && !it.startsWith("__") } + .distinct() + + private fun displayName(title: String, format: DocumentFormat, fallback: String? = null): String { + val ext = if (format == DocumentFormat.MARKDOWN) ".md" else ".txt" + val raw = title.trim().ifBlank { fallback?.substringBeforeLast('.')?.ifBlank { null } } + ?: if (format == DocumentFormat.MARKDOWN) "Plans ${LocalDate.now()}" else "Note ${LocalDate.now()}" + val sanitized = InputSanitizer.sanitizeFilename(raw) + return if (sanitized.endsWith(ext, ignoreCase = true)) sanitized else "$sanitized$ext" + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/feedback/FeedbackScreen.kt b/app/src/main/java/com/app/paperstow/presentation/feedback/FeedbackScreen.kt new file mode 100644 index 0000000..7d19989 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/feedback/FeedbackScreen.kt @@ -0,0 +1,218 @@ +package com.app.paperstow.presentation.feedback + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.app.paperstow.data.feedback.FeedbackDraft +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeedbackScreen( + onBack: () -> Unit, + onNoteSaved: (Document) -> Unit = {}, + viewModel: FeedbackViewModel = hiltViewModel() +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + var showShareConfirm by remember { mutableStateOf(false) } + var shareDraft by remember { mutableStateOf(null) } + + if (showShareConfirm && shareDraft != null) { + AlertDialog( + onDismissRequest = { showShareConfirm = false; shareDraft = null }, + title = { Text("Share this feedback?") }, + text = { + Text( + "Paperstow will not upload it. Another app you pick — email or share — will send it. You can still keep the draft on this phone.", + fontSize = 14.sp + ) + }, + confirmButton = { + TextButton(onClick = { + val draft = shareDraft!! + showShareConfirm = false + shareDraft = null + sendFeedback(context, draft) + viewModel.dismissMessage() + }) { Text("Yes, share") } + }, + dismissButton = { + TextButton(onClick = { showShareConfirm = false; shareDraft = null }) { Text("Keep on phone") } + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Feedback") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text("What worked, and what did not.", fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Typed words are saved here as draft feedback. Nothing leaves this phone until you choose to share.", + fontSize = 13.sp, + color = Color.Gray + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = state.liked, + onValueChange = viewModel::updateLiked, + label = { Text("What I liked") }, + placeholder = { Text("A feature, a moment, something that helped") }, + modifier = Modifier.fillMaxWidth().height(140.dp) + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.disliked, + onValueChange = viewModel::updateDisliked, + label = { Text("What I did not like") }, + placeholder = { Text("Something confusing, missing, or broken") }, + modifier = Modifier.fillMaxWidth().height(140.dp) + ) + Spacer(Modifier.height(8.dp)) + Text("Draft saved on this phone.", fontSize = 12.sp, color = Color.Gray) + if (state.message != null) { + Spacer(Modifier.height(8.dp)) + Text(state.message!!, fontSize = 13.sp, color = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + val draft = viewModel.currentDraft() + if (draft.isBlank()) return@Button + shareDraft = draft + showShareConfirm = true + }, + enabled = !state.liked.isBlank() || !state.disliked.isBlank(), + modifier = Modifier.fillMaxWidth() + ) { Text("Submit") } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = viewModel::newDraft, modifier = Modifier.fillMaxWidth()) { + Text("New draft") + } + + val drafts = state.drafts.filter { !it.isBlank() } + if (drafts.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + Text("Draft feedback on this phone", fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + Spacer(Modifier.height(8.dp)) + drafts.forEach { draft -> + val current = draft.id == state.current?.id + Card( + Modifier.fillMaxWidth().padding(bottom = 8.dp).clickable { viewModel.openDraft(draft.id) }, + colors = CardDefaults.cardColors( + containerColor = if (current) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(Modifier.padding(12.dp)) { + val whenWritten = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(draft.updatedAt)) + Text(if (current) "Current draft" else "Draft", fontSize = 12.sp, color = Color.Gray) + Text(whenWritten, fontWeight = FontWeight.Medium, fontSize = 14.sp) + Text(draft.preview(), fontSize = 13.sp, maxLines = 2) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { + viewModel.openDraft(draft.id) + shareDraft = draft + showShareConfirm = true + }) { Text("Send") } + TextButton(onClick = { + viewModel.openDraft(draft.id) + viewModel.saveAsNote(onNoteSaved) + }) { Text("Save as note") } + TextButton(onClick = { viewModel.deleteDraft(draft.id) }) { Text("Delete") } + } + } + } + } + } + } + } +} + +private fun sendFeedback(context: Context, draft: FeedbackDraft) { + val body = buildString { + appendLine("Paperstow feedback") + appendLine() + append(draft.asNoteBody()) + appendLine() + appendLine("--- Device (optional context) ---") + appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("Android: ${Build.VERSION.RELEASE}") + try { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + appendLine("App: ${info.versionName}") + } catch (_: Exception) { + } + } + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Paperstow — User Feedback") + putExtra(Intent.EXTRA_TEXT, body) + } + try { + context.startActivity( + Intent.createChooser(intent, "Share feedback").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + DebugLogger.i("Feedback", "Share sheet opened") + } catch (e: Exception) { + DebugLogger.e("Feedback", "Share failed", e) + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/feedback/FeedbackViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/feedback/FeedbackViewModel.kt new file mode 100644 index 0000000..7049a23 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/feedback/FeedbackViewModel.kt @@ -0,0 +1,166 @@ +package com.app.paperstow.presentation.feedback + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.app.paperstow.data.feedback.FeedbackDraft +import com.app.paperstow.data.feedback.FeedbackDraftStore +import com.app.paperstow.data.local.InputSanitizer +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.repository.DocumentFileStorage +import com.app.paperstow.domain.repository.DocumentRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import javax.inject.Inject + +const val APP_FEEDBACK_TAG = "App Feedbacks" + +data class FeedbackUiState( + val current: FeedbackDraft? = null, + val drafts: List = emptyList(), + val liked: String = "", + val disliked: String = "", + val message: String? = null, + val busy: Boolean = false +) + +@HiltViewModel +class FeedbackViewModel @Inject constructor( + private val store: FeedbackDraftStore, + private val fileStorage: DocumentFileStorage, + private val documentRepository: DocumentRepository +) : ViewModel() { + + private val _state = MutableStateFlow(FeedbackUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + val current = store.currentOrNew() + _state.value = FeedbackUiState( + current = current, + drafts = store.draftsNewestFirst(), + liked = current.liked, + disliked = current.disliked + ) + } + + fun updateLiked(value: String) { + _state.value = _state.value.copy(liked = value) + persist() + } + + fun updateDisliked(value: String) { + _state.value = _state.value.copy(disliked = value) + persist() + } + + fun openDraft(id: String) { + persist() + store.setCurrent(id) + val draft = store.get(id) ?: return + _state.value = _state.value.copy( + current = draft, + drafts = store.draftsNewestFirst(), + liked = draft.liked, + disliked = draft.disliked, + message = null + ) + } + + fun newDraft() { + persist() + val draft = store.startNew() + _state.value = _state.value.copy( + current = draft, + drafts = store.draftsNewestFirst(), + liked = draft.liked, + disliked = draft.disliked, + message = "Started a new draft. Earlier drafts are still on this phone." + ) + } + + fun deleteDraft(id: String) { + store.delete(id) + val current = store.currentOrNew() + _state.value = _state.value.copy( + current = current, + drafts = store.draftsNewestFirst(), + liked = current.liked, + disliked = current.disliked, + message = "Draft deleted. It stayed on this phone only." + ) + } + + fun saveAsNote(onSaved: (Document) -> Unit) { + persist() + val draft = store.currentOrNew() + if (draft.isBlank()) { + _state.value = _state.value.copy(message = "Write something first.") + return + } + viewModelScope.launch { + _state.value = _state.value.copy(busy = true, message = null) + try { + val title = "Feedback ${LocalDate.now()}" + val name = InputSanitizer.sanitizeFilename(title).let { + if (it.endsWith(".txt", true)) it else "$it.txt" + } + val bytes = draft.asNoteBody().toByteArray(Charsets.UTF_8) + val fileId = fileStorage.store("default-member", bytes, DocumentFormat.TEXT).getOrThrow() + val now = Instant.now() + val document = Document( + id = fileId, + memberId = "default-member", + type = DocumentType.UNKNOWN, + format = DocumentFormat.TEXT, + originalFileName = name, + metadata = emptyMap(), + tags = listOf( + Tag(name = "Notes", isAutoGenerated = false), + Tag(name = APP_FEEDBACK_TAG, isAutoGenerated = false) + ), + createdAt = now, + updatedAt = now, + extractionConfidence = 1f, + requiresManualReview = false + ) + documentRepository.insert(document).getOrThrow() + store.delete(draft.id) + val current = store.currentOrNew() + _state.value = FeedbackUiState( + current = current, + drafts = store.draftsNewestFirst(), + liked = current.liked, + disliked = current.disliked, + message = "Saved as a note in $APP_FEEDBACK_TAG." + ) + onSaved(document) + } catch (e: Exception) { + DebugLogger.e("Feedback", "Save as note failed", e) + _state.value = _state.value.copy(busy = false, message = e.message ?: "Could not save as a note") + } + } + } + + fun dismissMessage() { + _state.value = _state.value.copy(message = null) + } + + fun currentDraft(): FeedbackDraft = store.saveCurrent(_state.value.liked, _state.value.disliked) + + private fun persist() { + val draft = store.saveCurrent(_state.value.liked, _state.value.disliked) + _state.value = _state.value.copy( + current = draft, + drafts = store.draftsNewestFirst() + ) + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/onboarding/.gitkeep b/app/src/main/java/com/app/paperstow/presentation/onboarding/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/presentation/onboarding/.gitkeep rename to app/src/main/java/com/app/paperstow/presentation/onboarding/.gitkeep diff --git a/app/src/main/java/com/app/traveldocs/presentation/onboarding/DisclaimerScreen.kt b/app/src/main/java/com/app/paperstow/presentation/onboarding/DisclaimerScreen.kt similarity index 86% rename from app/src/main/java/com/app/traveldocs/presentation/onboarding/DisclaimerScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/onboarding/DisclaimerScreen.kt index fd2f60e..a52b4ce 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/onboarding/DisclaimerScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/onboarding/DisclaimerScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.onboarding +package com.app.paperstow.presentation.onboarding import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -51,7 +51,7 @@ fun DisclaimerScreen(onAccepted: (telemetryConsent: Boolean) -> Unit) { Column(Modifier.padding(14.dp)) { Text("Your Data, Your Responsibility", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Spacer(Modifier.height(4.dp)) - Text("Certain features such as sharing, cloud backup, and external viewing can move your documents off your phone to third-party services or other apps. Please exercise caution when using these features and understand that once data leaves this device, it is subject to the privacy policies of those external services.", fontSize = 12.sp, color = Color(0xFF1A237E)) + Text("Sharing a document or exporting a password-protected backup can move files off this phone to another app or folder you choose. Once data leaves this device, it is subject to that destination's policies.", fontSize = 12.sp, color = Color(0xFF1A237E)) } } @@ -62,7 +62,7 @@ fun DisclaimerScreen(onAccepted: (telemetryConsent: Boolean) -> Unit) { Column(Modifier.padding(14.dp)) { Text("Privacy", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) Spacer(Modifier.height(4.dp)) - Text("This application has no intent to collect, access, or transmit your personal documents or their content. All documents are stored locally on your device with encryption. No server, cloud service, or third party receives your document content unless you explicitly use sharing or backup features.", fontSize = 12.sp, color = Color(0xFF1B5E20)) + Text("This application has no intent to collect, access, or transmit your personal documents or their content. Your papers stay encrypted on this phone. Only you can open them. Nothing is sent off the device unless you share or archive it.", fontSize = 12.sp, color = Color(0xFF1B5E20)) } } diff --git a/app/src/main/java/com/app/traveldocs/presentation/onboarding/EulaScreen.kt b/app/src/main/java/com/app/paperstow/presentation/onboarding/EulaScreen.kt similarity index 86% rename from app/src/main/java/com/app/traveldocs/presentation/onboarding/EulaScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/onboarding/EulaScreen.kt index 7cb0d9f..1cf5b4a 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/onboarding/EulaScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/onboarding/EulaScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.onboarding +package com.app.paperstow.presentation.onboarding import android.content.Context import androidx.compose.foundation.layout.Column @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.sp * End User License Agreement screen. * * Shown on first launch BEFORE anything else. User must accept to proceed. - * If declined, the app closes. Acceptance is persisted with timestamp and GPS location. + * If declined, the app closes. Acceptance is persisted with a timestamp. * Accessible later via Settings menu to review the signed agreement. */ @Composable @@ -66,6 +66,16 @@ fun EulaScreen(onAccepted: () -> Unit, onDeclined: () -> Unit) { } } + Spacer(Modifier.height(8.dp)) + val privacyUrl = androidx.compose.ui.res.stringResource(com.app.paperstow.R.string.privacy_policy_url) + val eulaContext = LocalContext.current + OutlinedButton( + onClick = { + eulaContext.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(privacyUrl))) + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Privacy Policy") } + Spacer(Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { @@ -105,8 +115,6 @@ fun EulaViewScreen(onBack: () -> Unit) { val context = LocalContext.current val prefs = context.getSharedPreferences("eula_prefs", Context.MODE_PRIVATE) val acceptedTimestamp = prefs.getLong("accepted_timestamp", 0L) - val acceptedLocation = prefs.getString("accepted_location", "Not available") ?: "Not available" - val dateStr = if (acceptedTimestamp > 0) { java.text.SimpleDateFormat("EEEE, dd MMMM yyyy 'at' HH:mm:ss z", java.util.Locale.getDefault()) .format(java.util.Date(acceptedTimestamp)) @@ -126,7 +134,6 @@ fun EulaViewScreen(onBack: () -> Unit) { Text("Agreement Status: ACCEPTED", fontWeight = FontWeight.SemiBold, color = Color(0xFF2E7D32), fontSize = 14.sp) Spacer(Modifier.height(4.dp)) Text("Date & Time: $dateStr", fontSize = 12.sp) - Text("Location: $acceptedLocation", fontSize = 12.sp) } } @@ -150,7 +157,7 @@ fun EulaViewScreen(onBack: () -> Unit) { private fun EulaText() { val sections = listOf( "1. ACCEPTANCE OF TERMS" to """ -By downloading, installing, accessing, or using the Document Manager application ("the App"), you ("User") acknowledge that you have read, understood, and agree to be legally bound by this End User License Agreement ("Agreement"). If you do not agree to these terms, you must immediately uninstall the App and discontinue all use. +By downloading, installing, accessing, or using the Paperstow application ("the App"), you ("User") acknowledge that you have read, understood, and agree to be legally bound by this End User License Agreement ("Agreement"). If you do not agree to these terms, you must immediately uninstall the App and discontinue all use. This Agreement constitutes a legally binding contract between you and the developer(s) of the App ("Developer"). Your continued use of the App following any modifications to this Agreement constitutes acceptance of those modifications. """.trimIndent(), @@ -158,7 +165,7 @@ This Agreement constitutes a legally binding contract between you and the develo "2. LICENSE GRANT" to """ Subject to your compliance with this Agreement, Developer grants you a limited, non-exclusive, non-transferable, revocable license to use the App on a single device owned or controlled by you, solely for your personal, non-commercial purposes. -This license does not grant you any rights to the source code, except as separately provided under the Apache 2.0 open source license governing the source repository. +This license does not grant you any rights to the source code, except as separately provided under the Apache 2.0 open source license governing the source repository. Third-party libraries bundled with the App (including AndroidX, Bouncy Castle, Zip4j, and Google ML Kit / Play services) remain under their own licenses and terms, which are listed in the repository NOTICE and docs/THIRD_PARTY_LICENSES.md. """.trimIndent(), "3. DISCLAIMER OF WARRANTIES" to """ @@ -208,11 +215,11 @@ You further acknowledge that this App was built and tested with the assistance o "6. DATA AND PRIVACY" to """ The App stores all user data locally on your device. Developer does not operate servers, collect user data, or have access to your documents, metadata, or personal information. -If you enable optional features (cloud backup, sharing), you acknowledge that data will leave your device at your explicit direction. Developer bears no responsibility for data once it leaves the App's local encrypted storage. +If you share a document or export a password-protected backup, you acknowledge that data will leave your device at your explicit direction. Developer bears no responsibility for data once it leaves the App's local encrypted storage. -If you enable anonymous telemetry (opt-in only), only non-identifying usage statistics are collected. No document content, personal information, or location data is transmitted. +If you enable anonymous telemetry (opt-in only), only non-identifying usage counts are stored locally. No document content or personal information is collected. -GPS logging, if enabled by you, is stored exclusively on your device. Developer has no access to your location history. +Optional My Trail: if you start it, unique places from the last 24 hours stay on this device, including battery level at each save. Locations are not uploaded. You may share Maps links yourself. """.trimIndent(), "7. ENCRYPTION AND SECURITY" to """ @@ -224,19 +231,7 @@ The App uses encryption (AES-256-GCM) to protect stored documents. You acknowled • You are responsible for maintaining physical security of your device """.trimIndent(), - "7B. PER-DOCUMENT PIN PROTECTION" to """ -The App offers an optional per-document PIN lock feature ("Secure Docs"). You acknowledge and accept that: - -• Per-document PINs are derived into encryption keys using PBKDF2-SHA256. The PIN itself is never stored. -• IF YOU FORGET A PER-DOCUMENT PIN, THE PROTECTED DOCUMENT IS PERMANENTLY AND IRREVERSIBLY INACCESSIBLE. There is no recovery mechanism, no master key, and no backdoor. -• Developer has no ability whatsoever to recover, decrypt, or access PIN-protected documents. This is by design. -• This feature is a personal privacy tool. It does not exempt you from legal obligations. -• If compelled by lawful court order, government authority, or law enforcement to disclose document contents, you bear sole responsibility for compliance. Developer cannot assist in decryption. -• You must not use this feature to conceal evidence, obstruct justice, or violate any applicable law. -• Developer bears zero liability for data loss resulting from forgotten PINs. - """.trimIndent(), - - "8. INDEMNIFICATION" to """ + "7. ENCRYPTION AND SECURITY" to """ You agree to indemnify, defend, and hold harmless Developer, its affiliates, officers, agents, and licensors from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including reasonable attorneys' fees) arising from: • Your use of and access to the App @@ -313,7 +308,7 @@ No failure or delay by Developer in exercising any right under this Agreement sh } Text( - "Last updated: August 2026\n© 2026 Document Manager Developer. All rights reserved.", + "Last updated: September 2026\n© 2026 Paperstow Contributors. All rights reserved.", fontSize = 11.sp, color = Color.Gray, textAlign = TextAlign.Center, diff --git a/app/src/main/java/com/app/paperstow/presentation/onboarding/SplashScreen.kt b/app/src/main/java/com/app/paperstow/presentation/onboarding/SplashScreen.kt new file mode 100644 index 0000000..71a610a --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/onboarding/SplashScreen.kt @@ -0,0 +1,123 @@ +package com.app.paperstow.presentation.onboarding + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.app.paperstow.R + +@Composable +fun SplashScreen(onContinue: (skipInFuture: Boolean) -> Unit) { + var skipInFuture by remember { mutableStateOf(false) } + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val bodyAlign = if (landscape) TextAlign.Start else TextAlign.Center + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF0C2041)) + .verticalScroll(rememberScrollState()) + .padding(if (landscape) 20.dp else 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (landscape) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = "Paperstow", + modifier = Modifier.size(88.dp) + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text("Paperstow", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White) + Text( + "A pocket for the family’s travel papers.", + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.9f) + ) + } + } + Spacer(Modifier.height(12.dp)) + } else { + Spacer(Modifier.height(36.dp)) + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = "Paperstow", + modifier = Modifier.size(112.dp) + ) + Spacer(Modifier.height(16.dp)) + Text("Paperstow", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White) + Spacer(Modifier.height(8.dp)) + Text( + "A pocket for the family’s travel papers.", + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.9f), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + } + Text( + "Passports, visas, tickets, and insurance stay encrypted on this phone. No account. No Paperstow servers.", + fontSize = 15.sp, + color = Color.White.copy(alpha = 0.92f), + textAlign = bodyAlign, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(if (landscape) 10.dp else 16.dp)) + Text( + "Unlock with this phone’s fingerprint, face, or screen lock. Find a page by tag or text — even in airplane mode at the gate.", + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.82f), + textAlign = bodyAlign, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(if (landscape) 10.dp else 16.dp)) + Text( + "Nothing leaves unless you share a file or export an archive. Suggested names and tags are only a helper — always check the original page.", + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.72f), + textAlign = bodyAlign, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(if (landscape) 12.dp else 20.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox(checked = skipInFuture, onCheckedChange = { skipInFuture = it }) + Text("Don’t show this again", color = Color.White.copy(alpha = 0.95f), fontSize = 14.sp) + } + Spacer(Modifier.height(if (landscape) 12.dp else 20.dp)) + Button( + onClick = { onContinue(skipInFuture) }, + modifier = Modifier.fillMaxWidth().height(48.dp) + ) { + Text("Continue", fontSize = 16.sp) + } + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/review/ReviewScreen.kt b/app/src/main/java/com/app/paperstow/presentation/review/ReviewScreen.kt similarity index 99% rename from app/src/main/java/com/app/traveldocs/presentation/review/ReviewScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/review/ReviewScreen.kt index 90ce83d..02a093c 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/review/ReviewScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/review/ReviewScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.review +package com.app.paperstow.presentation.review import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -51,7 +51,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import com.app.traveldocs.domain.model.Document +import com.app.paperstow.domain.model.Document /** * Two-tab review screen: diff --git a/app/src/main/java/com/app/traveldocs/presentation/review/ReviewViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/review/ReviewViewModel.kt similarity index 91% rename from app/src/main/java/com/app/traveldocs/presentation/review/ReviewViewModel.kt rename to app/src/main/java/com/app/paperstow/presentation/review/ReviewViewModel.kt index 5d39cf0..cae1a83 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/review/ReviewViewModel.kt +++ b/app/src/main/java/com/app/paperstow/presentation/review/ReviewViewModel.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.presentation.review +package com.app.paperstow.presentation.review import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.repository.TagRepository +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.repository.DocumentRepository +import com.app.paperstow.domain.repository.TagRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/app/paperstow/presentation/safety/AutoStartTrail.kt b/app/src/main/java/com/app/paperstow/presentation/safety/AutoStartTrail.kt new file mode 100644 index 0000000..f5ab459 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/safety/AutoStartTrail.kt @@ -0,0 +1,71 @@ +package com.app.paperstow.presentation.safety + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import com.app.paperstow.data.safety.SafetyTrailPreferences +import com.app.paperstow.data.safety.SafetyTrailService + +@Composable +fun AutoStartTrailIfWanted() { + val context = LocalContext.current + val running by SafetyTrailPreferences.running.collectAsState() + var requested by remember { mutableStateOf(false) } + + fun start() { + SafetyTrailService.start(context.applicationContext) + } + + fun hasLocation(): Boolean { + val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + } + + fun needsNotification(): Boolean = + Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + + val notificationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { start() } + + val locationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grants -> + val granted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || + grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true + if (!granted) { + SafetyTrailPreferences.setWantsTrail(context, false) + return@rememberLauncherForActivityResult + } + if (needsNotification()) { + notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + start() + } + } + + LaunchedEffect(running) { + if (requested || running || !SafetyTrailPreferences.wantsTrail(context)) return@LaunchedEffect + requested = true + when { + hasLocation() && needsNotification() -> notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + hasLocation() -> start() + else -> locationLauncher.launch( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + ) + } + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/safety/SafetyLocationsScreen.kt b/app/src/main/java/com/app/paperstow/presentation/safety/SafetyLocationsScreen.kt new file mode 100644 index 0000000..fabb3b3 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/safety/SafetyLocationsScreen.kt @@ -0,0 +1,338 @@ +package com.app.paperstow.presentation.safety + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import com.app.paperstow.R +import com.app.paperstow.domain.safety.SafetyPlace +import com.app.paperstow.domain.safety.UniqueLocations +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SafetyLocationsScreen( + onBack: () -> Unit, + viewModel: SafetyLocationsViewModel = hiltViewModel() +) { + val colors = MaterialTheme.colorScheme + val context = LocalContext.current + val places by viewModel.places.collectAsState() + val running by viewModel.running.collectAsState() + var showDisclosure by remember { mutableStateOf(false) } + var showClearConfirm by remember { mutableStateOf(false) } + var permissionDenied by remember { mutableStateOf(false) } + + val notificationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { viewModel.startTrail(context) } + + val locationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grants -> + val granted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || + grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true + if (!granted) { + permissionDenied = true + return@rememberLauncherForActivityResult + } + permissionDenied = false + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + viewModel.startTrail(context) + } + } + + fun requestStart() { + val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + if (fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + viewModel.startTrail(context) + } + } else { + showDisclosure = true + } + } + + if (showDisclosure) { + AlertDialog( + onDismissRequest = { showDisclosure = false }, + title = { Text(stringResource(R.string.safety_disclosure_title)) }, + text = { Text(stringResource(R.string.safety_disclosure_body), fontSize = 14.sp) }, + confirmButton = { + TextButton(onClick = { + showDisclosure = false + locationLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + }) { Text(stringResource(R.string.safety_allow_location)) } + }, + dismissButton = { + TextButton(onClick = { showDisclosure = false }) { Text(stringResource(R.string.btn_cancel)) } + } + ) + } + + if (showClearConfirm) { + AlertDialog( + onDismissRequest = { showClearConfirm = false }, + title = { Text(stringResource(R.string.safety_clear_title)) }, + text = { Text(stringResource(R.string.safety_clear_body)) }, + confirmButton = { + TextButton(onClick = { + showClearConfirm = false + viewModel.clearPlaces() + }) { Text(stringResource(R.string.safety_clear)) } + }, + dismissButton = { + TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.btn_cancel)) } + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.safety_title)) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.btn_back)) } } + ) + } + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + ) { + Text( + stringResource(R.string.safety_intro), + fontSize = 14.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant) + ) { + Column(Modifier.padding(16.dp)) { + Text( + if (running) stringResource(R.string.safety_status_on) else stringResource(R.string.safety_status_off), + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ) + Spacer(Modifier.height(4.dp)) + Text( + if (running) stringResource(R.string.safety_status_on_hint) else stringResource(R.string.safety_status_off_hint), + fontSize = 12.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + if (running) { + OutlinedButton(onClick = { viewModel.stopTrail(context) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.safety_stop)) + } + } else { + Button(onClick = { requestStart() }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.safety_start)) + } + } + if (permissionDenied) { + Spacer(Modifier.height(8.dp)) + Text(stringResource(R.string.safety_permission_denied), fontSize = 12.sp, color = colors.error) + TextButton(onClick = { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) + ) + }) { Text(stringResource(R.string.safety_open_settings)) } + } + } + } + Spacer(Modifier.height(16.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + val text = viewModel.shareLastKnownText() ?: return@OutlinedButton + shareText(context, text) + }, + enabled = places.isNotEmpty(), + modifier = Modifier.weight(1f) + ) { Text(stringResource(R.string.safety_share_last), fontSize = 13.sp) } + OutlinedButton( + onClick = { shareText(context, viewModel.shareAllText()) }, + enabled = places.isNotEmpty(), + modifier = Modifier.weight(1f) + ) { Text(stringResource(R.string.safety_share_all), fontSize = 13.sp) } + } + Spacer(Modifier.height(8.dp)) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.safety_list_title, places.size), + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = Modifier.weight(1f) + ) + if (places.isNotEmpty()) { + TextButton(onClick = { showClearConfirm = true }) { + Text(stringResource(R.string.safety_clear)) + } + } + } + if (places.isEmpty()) { + Column( + Modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(Icons.Filled.MyLocation, null, Modifier.size(48.dp), tint = colors.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + Text(stringResource(R.string.safety_empty), fontSize = 14.sp, color = colors.onSurfaceVariant) + } + } else { + LazyColumn { + itemsIndexed(places) { index, place -> + PlaceCard( + place = place, + isLastKnown = index == 0, + onOpenMaps = { openMaps(context, place) }, + onShare = { shareText(context, UniqueLocationsShare(place)) } + ) + Spacer(Modifier.height(8.dp)) + } + } + } + } + } +} + +private fun UniqueLocationsShare(place: SafetyPlace): String = + com.app.paperstow.domain.safety.UniqueLocations.shareLastKnown(place) + +@Composable +private fun PlaceCard( + place: SafetyPlace, + isLastKnown: Boolean, + onOpenMaps: () -> Unit, + onShare: () -> Unit +) { + val colors = MaterialTheme.colorScheme + val time = remember(place.timestamp) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(place.timestamp)) + } + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isLastKnown) colors.primaryContainer else colors.surfaceVariant + ) + ) { + Column(Modifier.padding(12.dp)) { + Text( + if (isLastKnown) stringResource(R.string.safety_last_known) else stringResource(R.string.safety_earlier_place), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = colors.onSurfaceVariant + ) + Text(time, fontWeight = FontWeight.Medium, fontSize = 15.sp) + Text( + "%.5f, %.5f".format(place.latitude, place.longitude), + fontSize = 12.sp, + color = colors.onSurfaceVariant + ) + UniqueLocations.batteryLabel(place.batteryPercent)?.let { label -> + Text(label, fontSize = 12.sp, color = colors.onSurfaceVariant) + } + Spacer(Modifier.height(8.dp)) + Row { + TextButton(onClick = onOpenMaps) { Text(stringResource(R.string.safety_open_maps)) } + Spacer(Modifier.width(4.dp)) + TextButton(onClick = onShare) { + Icon(Icons.Filled.Share, null, Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.share_document)) + } + } + } + } +} + +private fun shareText(context: android.content.Context, text: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.safety_share_subject)) + } + context.startActivity(Intent.createChooser(intent, context.getString(R.string.safety_share_subject))) +} + +private fun openMaps(context: android.content.Context, place: SafetyPlace) { + val geo = Uri.parse("geo:${place.latitude},${place.longitude}?q=${place.latitude},${place.longitude}") + val maps = Intent(Intent.ACTION_VIEW, geo) + try { + context.startActivity(maps) + } catch (_: Exception) { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(com.app.paperstow.domain.safety.UniqueLocations.mapsUrl(place.latitude, place.longitude))) + ) + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/safety/SafetyLocationsViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/safety/SafetyLocationsViewModel.kt new file mode 100644 index 0000000..63b0c33 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/safety/SafetyLocationsViewModel.kt @@ -0,0 +1,50 @@ +package com.app.paperstow.presentation.safety + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.app.paperstow.data.safety.SafetyLocationRepository +import com.app.paperstow.data.safety.SafetyTrailPreferences +import com.app.paperstow.data.safety.SafetyTrailService +import com.app.paperstow.domain.safety.SafetyPlace +import com.app.paperstow.domain.safety.UniqueLocations +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SafetyLocationsViewModel @Inject constructor( + private val repository: SafetyLocationRepository +) : ViewModel() { + + val places: StateFlow> = repository.observeLast24Hours() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val running: StateFlow = SafetyTrailPreferences.running + + init { + viewModelScope.launch { repository.pruneExpired() } + } + + fun startTrail(context: Context) { + SafetyTrailService.start(context.applicationContext) + } + + fun stopTrail(context: Context) { + SafetyTrailService.stop(context.applicationContext) + } + + fun clearPlaces() { + viewModelScope.launch { repository.clearAll() } + } + + fun shareAllText(): String = UniqueLocations.shareAll(places.value) + + fun shareLastKnownText(): String? { + val last = places.value.firstOrNull() ?: return null + return UniqueLocations.shareLastKnown(last) + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/search/.gitkeep b/app/src/main/java/com/app/paperstow/presentation/search/.gitkeep similarity index 100% rename from app/src/main/java/com/app/traveldocs/presentation/search/.gitkeep rename to app/src/main/java/com/app/paperstow/presentation/search/.gitkeep diff --git a/app/src/main/java/com/app/traveldocs/presentation/search/SearchScreen.kt b/app/src/main/java/com/app/paperstow/presentation/search/SearchScreen.kt similarity index 93% rename from app/src/main/java/com/app/traveldocs/presentation/search/SearchScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/search/SearchScreen.kt index 27c4b27..1fa39d8 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/search/SearchScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/search/SearchScreen.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.presentation.search +package com.app.paperstow.presentation.search import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -33,8 +33,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.SearchResult +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.SearchResult @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -44,7 +44,7 @@ fun SearchScreen(onBack: () -> Unit, onDocumentClick: (Document) -> Unit = {}, v Scaffold(topBar = { TopAppBar(title = { Text("Search Documents") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { - OutlinedTextField(value = query, onValueChange = { query = it; viewModel.search(it) }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("Search by tag, name, or ask a question...") }, leadingIcon = { Icon(Icons.Filled.Search, null) }, singleLine = true) + OutlinedTextField(value = query, onValueChange = { query = it; viewModel.search(it) }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("Search tags, file names, or text from the page") }, leadingIcon = { Icon(Icons.Filled.Search, null) }, singleLine = true) Spacer(Modifier.height(16.dp)) when (val r = results) { diff --git a/app/src/main/java/com/app/traveldocs/presentation/search/SearchViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/search/SearchViewModel.kt similarity index 87% rename from app/src/main/java/com/app/traveldocs/presentation/search/SearchViewModel.kt rename to app/src/main/java/com/app/paperstow/presentation/search/SearchViewModel.kt index 11cc263..97cb141 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/search/SearchViewModel.kt +++ b/app/src/main/java/com/app/paperstow/presentation/search/SearchViewModel.kt @@ -1,10 +1,10 @@ -package com.app.traveldocs.presentation.search +package com.app.paperstow.presentation.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.SearchResult -import com.app.traveldocs.domain.repository.SearchEngine +import com.app.paperstow.debug.DebugLogger +import com.app.paperstow.domain.model.SearchResult +import com.app.paperstow.domain.repository.SearchEngine import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -32,7 +32,7 @@ class SearchViewModel @Inject constructor( if (query.isBlank()) { _results.value = null; return } searchJob = viewModelScope.launch { delay(300) - com.app.traveldocs.debug.UsageTelemetry.action("Search", "query") + com.app.paperstow.debug.UsageTelemetry.action("Search", "query") DebugLogger.i("SearchVM", "Searching: '$query'") try { val result = searchEngine.searchNaturalLanguage("default-member", query) diff --git a/app/src/main/java/com/app/paperstow/presentation/settings/SearchIndexViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/settings/SearchIndexViewModel.kt new file mode 100644 index 0000000..971989a --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/settings/SearchIndexViewModel.kt @@ -0,0 +1,19 @@ +package com.app.paperstow.presentation.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.app.paperstow.data.local.SearchIndexRebuilder +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchIndexViewModel @Inject constructor( + private val rebuilder: SearchIndexRebuilder +) : ViewModel() { + val state = rebuilder.state + + fun rebuild() { + viewModelScope.launch { rebuilder.rebuild() } + } +} diff --git a/app/src/main/java/com/app/paperstow/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/app/paperstow/presentation/settings/SettingsScreen.kt new file mode 100644 index 0000000..525e075 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/settings/SettingsScreen.kt @@ -0,0 +1,267 @@ +package com.app.paperstow.presentation.settings + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.app.paperstow.R +import com.app.paperstow.debug.UsageTelemetry + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit, onReset: () -> String, indexViewModel: SearchIndexViewModel = hiltViewModel()) { + val context = LocalContext.current + val colors = MaterialTheme.colorScheme + val index by indexViewModel.state.collectAsState() + var showResetDialog by remember { mutableStateOf(false) } + var resetStep by remember { mutableIntStateOf(0) } + var resetReport by remember { mutableStateOf("") } + + if (showResetDialog) { + AlertDialog( + onDismissRequest = { showResetDialog = false; resetStep = 0 }, + icon = { Icon(Icons.Filled.RestartAlt, null, tint = Color(0xFFF44336)) }, + title = { Text(if (resetStep < 2) "Reset App?" else "ARE YOU SURE?") }, + text = { + Column { + if (resetStep == 3) { + Text("Reset complete. Details:", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + Spacer(Modifier.height(8.dp)) + Text(resetReport, fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Gray) + Spacer(Modifier.height(12.dp)) + Text("Tap Restart App to start fresh.", fontSize = 13.sp) + } else if (resetStep < 2) { + Text("This will PERMANENTLY DELETE:", fontWeight = FontWeight.Bold, fontSize = 14.sp) + Spacer(Modifier.height(8.dp)) + Text("• All imported documents (encrypted files)", fontSize = 13.sp) + Text("• All tags and metadata", fontSize = 13.sp) + Text("• All settings and preferences", fontSize = 13.sp) + Text("• My Trail places on this phone", fontSize = 13.sp) + Text("• EULA acceptance (will show again)", fontSize = 13.sp) + Text("• Debug logs and telemetry", fontSize = 13.sp) + Spacer(Modifier.height(12.dp)) + Text( + "This CANNOT be undone. The app will restart as freshly installed.", + fontWeight = FontWeight.Bold, + color = Color(0xFFF44336), + fontSize = 13.sp + ) + } else { + Text("Last chance. Tap Delete Everything to proceed.", fontSize = 14.sp, color = Color(0xFFF44336)) + Spacer(Modifier.height(8.dp)) + Text("All data will be permanently erased.", fontSize = 13.sp) + } + } + }, + confirmButton = { + if (resetStep < 2) { + TextButton(onClick = { resetStep = 2 }) { Text("I understand, continue", color = Color(0xFFF44336)) } + } else if (resetStep == 2) { + TextButton(onClick = { resetReport = onReset(); resetStep = 3 }) { + Text("DELETE EVERYTHING", color = Color(0xFFF44336), fontWeight = FontWeight.Bold) + } + } else { + TextButton(onClick = { android.os.Process.killProcess(android.os.Process.myPid()) }) { Text("Restart App") } + } + }, + dismissButton = { TextButton(onClick = { showResetDialog = false; resetStep = 0 }) { Text("Cancel") } } + ) + } + + Scaffold(topBar = { TopAppBar(title = { Text("Settings") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { + Text("Personalization", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Home Page Title", fontSize = 14.sp) + Spacer(Modifier.height(4.dp)) + var homeTitle by remember { mutableStateOf(context.getSharedPreferences("app_settings", 0).getString("home_title", "Paperstow") ?: "Paperstow") } + OutlinedTextField( + value = homeTitle, + onValueChange = { homeTitle = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + Button(onClick = { context.getSharedPreferences("app_settings", 0).edit().putString("home_title", homeTitle).apply() }, modifier = Modifier.fillMaxWidth()) { Text("Save") } + } + } + + Spacer(Modifier.height(16.dp)) + Text("Privacy", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + var telemetryEnabled by remember { + mutableStateOf( + context.getSharedPreferences("app_settings", 0).getBoolean("telemetry_enabled", false) || + context.getSharedPreferences("disclaimer_prefs", 0).getBoolean("telemetry_consented", false) + ) + } + ToggleRow("Collect usage telemetry (local only)", telemetryEnabled) { + telemetryEnabled = it + context.getSharedPreferences("app_settings", 0).edit().putBoolean("telemetry_enabled", it).apply() + context.getSharedPreferences("disclaimer_prefs", 0).edit().putBoolean("telemetry_consented", it).apply() + UsageTelemetry.setConsent(it) + } + Spacer(Modifier.height(4.dp)) + Text( + "When enabled, anonymous usage counts stay on this device. Nothing is sent automatically.", + fontSize = 11.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + val privacyUrl = stringResource(R.string.privacy_policy_url) + OutlinedButton( + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(privacyUrl))) + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Privacy Policy") } + } + } + + Spacer(Modifier.height(16.dp)) + Text("Appearance", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + var darkMode by remember { mutableStateOf(context.getSharedPreferences("app_settings", 0).getBoolean("dark_mode", false)) } + ToggleRow("Dark Theme", darkMode) { + darkMode = it + context.getSharedPreferences("app_settings", 0).edit().putBoolean("dark_mode", it).apply() + (context as? android.app.Activity)?.recreate() + } + Spacer(Modifier.height(12.dp)) + var showTips by remember { mutableStateOf(context.getSharedPreferences("app_settings", 0).getBoolean("show_tips", true)) } + ToggleRow("Show tips", showTips) { + showTips = it + context.getSharedPreferences("app_settings", 0).edit().putBoolean("show_tips", it).apply() + } + Spacer(Modifier.height(4.dp)) + Text("Home walkthrough cards. Hide tips on Home remembers this.", fontSize = 11.sp, color = colors.onSurfaceVariant) + } + } + + Spacer(Modifier.height(16.dp)) + Text("Search index", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Rebuild reads each paper on this phone, extracts page text (ML Kit for photos and PDFs), and keeps it for Search. Nothing is uploaded.", + fontSize = 12.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { indexViewModel.rebuild() }, + enabled = !index.running, + modifier = Modifier.fillMaxWidth() + ) { Text(if (index.running) "Rebuilding…" else "Rebuild search index") } + if (index.running || index.done || index.error != null) { + Spacer(Modifier.height(12.dp)) + if (index.total > 0) { + LinearProgressIndicator( + progress = { index.current.toFloat() / index.total.coerceAtLeast(1) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(6.dp)) + Text("${index.current} / ${index.total} ${index.fileName}", fontSize = 12.sp) + } + if (index.error != null) { + Text(index.error!!, fontSize = 12.sp, color = colors.error) + } else if (index.done && !index.running) { + Text("Index rebuilt.", fontSize = 12.sp, color = colors.primary) + } + if (index.excerpt.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text("Extracted text", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text( + index.excerpt.take(2000), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colors.onSurfaceVariant + ) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + Text("Danger zone", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = Color(0xFFF44336)) + Spacer(Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = colors.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Reset App", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Permanently deletes papers, tags, settings, and My Trail on this phone. This cannot be undone.", + fontSize = 12.sp, + color = colors.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { resetStep = 0; resetReport = ""; showResetDialog = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF44336)) + ) { Text("Reset App") } + } + } + } + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onChanged: (Boolean) -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, modifier = Modifier.weight(1f), fontSize = 14.sp) + Switch(checked = checked, onCheckedChange = onChanged) + } +} diff --git a/app/src/main/java/com/app/traveldocs/presentation/tags/TagManagementScreen.kt b/app/src/main/java/com/app/paperstow/presentation/tags/TagManagementScreen.kt similarity index 66% rename from app/src/main/java/com/app/traveldocs/presentation/tags/TagManagementScreen.kt rename to app/src/main/java/com/app/paperstow/presentation/tags/TagManagementScreen.kt index ca40688..fead924 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/tags/TagManagementScreen.kt +++ b/app/src/main/java/com/app/paperstow/presentation/tags/TagManagementScreen.kt @@ -1,10 +1,10 @@ -package com.app.traveldocs.presentation.tags +package com.app.paperstow.presentation.tags +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -22,8 +22,11 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Label +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SortByAlpha +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.Card @@ -33,21 +36,22 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.foundation.layout.FlowRow import androidx.compose.ui.graphics.Color -import com.app.traveldocs.data.local.TagColorStore +import com.app.paperstow.data.local.TagColorStore import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext @@ -56,7 +60,11 @@ import androidx.hilt.navigation.compose.hiltViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TagManagementScreen(onBack: () -> Unit, viewModel: TagManagementViewModel = hiltViewModel()) { +fun TagManagementScreen( + onBack: () -> Unit, + onViewDocs: (String) -> Unit = {}, + viewModel: TagManagementViewModel = hiltViewModel() +) { val tags by viewModel.tags.collectAsState() val sortByCount by viewModel.sortByCount.collectAsState() var showCreateDialog by remember { mutableStateOf(false) } @@ -127,6 +135,9 @@ fun TagManagementScreen(onBack: () -> Unit, viewModel: TagManagementViewModel = title = { Text("Manage Tags") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }, actions = { + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Filled.Refresh, "Refresh tags") + } IconButton(onClick = { viewModel.toggleSort() }) { Icon(Icons.Filled.SortByAlpha, "Sort", tint = if (sortByCount) Color(0xFF1565C0) else Color.Gray) } @@ -148,10 +159,16 @@ fun TagManagementScreen(onBack: () -> Unit, viewModel: TagManagementViewModel = } else { LazyColumn(modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { item { - Text("${tags.size} tags (sorted ${if (sortByCount) "by usage" else "alphabetically"})", fontSize = 12.sp, color = Color.Gray, modifier = Modifier.padding(vertical = 8.dp)) + Text("${tags.size} tags (sorted ${if (sortByCount) "by usage" else "alphabetically"}). Turn Home off to hide a folder on the start screen.", fontSize = 12.sp, color = Color.Gray, modifier = Modifier.padding(vertical = 8.dp)) } - items(tags) { tag -> - TagListItem(tag = tag, onRename = { tagToRename = tag }, onDelete = { tagToDelete = tag }) + items(tags, key = { it.name }) { tag -> + TagListItem( + tag = tag, + onRename = { tagToRename = tag }, + onDelete = { tagToDelete = tag }, + onViewDocs = { onViewDocs(tag.name) }, + onShowOnHome = { viewModel.setShowOnHome(tag.name, it) } + ) Spacer(Modifier.height(6.dp)) } } @@ -159,60 +176,76 @@ fun TagManagementScreen(onBack: () -> Unit, viewModel: TagManagementViewModel = } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun TagListItem(tag: TagInfo, onRename: () -> Unit, onDelete: () -> Unit) { +private fun TagListItem( + tag: TagInfo, + onRename: () -> Unit, + onDelete: () -> Unit, + onViewDocs: () -> Unit, + onShowOnHome: (Boolean) -> Unit +) { val context = LocalContext.current - val colorStore = remember { com.app.traveldocs.data.local.TagColorStore(context) } - var showColorPicker by remember { mutableStateOf(false) } - var currentColor by remember { mutableStateOf(Color(colorStore.getColor(tag.name))) } + val colorStore = remember { TagColorStore(context) } + var showPhotoDialog by remember { mutableStateOf(false) } + var askPhotoConsent by remember { mutableStateOf(false) } + var lookTick by remember { mutableIntStateOf(0) } + + val photoPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null && colorStore.saveImage(tag.name, uri)) lookTick++ + } + + if (askPhotoConsent) { + AlertDialog( + onDismissRequest = { askPhotoConsent = false }, + title = { Text("Use a photo?") }, + text = { Text("This picture stays on this phone and is only used as the folder image for “${tag.name}”.", fontSize = 14.sp) }, + confirmButton = { + TextButton(onClick = { askPhotoConsent = false; photoPicker.launch("image/*") }) { Text("Choose photo") } + }, + dismissButton = { TextButton(onClick = { askPhotoConsent = false }) { Text("Cancel") } } + ) + } - if (showColorPicker) { + if (showPhotoDialog) { AlertDialog( - onDismissRequest = { showColorPicker = false }, - title = { Text("Pick color for \"${tag.name}\"") }, + onDismissRequest = { showPhotoDialog = false }, + title = { Text("Folder image for “${tag.name}”") }, text = { - // Simple palette grid - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - TagColorStore.PALETTE.forEach { colorInt -> - val c = Color(colorInt) - Box( - modifier = Modifier - .size(40.dp) - .padding(4.dp) - .background(c, shape = RoundedCornerShape(8.dp)) - .clickable { - colorStore.setColor(tag.name, colorInt) - currentColor = c - showColorPicker = false - } - ) + Column { + Text("A photo fills this tag’s folder on Home. It stays on this phone.", fontSize = 13.sp, color = Color.Gray) + Spacer(Modifier.height(8.dp)) + TextButton(onClick = { showPhotoDialog = false; askPhotoConsent = true }) { + Icon(Icons.Filled.Image, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Choose photo") } + TextButton(onClick = { + colorStore.clearImage(tag.name) + lookTick++ + showPhotoDialog = false + }) { Text("Remove photo") } } }, - confirmButton = { TextButton(onClick = { showColorPicker = false }) { Text("Cancel") } } + confirmButton = { TextButton(onClick = { showPhotoDialog = false }) { Text("Done") } } ) } Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - // Tap the color circle to change tag color - Box( - modifier = Modifier - .size(24.dp) - .background(currentColor, shape = RoundedCornerShape(12.dp)) - .clickable { showColorPicker = true } - ) + Box(Modifier.clickable { showPhotoDialog = true }) { + key(lookTick) { TagMark(tag.name, colorStore, size = 36.dp) } + } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text(tag.name, fontWeight = FontWeight.Medium, fontSize = 14.sp) - Text(if (tag.isAutoGenerated) "Auto-generated" else "Custom", fontSize = 11.sp, color = Color.Gray) + Text("Tap the mark to set a folder photo", fontSize = 11.sp, color = Color.Gray) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Home", fontSize = 12.sp, color = Color.Gray, modifier = Modifier.padding(end = 6.dp)) + Switch(checked = tag.showOnHome, onCheckedChange = onShowOnHome) + } } Badge(containerColor = Color(0xFFE3F2FD)) { Text("${tag.usageCount}", fontSize = 11.sp) } - Spacer(Modifier.width(8.dp)) + IconButton(onClick = onViewDocs, modifier = Modifier.size(32.dp)) { Icon(Icons.Filled.Visibility, "View documents", tint = Color(0xFF1565C0), modifier = Modifier.size(20.dp)) } IconButton(onClick = onRename, modifier = Modifier.size(32.dp)) { Icon(Icons.Filled.Edit, "Rename", tint = Color(0xFF757575), modifier = Modifier.size(18.dp)) } IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { Icon(Icons.Filled.Delete, "Delete", tint = Color(0xFFBDBDBD), modifier = Modifier.size(18.dp)) } } diff --git a/app/src/main/java/com/app/traveldocs/presentation/tags/TagManagementViewModel.kt b/app/src/main/java/com/app/paperstow/presentation/tags/TagManagementViewModel.kt similarity index 71% rename from app/src/main/java/com/app/traveldocs/presentation/tags/TagManagementViewModel.kt rename to app/src/main/java/com/app/paperstow/presentation/tags/TagManagementViewModel.kt index cc36511..3905370 100644 --- a/app/src/main/java/com/app/traveldocs/presentation/tags/TagManagementViewModel.kt +++ b/app/src/main/java/com/app/paperstow/presentation/tags/TagManagementViewModel.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.presentation.tags +package com.app.paperstow.presentation.tags import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.app.traveldocs.data.local.TagRepositoryImpl -import com.app.traveldocs.debug.DebugLogger +import com.app.paperstow.data.local.TagRepositoryImpl +import com.app.paperstow.debug.DebugLogger import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -11,11 +11,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject -data class TagInfo(val name: String, val usageCount: Int, val isAutoGenerated: Boolean) +data class TagInfo( + val name: String, + val usageCount: Int, + val isAutoGenerated: Boolean, + val showOnHome: Boolean = true +) @HiltViewModel class TagManagementViewModel @Inject constructor( - private val tagRepository: TagRepositoryImpl + private val tagRepository: TagRepositoryImpl, + private val tagLooks: com.app.paperstow.data.local.TagColorStore ) : ViewModel() { private val _tags = MutableStateFlow>(emptyList()) @@ -33,22 +39,21 @@ class TagManagementViewModel @Inject constructor( val allTags = tagRepository.getAllTags(memberId).filter { !it.name.startsWith("__") } val tagInfos = allTags.map { tag -> val count = tagRepository.getTagUsageCount(memberId, tag.name) - TagInfo(tag.name, count, tag.isAutoGenerated) + TagInfo(tag.name, count, tag.isAutoGenerated, tagLooks.isShownOnHome(tag.name)) } _tags.value = if (_sortByCount.value) tagInfos.sortedByDescending { it.usageCount } else tagInfos.sortedBy { it.name } DebugLogger.d("TagMgmt", "Loaded ${tagInfos.size} tags") } } + fun refresh() = loadTags() + fun createTag(name: String) { if (name.isBlank()) return viewModelScope.launch { DebugLogger.i("TagMgmt", "Creating tag: '$name'") - // Creating a tag without a document — we'll add it to a placeholder - // Actually tags only exist when associated with documents in our schema - // So "create" here means it's ready for use but not yet assigned - // For now we log it and reload — it will appear when assigned - DebugLogger.d("TagMgmt", "Tag '$name' created (will appear when assigned to a document)") + val result = tagRepository.createStandaloneTag(memberId, name.trim()) + result.onFailure { DebugLogger.e("TagMgmt", "Create tag failed: ${it.message}") } loadTags() } } @@ -58,6 +63,7 @@ class TagManagementViewModel @Inject constructor( viewModelScope.launch { DebugLogger.i("TagMgmt", "Renaming tag '$oldName' -> '$newName'") tagRepository.renameTag(memberId, oldName, newName) + tagLooks.renameLook(oldName, newName) loadTags() } } @@ -66,6 +72,7 @@ class TagManagementViewModel @Inject constructor( viewModelScope.launch { DebugLogger.w("TagMgmt", "Deleting tag '$tagName' globally") tagRepository.deleteTagGlobally(memberId, tagName) + tagLooks.clearLook(tagName) loadTags() } } @@ -77,6 +84,11 @@ class TagManagementViewModel @Inject constructor( } } + fun setShowOnHome(tagName: String, shown: Boolean) { + tagLooks.setShownOnHome(tagName, shown) + _tags.value = _tags.value.map { if (it.name == tagName) it.copy(showOnHome = shown) else it } + } + fun toggleSort() { _sortByCount.value = !_sortByCount.value val current = _tags.value diff --git a/app/src/main/java/com/app/paperstow/presentation/tags/TagMark.kt b/app/src/main/java/com/app/paperstow/presentation/tags/TagMark.kt new file mode 100644 index 0000000..df713f3 --- /dev/null +++ b/app/src/main/java/com/app/paperstow/presentation/tags/TagMark.kt @@ -0,0 +1,85 @@ +package com.app.paperstow.presentation.tags + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.app.paperstow.data.local.TagColorStore + +@Composable +fun TagMark( + tagName: String, + store: TagColorStore, + size: Dp = 32.dp, + rounded: Boolean = true +) { + val image = remember(tagName, store.imageFile(tagName)?.length()) { + store.imageFile(tagName)?.let { BitmapFactory.decodeFile(it.absolutePath) } + } + val shape = if (rounded) CircleShape else RoundedCornerShape(8.dp) + if (image != null) { + Image( + bitmap = image.asImageBitmap(), + contentDescription = tagName, + modifier = Modifier.size(size).clip(shape), + contentScale = ContentScale.Crop + ) + } else { + Box( + Modifier.size(size).clip(shape).background(Color(0xFFE8EEF5)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.Folder, tagName, tint = Color(0xFF5C6B7A), modifier = Modifier.size(size * 0.55f)) + } + } +} + +/** Full-bleed cover for a folder tile — photo if set, otherwise a folder mark. */ +@Composable +fun TagCover( + tagName: String, + store: TagColorStore, + modifier: Modifier = Modifier, + untagged: Boolean = false +) { + val image = remember(tagName, store.imageFile(tagName)?.length()) { + if (untagged) null else store.imageFile(tagName)?.let { BitmapFactory.decodeFile(it.absolutePath) } + } + + BoxWithConstraints(modifier.fillMaxSize()) { + val icon = minOf(maxWidth, maxHeight) * 0.42f + if (image != null) { + Image( + bitmap = image.asImageBitmap(), + contentDescription = tagName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + Modifier.fillMaxSize().background(Color(0xFFE8EEF5)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.Folder, tagName, tint = Color(0xFF5C6B7A), modifier = Modifier.size(icon)) + } + } + } +} diff --git a/app/src/main/java/com/app/traveldocs/data/backup/BackupManager.kt b/app/src/main/java/com/app/traveldocs/data/backup/BackupManager.kt deleted file mode 100644 index 06054ca..0000000 --- a/app/src/main/java/com/app/traveldocs/data/backup/BackupManager.kt +++ /dev/null @@ -1,199 +0,0 @@ -package com.app.traveldocs.data.backup - -import android.content.Context -import com.app.traveldocs.BuildConfig -import com.app.traveldocs.debug.DebugLogger -import net.lingala.zip4j.ZipFile -import net.lingala.zip4j.model.ZipParameters -import net.lingala.zip4j.model.enums.AesKeyStrength -import net.lingala.zip4j.model.enums.CompressionLevel -import net.lingala.zip4j.model.enums.CompressionMethod -import net.lingala.zip4j.model.enums.EncryptionMethod -import org.json.JSONArray -import org.json.JSONObject -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class BackupManager @Inject constructor() { - - data class BackupResult(val zipFile: File, val fileCount: Int, val totalBytes: Long) - - /** - * Creates a password-protected ZIP with DECRYPTED document contents. - * The ZIP uses AES-256 encryption with the user's backup PIN. - * Contents inside are plaintext — making the backup transportable to any device. - */ - fun createBackupZip(context: Context, backupPin: String? = null): BackupResult { - DebugLogger.i("Backup", "Creating transportable backup archive...") - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val zipPath = File(context.cacheDir, "backup_$timestamp.zip") - val tempDir = File(context.cacheDir, "backup_staging_$timestamp") - tempDir.mkdirs() - - var fileCount = 0 - var totalBytes = 0L - val fileManifest = JSONArray() - - // 1. Decrypt document files and stage them - val docsDir = File(context.filesDir, "docs") - if (docsDir.exists()) { - docsDir.walkTopDown().filter { it.isFile && it.name.endsWith(".enc") }.forEach { encFile -> - try { - val decrypted = decryptFile(context, encFile) - if (decrypted != null) { - val relativePath = encFile.relativeTo(docsDir).path.replace(".enc", "") - val fileId = encFile.nameWithoutExtension - val outFile = File(tempDir, "docs/$relativePath") - outFile.parentFile?.mkdirs() - - // Check if this doc has a PIN — if so, store PIN-encrypted (portable) - val pinPrefs = context.getSharedPreferences("secure_doc_pins", 0) - val pinSalt = pinPrefs.getString("pin_salt_$fileId", null) - val pinHash = pinPrefs.getString("pin_hash_$fileId", null) - if (pinSalt != null && pinHash != null) { - // Store as PIN-encrypted: the file can only be opened with the original PIN - // We re-encrypt with a key derived from a known marker + salt - // Actually just store plaintext — the PIN verification is in the prefs - // The prefs (hash+salt) will be stored in a separate backup metadata file - outFile.writeBytes(decrypted) - // Store PIN metadata for this file - val pinMetaFile = File(tempDir, "pin_metadata/$fileId.json") - pinMetaFile.parentFile?.mkdirs() - pinMetaFile.writeText(org.json.JSONObject().apply { - put("fileId", fileId) - put("pinHash", pinHash) - put("pinSalt", pinSalt) - put("pinSetTime", pinPrefs.getLong("pin_set_time_$fileId", 0)) - }.toString(2)) - } else { - outFile.writeBytes(decrypted) - } - - fileCount++ - totalBytes += decrypted.size - // Compute SHA-256 hash for integrity verification - val hash = java.security.MessageDigest.getInstance("SHA-256") - .digest(decrypted).joinToString("") { "%02x".format(it) } - // Get document metadata from DB for tags and PIN info - val docTags = try { - val db = context.getDatabasePath("traveldocs.db") - // Tags will be captured from the database copy - emptyList() - } catch (_: Exception) { emptyList() } - val hasPinSet = context.getSharedPreferences("secure_doc_pins", 0) - .contains("pin_hash_$fileId") - - fileManifest.put(JSONObject().apply { - put("path", "docs/$relativePath") - put("size", decrypted.size) - put("sha256", hash) - put("hasPinProtection", hasPinSet) - put("originalFileId", fileId) - }) - } - } catch (e: Exception) { - DebugLogger.e("Backup", "Failed to decrypt ${encFile.name}", e) - } - } - } - - // 2. Export database (checkpoint WAL first to ensure all data is in main file) - val dbFile = context.getDatabasePath("traveldocs.db") - if (dbFile.exists()) { - try { - val checkpointDb = android.database.sqlite.SQLiteDatabase.openDatabase(dbFile.path, null, android.database.sqlite.SQLiteDatabase.OPEN_READWRITE) - checkpointDb.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).close() - checkpointDb.close() - DebugLogger.i("Backup", "WAL checkpoint complete — DB consistent for backup") - } catch (e: Exception) { - DebugLogger.w("Backup", "WAL checkpoint before backup failed: ${e.message}") - } - val destDb = File(tempDir, "database/traveldocs.db") - destDb.parentFile?.mkdirs() - dbFile.copyTo(destDb, overwrite = true) - fileCount++ - totalBytes += dbFile.length() - } - - // 3. Write manifest with schema, hashes, tags, and PIN status - val manifest = JSONObject().apply { - put("schemaVersion", 2) - put("timestamp", timestamp) - put("appVersion", BuildConfig.VERSION_NAME) - put("fileCount", fileCount) - put("totalSizeBytes", totalBytes) - put("encrypted", backupPin != null) - put("transportable", true) - put("files", fileManifest) - } - File(tempDir, "manifest.json").writeText(manifest.toString(2)) - - // 4. Create ZIP (password-protected if PIN provided) - if (backupPin != null && backupPin.length >= 4) { - val zipFile = ZipFile(zipPath, backupPin.toCharArray()) - val params = ZipParameters().apply { - compressionMethod = CompressionMethod.DEFLATE - compressionLevel = CompressionLevel.NORMAL - isEncryptFiles = true - encryptionMethod = EncryptionMethod.AES - aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256 - } - tempDir.walkTopDown().filter { it.isFile }.forEach { file -> - val entryName = file.relativeTo(tempDir).path - params.fileNameInZip = entryName - zipFile.addFile(file, params) - } - } else { - // No password — plain ZIP - val zipFile = ZipFile(zipPath) - tempDir.walkTopDown().filter { it.isFile }.forEach { file -> - val entryName = file.relativeTo(tempDir).path - val params = ZipParameters().apply { fileNameInZip = entryName } - zipFile.addFile(file, params) - } - } - - // 5. Cleanup staging - tempDir.deleteRecursively() - - DebugLogger.i("Backup", "Backup created: ${zipPath.name}, $fileCount files, ${totalBytes / 1024}KB, protected=${backupPin != null}") - return BackupResult(zipPath, fileCount, totalBytes) - } - - /** - * Decrypt a .enc file using the same key from Android KeyStore. - */ - private fun decryptFile(context: Context, encFile: File): ByteArray? { - return try { - val encData = encFile.readBytes() - if (encData.size <= 12) return null - val iv = encData.copyOfRange(0, 12) - val ciphertext = encData.copyOfRange(12, encData.size) - - val keyStore = java.security.KeyStore.getInstance("AndroidKeyStore") - keyStore.load(null) - val key = (keyStore.getEntry("travel_docs_file_encryption_key", null) as java.security.KeyStore.SecretKeyEntry).secretKey - - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) - cipher.doFinal(ciphertext) - } catch (e: Exception) { - DebugLogger.e("Backup", "Decrypt failed: ${encFile.name}", e) - null - } - } - - fun suggestedFolderName(): String = "TravelDocs_Backup" - - fun suggestedFileName(): String { - val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - return "traveldocs_backup_$ts.zip" - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/backup/DriveBackupUploader.kt b/app/src/main/java/com/app/traveldocs/data/backup/DriveBackupUploader.kt deleted file mode 100644 index dc1c906..0000000 --- a/app/src/main/java/com/app/traveldocs/data/backup/DriveBackupUploader.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.app.traveldocs.data.backup - -import android.content.Context -import com.app.traveldocs.data.drive.DriveServiceProvider -import com.app.traveldocs.debug.DebugLogger -import com.google.api.client.http.FileContent -import com.google.api.services.drive.model.File as DriveFile -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class DriveBackupUploader @Inject constructor( - private val driveServiceProvider: DriveServiceProvider -) { - fun upload(context: Context, zipFile: File, fileName: String): Result { - return try { - val service = driveServiceProvider.getDriveService() - ?: return Result.failure(Exception("Google Drive not authenticated. Please sign in to Google in device settings.")) - - DebugLogger.i("DriveBackup", "Uploading $fileName (${zipFile.length() / 1024}KB) to Drive...") - - val metadata = DriveFile().apply { - name = fileName - mimeType = "application/zip" - parents = listOf("appDataFolder") - } - - val content = FileContent("application/zip", zipFile) - val uploaded = service.files().create(metadata, content) - .setFields("id,name,size") - .execute() - - DebugLogger.i("DriveBackup", "Upload complete: id=${uploaded.id}, name=${uploaded.name}") - Result.success("Uploaded to Google Drive: ${uploaded.name}") - } catch (e: Exception) { - DebugLogger.e("DriveBackup", "Upload failed", e) - Result.failure(Exception("Drive upload failed: ${e.message}")) - } - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/backup/S3BackupUploader.kt b/app/src/main/java/com/app/traveldocs/data/backup/S3BackupUploader.kt deleted file mode 100644 index 95e989c..0000000 --- a/app/src/main/java/com/app/traveldocs/data/backup/S3BackupUploader.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.app.traveldocs.data.backup - -import com.app.traveldocs.debug.DebugLogger -import java.io.File -import java.net.HttpURLConnection -import java.net.URL -import java.security.MessageDigest -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -data class S3Config(val endpoint: String, val bucket: String, val accessKey: String, val secretKey: String, val region: String = "us-east-1") - -object S3BackupUploader { - fun upload(zipFile: File, fileName: String, config: S3Config): Result { - return try { - DebugLogger.i("S3Backup", "Uploading $fileName (${zipFile.length()/1024}KB) to ${config.endpoint}/${config.bucket}") - val bytes = zipFile.readBytes() - val contentHash = sha256Hex(bytes) - val now = Date() - val dateStamp = SimpleDateFormat("yyyyMMdd", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(now) - val amzDate = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(now) - val host = URL(config.endpoint).host - val path = "/${config.bucket}/$fileName" - val url = "${config.endpoint}$path" - val headers = "content-type:application/zip\nhost:$host\nx-amz-content-sha256:$contentHash\nx-amz-date:$amzDate\n" - val signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date" - val canonical = "PUT\n$path\n\n$headers\n$signedHeaders\n$contentHash" - val scope = "$dateStamp/${config.region}/s3/aws4_request" - val stringToSign = "AWS4-HMAC-SHA256\n$amzDate\n$scope\n${sha256Hex(canonical.toByteArray())}" - val kDate = hmac("AWS4${config.secretKey}".toByteArray(), dateStamp) - val kRegion = hmac(kDate, config.region) - val kService = hmac(kRegion, "s3") - val kSigning = hmac(kService, "aws4_request") - val signature = hmac(kSigning, stringToSign).joinToString("") { "%02x".format(it) } - val authorization = "AWS4-HMAC-SHA256 Credential=${config.accessKey}/$scope, SignedHeaders=$signedHeaders, Signature=$signature" - val conn = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = "PUT"; doOutput = true - setRequestProperty("Content-Type", "application/zip") - setRequestProperty("Host", host) - setRequestProperty("x-amz-content-sha256", contentHash) - setRequestProperty("x-amz-date", amzDate) - setRequestProperty("Authorization", authorization) - setRequestProperty("Content-Length", bytes.size.toString()) - } - conn.outputStream.use { it.write(bytes) } - val code = conn.responseCode - if (code in 200..299) { DebugLogger.i("S3Backup", "Success: HTTP $code"); Result.success("Uploaded to S3: HTTP $code") } - else { val err = conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"; DebugLogger.e("S3Backup", err); Result.failure(Exception(err)) } - } catch (e: Exception) { DebugLogger.e("S3Backup", "Failed", e); Result.failure(e) } - } - private fun sha256Hex(data: ByteArray) = MessageDigest.getInstance("SHA-256").digest(data).joinToString("") { "%02x".format(it) } - private fun hmac(key: ByteArray, data: String): ByteArray { val m = Mac.getInstance("HmacSHA256"); m.init(SecretKeySpec(key, "HmacSHA256")); return m.doFinal(data.toByteArray()) } -} diff --git a/app/src/main/java/com/app/traveldocs/data/drive/DriveServiceProvider.kt b/app/src/main/java/com/app/traveldocs/data/drive/DriveServiceProvider.kt deleted file mode 100644 index a914e2b..0000000 --- a/app/src/main/java/com/app/traveldocs/data/drive/DriveServiceProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.app.traveldocs.data.drive - -import com.google.api.services.drive.Drive - -/** - * Provides an authenticated Google Drive service instance. - * Returns null if the user is not authenticated or credentials are invalid. - */ -interface DriveServiceProvider { - /** - * Returns an authenticated Drive service, or null if authentication is unavailable. - */ - fun getDriveService(): Drive? -} diff --git a/app/src/main/java/com/app/traveldocs/data/drive/GoogleDriveImporter.kt b/app/src/main/java/com/app/traveldocs/data/drive/GoogleDriveImporter.kt deleted file mode 100644 index 30aec24..0000000 --- a/app/src/main/java/com/app/traveldocs/data/drive/GoogleDriveImporter.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.app.traveldocs.data.drive - -import com.app.traveldocs.debug.DebugLogger -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.ImportedDocument -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.ByteArrayOutputStream -import java.io.IOException -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Handles importing documents from Google Drive or local content URIs. - * - * For content URIs (from Drive Picker activity results), uses ContentResolver to read bytes directly. - * For Drive file IDs, uses the Drive REST API to download the file. - */ -@Singleton -class GoogleDriveImporter @Inject constructor( - @ApplicationContext private val context: Context, - private val driveServiceProvider: DriveServiceProvider -) { - - /** - * Imports a document from a content URI (e.g., from Google Drive Picker result). - * Reads the file content, determines format, and returns an ImportedDocument. - */ - suspend fun importFromUri(uri: Uri): Result { - DebugLogger.i("Drive", "importFromUri: uri=$uri") - return try { - val contentResolver = context.contentResolver - - val mimeType = contentResolver.getType(uri) - val fileName = getFileName(contentResolver, uri) - val format = resolveFormatFromMimeType(mimeType) - ?: resolveFormatFromFileName(fileName) - ?: return Result.failure( - IOException( - "Unsupported document format. Only PDF, JPG, and PNG files are supported. MIME type: $mimeType" - ) - ) - - val rawBytes = readBytesFromUri(contentResolver, uri) - - Result.success( - ImportedDocument( - rawBytes = rawBytes, - format = format, - originalFileName = fileName - ) - ) - } catch (e: IOException) { - Result.failure( - IOException( - "Failed to import document: ${e.message}. Please check your network connection and try again.", - e - ) - ) - } catch (e: SecurityException) { - Result.failure( - IOException( - "Access denied. Please re-authorize Google Drive access and try again.", - e - ) - ) - } catch (e: Exception) { - Result.failure( - IOException( - "An unexpected error occurred during document import: ${e.message}", - e - ) - ) - } - } - - /** - * Imports a document from Google Drive using a file ID. - * Uses the Drive REST API to download the file content. - */ - suspend fun importFromDriveFileId(fileId: String): Result { - DebugLogger.i("Drive", "importFromDriveFileId: fileId=$fileId") - return try { - val driveService = driveServiceProvider.getDriveService() - ?: return Result.failure( - IOException( - "Google Drive authentication failed. Please sign in to Google Drive and try again." - ) - ) - - val file = driveService.files().get(fileId) - .setFields("name,mimeType") - .execute() - - val mimeType = file.mimeType - val fileName = file.name - val format = resolveFormatFromMimeType(mimeType) - ?: resolveFormatFromFileName(fileName) - ?: return Result.failure( - IOException( - "Unsupported document format. Only PDF, JPG, and PNG files are supported. File: $fileName, MIME type: $mimeType" - ) - ) - - val outputStream = ByteArrayOutputStream() - driveService.files().get(fileId).executeMediaAndDownloadTo(outputStream) - val rawBytes = outputStream.toByteArray() - - Result.success( - ImportedDocument( - rawBytes = rawBytes, - format = format, - originalFileName = fileName - ) - ) - } catch (e: IOException) { - Result.failure( - IOException( - "Failed to download file from Google Drive: ${e.message}. Please check your network connection and try again.", - e - ) - ) - } catch (e: SecurityException) { - Result.failure( - IOException( - "Google Drive access denied. Please re-authorize and try again.", - e - ) - ) - } catch (e: Exception) { - Result.failure( - IOException( - "An unexpected error occurred while downloading from Google Drive: ${e.message}", - e - ) - ) - } - } - - private fun readBytesFromUri(contentResolver: ContentResolver, uri: Uri): ByteArray { - return contentResolver.openInputStream(uri)?.use { inputStream -> - inputStream.readBytes() - } ?: throw IOException("Unable to open document. The file may have been moved or deleted.") - } - - private fun getFileName(contentResolver: ContentResolver, uri: Uri): String? { - var name: String? = null - contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0 && cursor.moveToFirst()) { - name = cursor.getString(nameIndex) - } - } - return name - } - - internal fun resolveFormatFromMimeType(mimeType: String?): DocumentFormat? { - return when (mimeType?.lowercase()) { - "application/pdf" -> DocumentFormat.PDF - "image/jpeg", "image/jpg" -> DocumentFormat.JPG - "image/png" -> DocumentFormat.PNG - else -> null - } - } - - internal fun resolveFormatFromFileName(fileName: String?): DocumentFormat? { - if (fileName == null) return null - val extension = fileName.substringAfterLast('.', "").lowercase() - return when (extension) { - "pdf" -> DocumentFormat.PDF - "jpg", "jpeg" -> DocumentFormat.JPG - "png" -> DocumentFormat.PNG - else -> null - } - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/drive/GoogleDriveServiceProvider.kt b/app/src/main/java/com/app/traveldocs/data/drive/GoogleDriveServiceProvider.kt deleted file mode 100644 index 55875d8..0000000 --- a/app/src/main/java/com/app/traveldocs/data/drive/GoogleDriveServiceProvider.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.app.traveldocs.data.drive - -import android.content.Context -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential -import com.google.api.client.http.javanet.NetHttpTransport -import com.google.api.client.json.gson.GsonFactory -import com.google.api.services.drive.Drive -import com.google.api.services.drive.DriveScopes -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Provides an authenticated Google Drive service using the currently signed-in Google account. - * Returns null if no Google account is signed in. - */ -@Singleton -class GoogleDriveServiceProvider @Inject constructor( - @ApplicationContext private val context: Context -) : DriveServiceProvider { - - override fun getDriveService(): Drive? { - val account = GoogleSignIn.getLastSignedInAccount(context) ?: return null - - val credential = GoogleAccountCredential.usingOAuth2( - context, - listOf(DriveScopes.DRIVE_READONLY) - ) - credential.selectedAccount = account.account ?: return null - - return Drive.Builder( - NetHttpTransport(), - GsonFactory.getDefaultInstance(), - credential - ) - .setApplicationName("TravelDocs") - .build() - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/local/FeatureFlags.kt b/app/src/main/java/com/app/traveldocs/data/local/FeatureFlags.kt deleted file mode 100644 index 6763a10..0000000 --- a/app/src/main/java/com/app/traveldocs/data/local/FeatureFlags.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.app.traveldocs.data.local - -import android.content.Context - -/** - * Simple SharedPreferences-backed feature flags. - * - * No server, no remote config, no complexity. Toggle in Settings, persists across restarts. - * All flags default to OFF — users only see stable features until they explicitly opt in. - * - * Why not a proper feature flag service? Because this app has no backend. - * The whole point is zero network dependency for core functionality. - */ -object FeatureFlags { - private const val PREFS = "feature_flags" - - fun isExperimentalEnabled(context: Context) = prefs(context).getBoolean("experimental", false) - fun isGoogleDriveEnabled(context: Context) = prefs(context).getBoolean("google_drive", false) - fun isS3Enabled(context: Context) = prefs(context).getBoolean("s3_storage", false) - fun isGpsTrackingEnabled(context: Context) = prefs(context).getBoolean("gps_tracking", false) - fun setGpsTracking(context: Context, v: Boolean) = prefs(context).edit().putBoolean("gps_tracking", v).apply() - - fun isBackupRestoreEnabled(context: Context) = prefs(context).getBoolean("backup_restore", false) - fun isWifiShareEnabled(context: Context) = prefs(context).getBoolean("wifi_share", false) - fun setWifiShare(context: Context, v: Boolean) = prefs(context).edit().putBoolean("wifi_share", v).apply() - - fun isAudioPlaybackEnabled(context: Context) = prefs(context).getBoolean("audio_playback", false) - fun setAudioPlayback(context: Context, v: Boolean) = prefs(context).edit().putBoolean("audio_playback", v).apply() - - fun isExtendedFormatsEnabled(context: Context) = prefs(context).getBoolean("extended_formats", false) - fun setExtendedFormats(context: Context, v: Boolean) = prefs(context).edit().putBoolean("extended_formats", v).apply() - - fun setExperimental(context: Context, v: Boolean) = prefs(context).edit().putBoolean("experimental", v).apply() - fun setGoogleDrive(context: Context, v: Boolean) = prefs(context).edit().putBoolean("google_drive", v).apply() - fun setS3(context: Context, v: Boolean) = prefs(context).edit().putBoolean("s3_storage", v).apply() - fun setBackupRestore(context: Context, v: Boolean) = prefs(context).edit().putBoolean("backup_restore", v).apply() - - /** - * Per-format toggles. Each extended image format can be individually enabled/disabled. - * Defaults to ON when extended formats master toggle is enabled. - */ - fun isFormatEnabled(context: Context, format: String) = prefs(context).getBoolean("format_$format", true) - fun setFormatEnabled(context: Context, format: String, v: Boolean) = prefs(context).edit().putBoolean("format_$format", v).apply() - - private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) -} diff --git a/app/src/main/java/com/app/traveldocs/data/local/GpxExporter.kt b/app/src/main/java/com/app/traveldocs/data/local/GpxExporter.kt deleted file mode 100644 index 24b9a18..0000000 --- a/app/src/main/java/com/app/traveldocs/data/local/GpxExporter.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.app.traveldocs.data.local - -import com.app.traveldocs.data.local.entity.GpsTrackEntity -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone - -object GpxExporter { - fun export(tracks: List): String { - val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) - dateFormat.timeZone = TimeZone.getTimeZone("UTC") - - val sb = StringBuilder() - sb.appendLine("") - sb.appendLine("") - sb.appendLine(" ") - sb.appendLine(" Travel Track") - sb.appendLine(" ") - for (t in tracks) { - val time = dateFormat.format(Date(t.timestamp)) - sb.appendLine(" ") - sb.appendLine(" ") - sb.appendLine(" ${t.accuracy}") - sb.appendLine(" ") - } - sb.appendLine(" ") - sb.appendLine(" ") - sb.appendLine("") - return sb.toString() - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/local/SecureDocumentManager.kt b/app/src/main/java/com/app/traveldocs/data/local/SecureDocumentManager.kt deleted file mode 100644 index e02b87a..0000000 --- a/app/src/main/java/com/app/traveldocs/data/local/SecureDocumentManager.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.app.traveldocs.data.local - -import android.content.Context -import com.app.traveldocs.debug.DebugLogger -import java.security.MessageDigest -import javax.crypto.Cipher -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.SecretKeySpec -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Per-document PIN protection. - * - * How it works: - * - When user sets a PIN on a document, we derive a key from that PIN using PBKDF2 - * - The document's file bytes are re-encrypted with this PIN-derived key (on top of the - * existing device-level AES-256-GCM encryption) - * - The PIN hash is stored in SharedPreferences so we can verify attempts - * - The actual decryption key exists ONLY when the correct PIN is entered - * - * Important guarantees: - * - Developer cannot recover PIN-protected documents (no backdoor, no master key) - * - If user forgets the PIN, the document is permanently inaccessible - * - This is a privacy feature — user bears full responsibility - * - * Tagged with system tag "__PIN_PROTECTED" for folder grouping on home screen. - */ -@Singleton -class SecureDocumentManager @Inject constructor( - @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context -) { - companion object { - const val SECURE_TAG = "__PIN_PROTECTED" - const val SECURE_FOLDER_DISPLAY_NAME = "Secure Docs" - private const val PREFS_NAME = "secure_doc_pins" - private const val PBKDF2_ITERATIONS = 10000 - private const val KEY_LENGTH_BITS = 256 - private const val GCM_TAG_LENGTH = 128 - } - - private fun prefs() = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - - /** - * Check if a document has a PIN set. - */ - fun hasPinSet(documentId: String): Boolean { - return prefs().contains("pin_hash_$documentId") - } - - /** - * Set a PIN on a document. Stores the hash for verification. - * Returns the derived encryption key (caller uses it to re-encrypt the file). - */ - fun setPin(documentId: String, pin: String): ByteArray { - val salt = generateSalt() - val pinHash = hashPin(pin, salt) - prefs().edit() - .putString("pin_hash_$documentId", pinHash) - .putString("pin_salt_$documentId", salt.toHexString()) - .putLong("pin_set_time_$documentId", System.currentTimeMillis()) - .apply() - DebugLogger.i("SecureDoc", "PIN set for document ${documentId.take(8)}...") - return deriveKey(pin, salt) - } - - /** - * Verify a PIN attempt. Returns the derived key if correct, null if wrong. - */ - fun verifyPin(documentId: String, pin: String): ByteArray? { - val storedHash = prefs().getString("pin_hash_$documentId", null) ?: return null - val saltHex = prefs().getString("pin_salt_$documentId", null) ?: return null - val salt = saltHex.hexToByteArray() - val attemptHash = hashPin(pin, salt) - return if (attemptHash == storedHash) { - deriveKey(pin, salt) - } else { - DebugLogger.w("SecureDoc", "PIN verification failed for ${documentId.take(8)}...") - null - } - } - - /** - * Remove PIN protection from a document. - */ - fun removePin(documentId: String) { - prefs().edit() - .remove("pin_hash_$documentId") - .remove("pin_salt_$documentId") - .remove("pin_set_time_$documentId") - .apply() - DebugLogger.i("SecureDoc", "PIN removed for document ${documentId.take(8)}...") - } - - /** - * Get all document IDs that have PINs set. - */ - fun getAllSecuredDocumentIds(): Set { - return prefs().all.keys - .filter { it.startsWith("pin_hash_") } - .map { it.removePrefix("pin_hash_") } - .toSet() - } - - /** - * Encrypt bytes with a PIN-derived key (AES-256-GCM). - */ - fun encryptWithPin(data: ByteArray, pinKey: ByteArray): ByteArray { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val keySpec = SecretKeySpec(pinKey, "AES") - cipher.init(Cipher.ENCRYPT_MODE, keySpec) - val iv = cipher.iv // GCM generates a random IV - val encrypted = cipher.doFinal(data) - // Prepend IV (12 bytes) to ciphertext - return iv + encrypted - } - - /** - * Decrypt bytes with a PIN-derived key (AES-256-GCM). - */ - fun decryptWithPin(data: ByteArray, pinKey: ByteArray): ByteArray? { - return try { - if (data.size < 12) return null - val iv = data.copyOfRange(0, 12) - val ciphertext = data.copyOfRange(12, data.size) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val keySpec = SecretKeySpec(pinKey, "AES") - val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv) - cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec) - cipher.doFinal(ciphertext) - } catch (e: Exception) { - DebugLogger.e("SecureDoc", "Decryption failed (wrong PIN or corrupted data)", e) - null - } - } - - // --- Internal crypto helpers --- - - private fun deriveKey(pin: String, salt: ByteArray): ByteArray { - val spec = PBEKeySpec(pin.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS) - val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - return factory.generateSecret(spec).encoded - } - - private fun hashPin(pin: String, salt: ByteArray): String { - val key = deriveKey(pin, salt) - val digest = MessageDigest.getInstance("SHA-256") - return digest.digest(key).toHexString() - } - - private fun generateSalt(): ByteArray { - val salt = ByteArray(16) - java.security.SecureRandom().nextBytes(salt) - return salt - } - - private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } - - private fun String.hexToByteArray(): ByteArray { - val len = length - val data = ByteArray(len / 2) - for (i in 0 until len step 2) { - data[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte() - } - return data - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/local/TagColorStore.kt b/app/src/main/java/com/app/traveldocs/data/local/TagColorStore.kt deleted file mode 100644 index 48dde53..0000000 --- a/app/src/main/java/com/app/traveldocs/data/local/TagColorStore.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.app.traveldocs.data.local - -import android.content.Context -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Stores tag-to-color mappings in SharedPreferences. - * - * Colors are stored as ARGB hex integers (e.g., 0xFFE3F2FD). - * Each tag can have its own color for visual differentiation in the UI. - * If no color is set, a default palette color is derived from the tag name hash. - */ -@Singleton -class TagColorStore @Inject constructor( - @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context -) { - companion object { - private const val PREFS = "tag_colors" - - // Predefined palette — visually distinct, accessible on white backgrounds - val PALETTE = listOf( - 0xFFE3F2FD.toInt(), // Light Blue - 0xFFE8F5E9.toInt(), // Light Green - 0xFFFFF3E0.toInt(), // Light Orange - 0xFFFCE4EC.toInt(), // Light Pink - 0xFFEDE7F6.toInt(), // Light Purple - 0xFFFFFDE7.toInt(), // Light Yellow - 0xFFE0F7FA.toInt(), // Light Cyan - 0xFFF3E5F5.toInt(), // Light Lavender - 0xFFEFEBE9.toInt(), // Light Brown - 0xFFE8EAF6.toInt(), // Light Indigo - 0xFFF1F8E9.toInt(), // Lime tint - 0xFFFFF8E1.toInt(), // Amber tint - ) - } - - private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - - /** - * Get the color for a tag. Falls back to a deterministic palette color based on name hash. - */ - fun getColor(tagName: String): Int { - val stored = prefs().getInt("color_$tagName", -1) - if (stored != -1) return stored - // Deterministic fallback from name hash - val idx = (tagName.hashCode().and(0x7FFFFFFF)) % PALETTE.size - return PALETTE[idx] - } - - /** - * Set a specific color for a tag. - */ - fun setColor(tagName: String, color: Int) { - prefs().edit().putInt("color_$tagName", color).apply() - } - - /** - * Remove stored color (revert to auto-derived). - */ - fun clearColor(tagName: String) { - prefs().edit().remove("color_$tagName").apply() - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/local/TravelDocsDatabase.kt b/app/src/main/java/com/app/traveldocs/data/local/TravelDocsDatabase.kt deleted file mode 100644 index 21bf9d0..0000000 --- a/app/src/main/java/com/app/traveldocs/data/local/TravelDocsDatabase.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.app.traveldocs.data.local - -import androidx.room.Database -import androidx.room.RoomDatabase -import com.app.traveldocs.data.local.dao.DocumentDao -import com.app.traveldocs.data.local.dao.DocumentMetadataDao -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.dao.FamilyMemberDao -import com.app.traveldocs.data.local.dao.GpsTrackDao -import com.app.traveldocs.data.local.entity.DocumentEntity -import com.app.traveldocs.data.local.entity.DocumentMetadataEntity -import com.app.traveldocs.data.local.entity.DocumentTagEntity -import com.app.traveldocs.data.local.entity.FamilyMemberEntity -import com.app.traveldocs.data.local.entity.GpsTrackEntity - -@Database( - entities = [ - DocumentEntity::class, - DocumentMetadataEntity::class, - DocumentTagEntity::class, - FamilyMemberEntity::class, - GpsTrackEntity::class - ], - version = 2, - exportSchema = false -) -abstract class TravelDocsDatabase : RoomDatabase() { - abstract fun documentDao(): DocumentDao - abstract fun documentMetadataDao(): DocumentMetadataDao - abstract fun documentTagDao(): DocumentTagDao - abstract fun familyMemberDao(): FamilyMemberDao - abstract fun gpsTrackDao(): GpsTrackDao -} diff --git a/app/src/main/java/com/app/traveldocs/data/local/auth/SecurityAlertService.kt b/app/src/main/java/com/app/traveldocs/data/local/auth/SecurityAlertService.kt deleted file mode 100644 index 3df0a8e..0000000 --- a/app/src/main/java/com/app/traveldocs/data/local/auth/SecurityAlertService.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.app.traveldocs.data.local.auth - -import android.Manifest -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.pm.PackageManager -import android.location.LocationManager -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import com.app.traveldocs.debug.DebugLogger -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Handles security alerts on PIN lockout events. - * Instead of sending SMS (which requires SEND_SMS restricted permission), - * we show a local notification and log the event. The user can view - * the alert history in Settings. - * - * We originally used SMS for alerts but Google Play requires a Permissions Declaration Form - * for SEND_SMS and will reject document-manager apps that request it. Local notifications - * accomplish the same thing without the permission headache. - */ -@Singleton -class SecurityAlertService @Inject constructor( - @dagger.hilt.android.qualifiers.ApplicationContext - private val context: Context, - private val securityPreferences: SecurityPreferences -) { - fun onLockoutTriggered() { - if (!securityPreferences.smsAlertsEnabled || !securityPreferences.isConfigured()) { - DebugLogger.w("SecurityAlert", "Alerts disabled or not configured") - return - } - val timestamp = SimpleDateFormat("HH:mm dd-MMM-yyyy", Locale.US).format(Date()) - val location = getLocationString() - val message = "Document Manager alert: PIN failed 3 times at $timestamp. $location" - showSecurityNotification(message) - securityPreferences.lastAlertTime = System.currentTimeMillis() - DebugLogger.i("SecurityAlert", "Lockout alert dispatched at $timestamp") - } - - private fun showSecurityNotification(message: String) { - try { - val channelId = "security_alerts" - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel(channelId, "Security Alerts", NotificationManager.IMPORTANCE_HIGH) - channel.description = "Alerts when PIN lockout is triggered" - notificationManager.createNotificationChannel(channel) - } - - val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(android.R.drawable.ic_dialog_alert) - .setContentTitle("Security Alert") - .setContentText(message) - .setStyle(NotificationCompat.BigTextStyle().bigText(message)) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .build() - - notificationManager.notify(8888, notification) - DebugLogger.i("SecurityAlert", "Security notification shown") - } catch (e: Exception) { - DebugLogger.e("SecurityAlert", "Failed to show notification", e) - } - } - - @Suppress("MissingPermission") - private fun getLocationString(): String { - return try { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) return "Location: n/a" - val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val loc = lm.getLastKnownLocation(LocationManager.FUSED_PROVIDER) ?: lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) - if (loc != null) "Location: ${loc.latitude},${loc.longitude}" else "Location: n/a" - } catch (_: Exception) { "Location: n/a" } - } -} diff --git a/app/src/main/java/com/app/traveldocs/data/scanner/CameraDocumentScanner.kt b/app/src/main/java/com/app/traveldocs/data/scanner/CameraDocumentScanner.kt deleted file mode 100644 index 0b40ef2..0000000 --- a/app/src/main/java/com/app/traveldocs/data/scanner/CameraDocumentScanner.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.app.traveldocs.data.scanner - -import android.content.Context -import android.content.Intent -import android.net.Uri -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.ImportedDocument -import com.google.mlkit.vision.documentscanner.GmsDocumentScanner -import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions -import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions.RESULT_FORMAT_JPEG -import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions.SCANNER_MODE_FULL -import com.google.mlkit.vision.documentscanner.GmsDocumentScanning -import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.IOException -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Camera-based document scanner that wraps ML Kit's Document Scanner API. - * - * ML Kit Document Scanner provides edge detection, perspective correction, - * and image enhancement automatically. This class configures the scanner - * for single-page document capture in FULL mode and processes results - * into [ImportedDocument] instances. - */ -@Singleton -class CameraDocumentScanner @Inject constructor( - @ApplicationContext private val context: Context -) { - - private val scannerOptions: GmsDocumentScannerOptions = GmsDocumentScannerOptions.Builder() - .setPageLimit(1) - .setGalleryImportAllowed(false) - .setResultFormats(RESULT_FORMAT_JPEG) - .setScannerMode(SCANNER_MODE_FULL) - .build() - - /** - * Creates and returns the ML Kit Document Scanner client configured for - * single-page scanning in FULL mode with JPEG output. - */ - fun getScanner(): GmsDocumentScanner { - return GmsDocumentScanning.getClient(scannerOptions) - } - - /** - * Processes the scanning result from the Activity result callback. - * - * Reads the first scanned page URI and converts it into an [ImportedDocument] - * with raw JPEG bytes. - * - * @param resultCode The Activity result code from the scanner intent - * @param data The Intent data returned from the scanner activity - * @return Result containing the ImportedDocument on success or an error on failure - */ - fun processResult(resultCode: Int, data: Intent?): Result { - return try { - val scanningResult = GmsDocumentScanningResult.fromActivityResultIntent(data) - ?: return Result.failure( - ScannerException("Document scanning was cancelled or returned no result") - ) - - val pages = scanningResult.pages - if (pages.isNullOrEmpty()) { - return Result.failure( - ScannerException("No pages were captured during scanning") - ) - } - - val pageUri = pages[0].imageUri - val rawBytes = readBytesFromUri(pageUri) - - Result.success( - ImportedDocument( - rawBytes = rawBytes, - format = DocumentFormat.JPG, - originalFileName = "scanned_document.jpg" - ) - ) - } catch (e: IOException) { - Result.failure( - ScannerException("Failed to read scanned document: ${e.message}", e) - ) - } catch (e: SecurityException) { - Result.failure( - ScannerException("Permission denied when reading scanned document: ${e.message}", e) - ) - } catch (e: Exception) { - Result.failure( - ScannerException("Unexpected error processing scan result: ${e.message}", e) - ) - } - } - - /** - * Processes a scanning result directly from [GmsDocumentScanningResult]. - * - * @param scanningResult The result object from ML Kit Document Scanner - * @return Result containing the ImportedDocument on success or an error on failure - */ - fun processResult(scanningResult: GmsDocumentScanningResult): Result { - return try { - val pages = scanningResult.pages - if (pages.isNullOrEmpty()) { - return Result.failure( - ScannerException("No pages were captured during scanning") - ) - } - - val pageUri = pages[0].imageUri - val rawBytes = readBytesFromUri(pageUri) - - Result.success( - ImportedDocument( - rawBytes = rawBytes, - format = DocumentFormat.JPG, - originalFileName = "scanned_document.jpg" - ) - ) - } catch (e: IOException) { - Result.failure( - ScannerException("Failed to read scanned document: ${e.message}", e) - ) - } catch (e: SecurityException) { - Result.failure( - ScannerException("Permission denied when reading scanned document: ${e.message}", e) - ) - } catch (e: Exception) { - Result.failure( - ScannerException("Unexpected error processing scan result: ${e.message}", e) - ) - } - } - - /** - * Reads raw bytes from a content URI using the application's content resolver. - */ - internal fun readBytesFromUri(uri: Uri): ByteArray { - return context.contentResolver.openInputStream(uri)?.use { inputStream -> - inputStream.readBytes() - } ?: throw IOException("Unable to open input stream for URI: $uri") - } -} - -/** - * Exception type for document scanning errors, providing descriptive messages - * about what went wrong during the scanning process. - */ -class ScannerException( - message: String, - cause: Throwable? = null -) : Exception(message, cause) diff --git a/app/src/main/java/com/app/traveldocs/data/webserver/DocumentWebServer.kt b/app/src/main/java/com/app/traveldocs/data/webserver/DocumentWebServer.kt deleted file mode 100644 index 162be0d..0000000 --- a/app/src/main/java/com/app/traveldocs/data/webserver/DocumentWebServer.kt +++ /dev/null @@ -1,228 +0,0 @@ -package com.app.traveldocs.data.webserver - -import android.content.Context -import android.net.wifi.WifiManager -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.repository.DocumentFileStorage -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.repository.TagRepository -import fi.iki.elonen.NanoHTTPD -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import java.io.ByteArrayInputStream - -/** - * Embedded HTTP server for WiFi document management. - * - * Serves a full interactive web UI with: - * - Browse documents by tags (folder view) - * - Download files (decrypted, streamed for large files) - * - Upload files (multipart, supports large files via NanoHTTPD temp files) - * - Rename documents inline - * - Add/remove tags (single or bulk) - * - Bulk select + bulk tag assignment - * - Token-based auth (random per session, shown in app) - * - * Large file handling: - * - Downloads: served via NanoHTTPD's chunked response (no full-file buffering for response) - * - Uploads: NanoHTTPD writes multipart data to temp files on disk (not RAM), then we read - */ -class DocumentWebServer( - private val context: Context, - private val documentRepository: DocumentRepository, - private val fileStorage: DocumentFileStorage, - private val tagRepository: TagRepository, - port: Int = 8080 -) : NanoHTTPD(port) { - - companion object { - private const val TAG = "WebServer" - // NanoHTTPD default tmp dir handles large uploads to disk - private const val MAX_REQUEST_SIZE = 200 * 1024 * 1024 // 200MB - } - - val accessToken: String = java.util.UUID.randomUUID().toString().take(8) - - init { - // Increase NanoHTTPD temp file threshold for large uploads - setTempFileManagerFactory { NanoHTTPD.DefaultTempFileManager() } - } - - override fun serve(session: IHTTPSession): Response { - val uri = session.uri - val method = session.method - DebugLogger.d(TAG, "${method.name} $uri") - - // Auth: token required for all endpoints except index (which shows the login form) - val token = session.parms?.get("token") ?: session.headers?.get("x-access-token") - if (token != accessToken && uri != "/" && uri != "/favicon.ico") { - return newFixedLengthResponse(Response.Status.UNAUTHORIZED, "text/html", - "

Access Denied

Include ?token=$accessToken in your request.

") - } - - return try { - when { - uri == "/" || uri == "/index.html" -> serveIndex() - uri == "/api/documents" -> serveDocumentList() - uri.startsWith("/api/download/") -> serveDownload(uri) - uri == "/api/upload" && method == Method.POST -> handleUpload(session) - uri == "/api/rename" && method == Method.POST -> handleRename(session) - uri == "/api/tags" && method == Method.POST -> handleTagUpdate(session) - uri.startsWith("/tag/") -> serveTagFolder(uri) - else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found") - } - } catch (e: Exception) { - DebugLogger.e(TAG, "Error serving $uri", e) - newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "text/plain", "Error: ${e.message}") - } - } - - private fun serveDownload(uri: String): Response { - val docId = uri.removePrefix("/api/download/").substringBefore("?") - val bytes = runBlocking { fileStorage.retrieve(docId).getOrNull() } - ?: return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "File not found") - val doc = runBlocking { documentRepository.getById(docId) } - val fileName = doc?.originalFileName ?: "document" - val mime = when (doc?.format?.name) { - "PDF" -> "application/pdf"; "JPG" -> "image/jpeg"; "PNG" -> "image/png" - "VIDEO" -> "video/mp4"; "WEBP" -> "image/webp"; "GIF" -> "image/gif" - else -> "application/octet-stream" - } - DebugLogger.i(TAG, "Download: $fileName (${bytes.size} bytes)") - // Stream response - NanoHTTPD handles chunked transfer for large files - val response = newFixedLengthResponse(Response.Status.OK, mime, ByteArrayInputStream(bytes), bytes.size.toLong()) - response.addHeader("Content-Disposition", "attachment; filename=\"$fileName\"") - response.addHeader("Cache-Control", "no-cache") - return response - } - - private fun handleUpload(session: IHTTPSession): Response { - // NanoHTTPD writes large uploads to temp files on disk (not held in RAM) - val files = HashMap() - session.parseBody(files) - val tmpFilePath = files["file"] - ?: return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "No file in request") - val tmpFile = java.io.File(tmpFilePath) - if (!tmpFile.exists()) return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Upload failed") - val bytes = tmpFile.readBytes() - val fileName = session.parms["filename"] ?: session.parms["file"] ?: "upload_${System.currentTimeMillis()}" - DebugLogger.i(TAG, "Upload: $fileName (${bytes.size} bytes)") - runBlocking { - val format = com.app.traveldocs.domain.model.DocumentFormat.UNKNOWN - val doc = com.app.traveldocs.domain.model.ImportedDocument(bytes, format, fileName) - com.app.traveldocs.domain.usecase.DocumentImportUseCase::class.java // placeholder - store directly - fileStorage.store("default-member", bytes, format) - } - tmpFile.delete() - return newFixedLengthResponse(Response.Status.OK, "text/html", - "

Uploaded!

$fileName (${bytes.size / 1024} KB)

Back") - } - - private fun handleRename(session: IHTTPSession): Response { - session.parseBody(HashMap()) - val docId = session.parms["id"] ?: return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Missing id") - val newName = session.parms["name"] ?: return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Missing name") - val sanitized = com.app.traveldocs.data.local.InputSanitizer.sanitizeFilename(newName) - DebugLogger.i(TAG, "Rename: $docId -> $sanitized") - runBlocking { - val doc = documentRepository.getById(docId) - if (doc != null) { - val updated = doc.copy(originalFileName = sanitized, updatedAt = java.time.Instant.now()) - documentRepository.delete(docId) - documentRepository.insert(updated) - } - } - return newFixedLengthResponse(Response.Status.OK, "application/json", """{"status":"ok"}""") - } - - private fun handleTagUpdate(session: IHTTPSession): Response { - session.parseBody(HashMap()) - val docId = session.parms["id"] ?: return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Missing id") - val action = session.parms["action"] ?: "add" - val tagName = session.parms["tag"] ?: return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Missing tag") - val sanitized = com.app.traveldocs.data.local.InputSanitizer.sanitizeTag(tagName) - DebugLogger.i(TAG, "Tag $action: $sanitized on $docId") - runBlocking { - if (action == "remove") tagRepository.removeTag(docId, sanitized) - else tagRepository.addTag(docId, sanitized) - } - return newFixedLengthResponse(Response.Status.OK, "application/json", """{"status":"ok"}""") - } - - private fun serveDocumentList(): Response { - val docs = runBlocking { documentRepository.getAll("default-member").first() } - val json = docs.joinToString(",", "[", "]") { doc -> - val tags = doc.tags.filter { !it.name.startsWith("__") }.joinToString(",") { "\"${it.name}\"" } - """{"id":"${doc.id}","name":"${doc.originalFileName ?: "doc"}","format":"${doc.format.name}","tags":[$tags]}""" - } - return newFixedLengthResponse(Response.Status.OK, "application/json", json) - } - - private fun serveIndex(): Response = newFixedLengthResponse(Response.Status.OK, "text/html", buildIndexHtml()) - private fun serveTagFolder(uri: String): Response { - val tag = uri.removePrefix("/tag/").substringBefore("?").replace("%20", " ") - val docs = runBlocking { documentRepository.getAll("default-member").first() } - val filtered = docs.filter { d -> d.tags.any { it.name == tag } } - return newFixedLengthResponse(Response.Status.OK, "text/html", buildTagHtml(tag, filtered)) - } - - fun getServerUrl(): String { - return try { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val ip = wm.connectionInfo.ipAddress - val s = "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}" - "http://$s:${listeningPort}?token=$accessToken" - } catch (e: Exception) { - "http://localhost:${listeningPort}?token=$accessToken" - } - } - - private fun buildIndexHtml(): String { - val docs = runBlocking { documentRepository.getAll("default-member").first() } - val tags = docs.flatMap { it.tags }.map { it.name }.filter { !it.startsWith("__") }.distinct().sorted() - val t = accessToken - val rows = docs.joinToString("") { doc -> - val dtags = doc.tags.filter { !it.name.startsWith("__") }.joinToString("") { "${it.name} x" } - "${doc.originalFileName?:"doc"}${doc.format.name}$dtags +Download " - } - val folders = tags.joinToString("") { "$it
${docs.count{d->d.tags.any{x->x.name==it}}} docs
" } - return """Document Manager - -

Document Manager 🔄

-

${docs.size} documents • Token-protected • Stop from app when done

-

Tags

$folders
-

Documents

-
0 selected
-$rows
NameFormatTagsActions
-

Upload



Supports large files (up to 200MB)

-""" - } - - private fun buildTagHtml(tag: String, docs: List): String { - val t = accessToken - val rows = docs.joinToString("") { doc -> "${doc.originalFileName?:"doc"}${doc.format.name}Download" } - return """$tag - -

← Back

$tag 🔄

${docs.size} documents

-$rows
NameFormatActions
""" - } -} diff --git a/app/src/main/java/com/app/traveldocs/debug/LocationTrackingService.kt b/app/src/main/java/com/app/traveldocs/debug/LocationTrackingService.kt deleted file mode 100644 index 5120ed7..0000000 --- a/app/src/main/java/com/app/traveldocs/debug/LocationTrackingService.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.app.traveldocs.debug - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.location.Location -import android.location.LocationManager -import android.os.Build -import android.os.IBinder -import android.os.Looper -import androidx.core.app.NotificationCompat -import com.app.traveldocs.R -import com.app.traveldocs.presentation.MainActivity -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** - * Foreground service that logs GPS coordinates at a configurable interval. - * Shows a persistent notification so the user is always aware it's running. - * Skips logging if location hasn't changed (< 10m movement). - */ -class LocationTrackingService : Service() { - - companion object { - const val CHANNEL_ID = "location_tracking_channel" - const val NOTIFICATION_ID = 1001 - const val PREFS_NAME = "location_tracking_prefs" - const val KEY_INTERVAL_MS = "interval_ms" - const val DEFAULT_INTERVAL_MS = 60_000L // 1 minute - const val MIN_MOVE_METERS = 10.0f - - const val ACTION_START = "com.app.traveldocs.START_TRACKING" - const val ACTION_STOP = "com.app.traveldocs.STOP_TRACKING" - - fun getInterval(context: Context): Long { - val prefs = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - return prefs.getLong(KEY_INTERVAL_MS, DEFAULT_INTERVAL_MS) - } - - fun setInterval(context: Context, intervalMs: Long) { - val prefs = context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - prefs.edit().putLong(KEY_INTERVAL_MS, intervalMs).apply() - } - - fun start(context: Context) { - context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putBoolean("is_tracking", true).apply() - val intent = Intent(context, LocationTrackingService::class.java).apply { - action = ACTION_START - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } - } - - fun stop(context: Context) { - context.getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putBoolean("is_tracking", false).apply() - val intent = Intent(context, LocationTrackingService::class.java).apply { - action = ACTION_STOP - } - context.stopService(intent) - } - } - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var trackingJob: Job? = null - private var lastLat: Double? = null - private var lastLng: Double? = null - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onCreate() { - super.onCreate() - createNotificationChannel() - DebugLogger.i("LocationService", "Service created") - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // Always call startForeground immediately to avoid ForegroundServiceDidNotStartInTimeException - startForeground(NOTIFICATION_ID, buildNotification()) - when (intent?.action) { - ACTION_STOP -> { - DebugLogger.i("LocationService", "Stop requested") - stopSelf() - return START_NOT_STICKY - } - else -> { - startTracking() - } - } - return START_STICKY - } - - override fun onDestroy() { - trackingJob?.cancel() - scope.cancel() - DebugLogger.i("LocationService", "Service destroyed") - super.onDestroy() - } - - private fun startTracking() { - trackingJob?.cancel() - trackingJob = scope.launch { - DebugLogger.i("LocationService", "Tracking started, interval=${getInterval(this@LocationTrackingService)}ms") - while (true) { - val intervalMs = getInterval(this@LocationTrackingService) - delay(intervalMs) - logLocationIfMoved() - } - } - } - - @Suppress("MissingPermission") - private fun logLocationIfMoved() { - try { - val hasPermission = checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == - android.content.pm.PackageManager.PERMISSION_GRANTED - if (!hasPermission) { - DebugLogger.w("LocationService", "No location permission") - return - } - - val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager - val loc = lm.getLastKnownLocation(LocationManager.FUSED_PROVIDER) - ?: lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) - ?: lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) - - if (loc == null) { - DebugLogger.d("LocationService", "No location fix available") - return - } - - val prevLat = lastLat - val prevLng = lastLng - - if (prevLat != null && prevLng != null) { - val results = FloatArray(1) - Location.distanceBetween(prevLat, prevLng, loc.latitude, loc.longitude, results) - if (results[0] < MIN_MOVE_METERS) { - return // Skip — hasn't moved enough - } - } - - lastLat = loc.latitude - lastLng = loc.longitude - DebugLogger.i("LocationService", "GPS: lat=" + loc.latitude + ", lng=" + loc.longitude + ", accuracy=" + loc.accuracy + "m") - scope.launch { storeTrackPoint(loc.latitude, loc.longitude, loc.accuracy) } - } catch (e: Exception) { - DebugLogger.e("LocationService", "Error getting location", e) - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "Location Tracking", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Logs travel route in background" - setShowBadge(false) - } - val nm = getSystemService(NotificationManager::class.java) - nm.createNotificationChannel(channel) - } - } - - private fun buildNotification(): Notification { - val openIntent = Intent(this, MainActivity::class.java) - val pendingOpen = PendingIntent.getActivity( - this, 0, openIntent, PendingIntent.FLAG_IMMUTABLE - ) - - val stopIntent = Intent(this, LocationTrackingService::class.java).apply { - action = ACTION_STOP - } - val pendingStop = PendingIntent.getService( - this, 1, stopIntent, PendingIntent.FLAG_IMMUTABLE - ) - - val intervalSec = getInterval(this) / 1000 - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Logging travel route") - .setContentText("GPS logged every ${intervalSec}s (only when moving)") - .setSmallIcon(android.R.drawable.ic_menu_mylocation) - .setContentIntent(pendingOpen) - .addAction(android.R.drawable.ic_delete, "Stop", pendingStop) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - // Separate SQLite database for GPS tracks — avoids contention with Room's main DB - private val db by lazy { - val gpsDb = android.database.sqlite.SQLiteDatabase.openOrCreateDatabase(applicationContext.getDatabasePath("gps_tracks.db"), null) - gpsDb.execSQL("CREATE TABLE IF NOT EXISTS gps_tracks (id INTEGER PRIMARY KEY AUTOINCREMENT, latitude REAL NOT NULL, longitude REAL NOT NULL, accuracy REAL NOT NULL, timestamp INTEGER NOT NULL, isMoving INTEGER NOT NULL DEFAULT 0)") - gpsDb - } - - private suspend fun storeTrackPoint(lat: Double, lng: Double, accuracy: Float) { - try { - val entity = com.app.traveldocs.data.local.entity.GpsTrackEntity( - latitude = lat, longitude = lng, accuracy = accuracy, - timestamp = System.currentTimeMillis(), isMoving = true - ) - try { db.execSQL("INSERT INTO gps_tracks (latitude, longitude, accuracy, timestamp, isMoving) VALUES (?, ?, ?, ?, ?)", arrayOf(entity.latitude, entity.longitude, entity.accuracy, entity.timestamp, if (entity.isMoving) 1 else 0)) } catch (_: Exception) {} - } catch (e: Exception) { - DebugLogger.e("LocationService", "Failed to store GPS track", e) - } - }} diff --git a/app/src/main/java/com/app/traveldocs/debug/SystemTelemetry.kt b/app/src/main/java/com/app/traveldocs/debug/SystemTelemetry.kt deleted file mode 100644 index 7c57aa3..0000000 --- a/app/src/main/java/com/app/traveldocs/debug/SystemTelemetry.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.app.traveldocs.debug - -import android.app.ActivityManager -import android.app.usage.UsageStatsManager -import android.content.Context -import android.location.Location -import android.location.LocationListener -import android.location.LocationManager -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.BatteryManager -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Captures system-level telemetry: resource usage, connectivity, GPS location. - * - * - On launch/resume: logs memory, CPU info, top apps, connectivity, GPS - * - Background: wakes every 60s to check GPS, only logs if location changed - */ -class SystemTelemetry(private val context: Context) { - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var locationJob: Job? = null - private var lastLoggedLat: Double? = null - private var lastLoggedLng: Double? = null - private val locationManager by lazy { - context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - } - - companion object { - private const val TAG = "Telemetry" - private const val LOCATION_CHANGE_THRESHOLD_METERS = 10.0 - private const val POLL_INTERVAL_MS = 60_000L - } - - /** - * Call on onCreate/onResume. Logs full system snapshot. - */ - fun captureSnapshot(event: String) { - scope.launch { - DebugLogger.i(TAG, "═══ System Snapshot ($event) ═══") - logMemoryAndCpu() - logTopApps() - logConnectivity() - logCurrentLocation() - } - } - - /** - * Start background GPS polling (every 60s, only logs if moved). - */ - fun startLocationPolling() { - stopLocationPolling() - locationJob = scope.launch { - DebugLogger.d(TAG, "GPS polling started (60s interval)") - while (true) { - delay(POLL_INTERVAL_MS) - logLocationIfChanged() - } - } - } - - /** - * Stop background GPS polling. - */ - fun stopLocationPolling() { - locationJob?.cancel() - locationJob = null - } - - private fun logMemoryAndCpu() { - try { - val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val memInfo = ActivityManager.MemoryInfo() - activityManager.getMemoryInfo(memInfo) - - val totalMb = memInfo.totalMem / (1024 * 1024) - val availMb = memInfo.availMem / (1024 * 1024) - val usedMb = totalMb - availMb - val usedPercent = (usedMb * 100) / totalMb - - DebugLogger.i(TAG, "Memory: ${usedMb}MB / ${totalMb}MB (${usedPercent}% used, avail=${availMb}MB, lowMem=${memInfo.lowMemory})") - - // Battery - val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager - val batteryLevel = batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1 - val isCharging = batteryManager?.isCharging ?: false - DebugLogger.i(TAG, "Battery: ${batteryLevel}% ${if (isCharging) "(charging)" else "(discharging)"}") - - // Runtime memory for our process - val runtime = Runtime.getRuntime() - val appMaxMb = runtime.maxMemory() / (1024 * 1024) - val appTotalMb = runtime.totalMemory() / (1024 * 1024) - val appFreeMb = runtime.freeMemory() / (1024 * 1024) - val appUsedMb = appTotalMb - appFreeMb - DebugLogger.d(TAG, "App heap: used=${appUsedMb}MB, total=${appTotalMb}MB, max=${appMaxMb}MB") - } catch (e: Exception) { - DebugLogger.e(TAG, "Failed to read memory/CPU info", e) - } - } - - private fun logTopApps() { - try { - val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - - // getRunningAppProcesses gives us running processes - val processes = activityManager.runningAppProcesses ?: emptyList() - - // Sort by importance (lower = more important/active) - val top5 = processes - .sortedBy { it.importance } - .take(5) - - DebugLogger.i(TAG, "Top 5 active processes:") - top5.forEachIndexed { idx, proc -> - val importanceLabel = when { - proc.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND -> "FOREGROUND" - proc.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE -> "VISIBLE" - proc.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_SERVICE -> "SERVICE" - else -> "BACKGROUND" - } - DebugLogger.d(TAG, " ${idx + 1}. ${proc.processName} [$importanceLabel] pid=${proc.pid}") - } - } catch (e: Exception) { - DebugLogger.e(TAG, "Failed to get top apps", e) - } - } - - private fun logConnectivity() { - try { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val network = cm.activeNetwork - if (network == null) { - DebugLogger.w(TAG, "Connectivity: NO ACTIVE NETWORK (offline)") - return - } - - val caps = cm.getNetworkCapabilities(network) - if (caps == null) { - DebugLogger.w(TAG, "Connectivity: network present but no capabilities") - return - } - - val type = when { - caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WiFi" - caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Cellular" - caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" - caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" - else -> "Other" - } - - val hasInternet = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - val downMbps = caps.linkDownstreamBandwidthKbps / 1000 - val upMbps = caps.linkUpstreamBandwidthKbps / 1000 - - DebugLogger.i(TAG, "Connectivity: $type | internet=$hasInternet | validated=$validated | down=${downMbps}Mbps | up=${upMbps}Mbps") - } catch (e: Exception) { - DebugLogger.e(TAG, "Failed to read connectivity", e) - } - } - - @Suppress("MissingPermission") - private fun logCurrentLocation() { - try { - if (!hasLocationPermission()) { - DebugLogger.w(TAG, "GPS: Location permission not granted — skipping") - return - } - - val providers = locationManager.getProviders(true) - DebugLogger.d(TAG, "GPS: Available providers: $providers") - - // Try to get last known location from best available provider - val location = getLastKnownLocation() - if (location != null) { - lastLoggedLat = location.latitude - lastLoggedLng = location.longitude - DebugLogger.i(TAG, "GPS: lat=${location.latitude}, lng=${location.longitude}, accuracy=${location.accuracy}m, provider=${location.provider}, age=${getLocationAgeMs(location)}ms") - } else { - DebugLogger.w(TAG, "GPS: No last known location available") - } - } catch (e: SecurityException) { - DebugLogger.w(TAG, "GPS: Permission denied", e) - } catch (e: Exception) { - DebugLogger.e(TAG, "GPS: Failed to read location", e) - } - } - - @Suppress("MissingPermission") - private fun logLocationIfChanged() { - try { - if (!hasLocationPermission()) return - - val location = getLastKnownLocation() ?: return - - val prevLat = lastLoggedLat - val prevLng = lastLoggedLng - - if (prevLat != null && prevLng != null) { - val distance = FloatArray(1) - Location.distanceBetween(prevLat, prevLng, location.latitude, location.longitude, distance) - if (distance[0] < LOCATION_CHANGE_THRESHOLD_METERS) { - // Location hasn't changed enough — skip logging - return - } - } - - lastLoggedLat = location.latitude - lastLoggedLng = location.longitude - DebugLogger.i(TAG, "GPS (periodic): lat=${location.latitude}, lng=${location.longitude}, accuracy=${location.accuracy}m") - } catch (e: Exception) { - // Silently skip on errors during background polling - } - } - - @Suppress("MissingPermission") - private fun getLastKnownLocation(): Location? { - val providers = listOf(LocationManager.FUSED_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) - for (provider in providers) { - try { - val loc = locationManager.getLastKnownLocation(provider) - if (loc != null) return loc - } catch (_: Exception) { - // Try next provider - } - } - return null - } - - private fun hasLocationPermission(): Boolean { - val fine = context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) - val coarse = context.checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) - return fine == android.content.pm.PackageManager.PERMISSION_GRANTED || - coarse == android.content.pm.PackageManager.PERMISSION_GRANTED - } - - private fun getLocationAgeMs(location: Location): Long { - return System.currentTimeMillis() - location.time - } -} diff --git a/app/src/main/java/com/app/traveldocs/debug/TrackingSettingsPanel.kt b/app/src/main/java/com/app/traveldocs/debug/TrackingSettingsPanel.kt deleted file mode 100644 index 51e8fd4..0000000 --- a/app/src/main/java/com/app/traveldocs/debug/TrackingSettingsPanel.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.app.traveldocs.debug - -import android.content.Context -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -data class IntervalOption(val label: String, val ms: Long) - -val intervalOptions = listOf( - IntervalOption("30s", 30_000L), - IntervalOption("1 min", 60_000L), - IntervalOption("5 min", 300_000L), - IntervalOption("15 min", 900_000L), - IntervalOption("30 min", 1_800_000L), -) - -@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) -@Composable -fun TrackingSettingsPanel() { - val context = LocalContext.current - var isTracking by remember { mutableStateOf(isServiceRunning(context)) } - var selectedInterval by remember { mutableLongStateOf(LocationTrackingService.getInterval(context)) } - - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = Color.White), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text("Background GPS Tracking", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - Text( - if (isTracking) "Active — logging when moving" else "Stopped", - fontSize = 11.sp, - color = if (isTracking) Color(0xFF4CAF50) else Color.Gray - ) - } - Switch( - checked = isTracking, - onCheckedChange = { enabled -> - isTracking = enabled - if (enabled) { - LocationTrackingService.start(context) - DebugLogger.i("Settings", "GPS tracking started") - } else { - LocationTrackingService.stop(context) - DebugLogger.i("Settings", "GPS tracking stopped") - } - } - ) - } - - Spacer(Modifier.height(12.dp)) - Text("Log interval:", fontSize = 12.sp, color = Color.Gray) - Spacer(Modifier.height(6.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - intervalOptions.forEach { option -> - FilterChip( - selected = selectedInterval == option.ms, - onClick = { - selectedInterval = option.ms - LocationTrackingService.setInterval(context, option.ms) - DebugLogger.d("Settings", "GPS interval changed to ${option.label}") - if (isTracking) { - // Restart service to pick up new interval - LocationTrackingService.stop(context) - LocationTrackingService.start(context) - } - }, - label = { Text(option.label, fontSize = 11.sp) }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = Color(0xFF1565C0), - selectedLabelColor = Color.White - ) - ) - } - } - } - } -} - -private fun isServiceRunning(context: Context): Boolean { - val prefs = context.getSharedPreferences(LocationTrackingService.PREFS_NAME, Context.MODE_PRIVATE) - // Simple heuristic — if we set tracking on, assume it's running - // A more robust check would use ActivityManager but it's restricted on modern Android - return prefs.getBoolean("is_tracking", false) -} diff --git a/app/src/main/java/com/app/traveldocs/domain/model/DocumentFormat.kt b/app/src/main/java/com/app/traveldocs/domain/model/DocumentFormat.kt deleted file mode 100644 index 5a66ef4..0000000 --- a/app/src/main/java/com/app/traveldocs/domain/model/DocumentFormat.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.app.traveldocs.domain.model - -/** - * File formats we know how to handle. - * - * The core set (PDF, JPG, PNG, VIDEO) is always available. - * Extended formats (WEBP, HEIC, BMP, GIF, DICOM) are behind the "Extended Image Formats" - * experimental feature flag — mostly because HEIC needs API 28+ and DICOM is niche. - */ -enum class DocumentFormat { - PDF, - JPG, - PNG, - VIDEO, - - // Extended formats (experimental) - WEBP, - HEIC, // iOS photos — needs API 28+ - BMP, // Legacy scanner output - GIF, // Rarely useful for docs, but trivial to support - DICOM, // Medical imaging — custom parser, no third-party deps - AUDIO, // MP3, M4A, WAV — playable media - UNKNOWN, // Unsupported format — stored as-is, tagged __UNSUPPORTED -} diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/AutoTagGenerator.kt b/app/src/main/java/com/app/traveldocs/domain/repository/AutoTagGenerator.kt deleted file mode 100644 index 3eab3a5..0000000 --- a/app/src/main/java/com/app/traveldocs/domain/repository/AutoTagGenerator.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.app.traveldocs.domain.repository - -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractedValue -import com.app.traveldocs.domain.model.MetadataField - -interface AutoTagGenerator { - fun generateTags(documentType: DocumentType, metadata: Map): List -} diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentChecklistGenerator.kt b/app/src/main/java/com/app/traveldocs/domain/repository/DocumentChecklistGenerator.kt deleted file mode 100644 index 1d8903e..0000000 --- a/app/src/main/java/com/app/traveldocs/domain/repository/DocumentChecklistGenerator.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.app.traveldocs.domain.repository - -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.MissingDocument -import com.app.traveldocs.domain.model.TravelDocumentChecklist -import com.app.traveldocs.domain.model.TravelParameters - -interface DocumentChecklistGenerator { - fun generateChecklist(params: TravelParameters): TravelDocumentChecklist - fun detectMissing(checklist: TravelDocumentChecklist, existingDocuments: List): List -} diff --git a/app/src/main/java/com/app/traveldocs/domain/repository/NaturalLanguageParser.kt b/app/src/main/java/com/app/traveldocs/domain/repository/NaturalLanguageParser.kt deleted file mode 100644 index a290cec..0000000 --- a/app/src/main/java/com/app/traveldocs/domain/repository/NaturalLanguageParser.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.app.traveldocs.domain.repository - -import com.app.traveldocs.domain.model.ParseResult - -interface NaturalLanguageParser { - fun parse(query: String): ParseResult -} diff --git a/app/src/main/java/com/app/traveldocs/media/AudioPlaybackService.kt b/app/src/main/java/com/app/traveldocs/media/AudioPlaybackService.kt deleted file mode 100644 index 801ed04..0000000 --- a/app/src/main/java/com/app/traveldocs/media/AudioPlaybackService.kt +++ /dev/null @@ -1,228 +0,0 @@ -package com.app.traveldocs.media - -import android.content.Intent -import android.media.MediaPlayer -import android.net.Uri -import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.media.MediaBrowserServiceCompat -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.repository.DocumentFileStorage -import com.app.traveldocs.domain.repository.DocumentRepository -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import java.io.File - -/** - * MediaBrowserService for Android Auto and system media controls. - * - * Exposes audio documents (MP3, M4A, WAV) organized by tags as a browsable media tree. - * Android Auto connects to this service to show audio content and playback controls - * on the car's head unit. - * - * Media tree structure: - * ROOT - * ├── All Audio (flat list) - * └── By Tag - * ├── tag1 (audio files with this tag) - * └── tag2 (audio files with this tag) - */ -class AudioPlaybackService : MediaBrowserServiceCompat() { - - private lateinit var documentRepository: DocumentRepository - private lateinit var fileStorage: DocumentFileStorage - - private lateinit var mediaSession: MediaSessionCompat - private var mediaPlayer: MediaPlayer? = null - private var currentDocId: String? = null - private var playlist: List = emptyList() - private var playlistIndex = 0 - - companion object { - private const val TAG = "AudioService" - private const val ROOT_ID = "root" - private const val ALL_AUDIO_ID = "all_audio" - private const val BY_TAG_ID = "by_tag" - } - - @dagger.hilt.EntryPoint - @dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) - interface ServiceEntryPoint { - fun documentRepository(): DocumentRepository - fun fileStorage(): DocumentFileStorage - } - - override fun onCreate() { - super.onCreate() - val entryPoint = dagger.hilt.android.EntryPointAccessors.fromApplication(applicationContext, ServiceEntryPoint::class.java) - documentRepository = entryPoint.documentRepository() - fileStorage = entryPoint.fileStorage() - mediaSession = MediaSessionCompat(this, "DocumentManagerAudio").apply { - setCallback(mediaSessionCallback) - isActive = true - } - sessionToken = mediaSession.sessionToken - DebugLogger.i(TAG, "AudioPlaybackService created") - } - - override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot { - return BrowserRoot(ROOT_ID, null) - } - - override fun onLoadChildren(parentId: String, result: Result>) { - val items = mutableListOf() - val audioDocs = getAudioDocuments() - - when (parentId) { - ROOT_ID -> { - items.add(createBrowsableItem(ALL_AUDIO_ID, "All Audio", "${audioDocs.size} tracks")) - items.add(createBrowsableItem(BY_TAG_ID, "By Tag", "Organized by tags")) - } - ALL_AUDIO_ID -> { - audioDocs.forEach { doc -> - items.add(createPlayableItem(doc)) - } - } - BY_TAG_ID -> { - val tags = audioDocs.flatMap { it.tags }.map { it.name }.filter { !it.startsWith("__") }.distinct().sorted() - tags.forEach { tag -> - val count = audioDocs.count { d -> d.tags.any { it.name == tag } } - items.add(createBrowsableItem("tag_$tag", tag, "$count tracks")) - } - } - else -> { - if (parentId.startsWith("tag_")) { - val tag = parentId.removePrefix("tag_") - audioDocs.filter { d -> d.tags.any { it.name == tag } }.forEach { doc -> - items.add(createPlayableItem(doc)) - } - } - } - } - result.sendResult(items) - } - - private fun getAudioDocuments(): List { - return runBlocking { - // Only show audio if the experimental feature is enabled - if (!com.app.traveldocs.data.local.FeatureFlags.isAudioPlaybackEnabled(applicationContext)) return@runBlocking emptyList() - documentRepository.getAll("default-member").first() - .filter { it.format == DocumentFormat.AUDIO } - } - } - - private fun createBrowsableItem(id: String, title: String, subtitle: String): MediaBrowserCompat.MediaItem { - val desc = MediaDescriptionCompat.Builder() - .setMediaId(id) - .setTitle(title) - .setSubtitle(subtitle) - .build() - return MediaBrowserCompat.MediaItem(desc, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) - } - - private fun createPlayableItem(doc: Document): MediaBrowserCompat.MediaItem { - val desc = MediaDescriptionCompat.Builder() - .setMediaId(doc.id) - .setTitle(doc.originalFileName ?: "Audio") - .setSubtitle(doc.tags.filter { !it.name.startsWith("__") }.joinToString(", ") { it.name }) - .build() - return MediaBrowserCompat.MediaItem(desc, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - } - - private val mediaSessionCallback = object : MediaSessionCompat.Callback() { - override fun onPlayFromMediaId(mediaId: String, extras: Bundle?) { - DebugLogger.i(TAG, "Play: $mediaId") - playDocument(mediaId) - } - - override fun onPlay() { - mediaPlayer?.start() - updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) - } - - override fun onPause() { - mediaPlayer?.pause() - updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) - } - - override fun onStop() { - mediaPlayer?.stop() - mediaPlayer?.release() - mediaPlayer = null - updatePlaybackState(PlaybackStateCompat.STATE_STOPPED) - } - - override fun onSkipToNext() { - if (playlistIndex < playlist.size - 1) { - playlistIndex++ - playDocument(playlist[playlistIndex].id) - } - } - - override fun onSkipToPrevious() { - if (playlistIndex > 0) { - playlistIndex-- - playDocument(playlist[playlistIndex].id) - } - } - } - - private fun playDocument(docId: String) { - try { - mediaPlayer?.release() - val bytes = runBlocking { fileStorage.retrieve(docId).getOrNull() } ?: return - val doc = runBlocking { documentRepository.getById(docId) } - - // Write to temp file for MediaPlayer - val tmpFile = File(cacheDir, "audio_playback.tmp") - tmpFile.writeBytes(bytes) - - mediaPlayer = MediaPlayer().apply { - setDataSource(tmpFile.absolutePath) - prepare() - start() - setOnCompletionListener { mediaSessionCallback.onSkipToNext() } - } - - currentDocId = docId - updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) - - // Update metadata for lock screen / Android Auto display - val metadata = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, docId) - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, doc?.originalFileName ?: "Audio") - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, mediaPlayer!!.duration.toLong()) - .build() - mediaSession.setMetadata(metadata) - - DebugLogger.i(TAG, "Playing: ${doc?.originalFileName} (${bytes.size / 1024}KB)") - } catch (e: Exception) { - DebugLogger.e(TAG, "Playback failed", e) - } - } - - private fun updatePlaybackState(state: Int) { - val position = mediaPlayer?.currentPosition?.toLong() ?: 0L - val stateBuilder = PlaybackStateCompat.Builder() - .setActions( - PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - ) - .setState(state, position, 1f) - mediaSession.setPlaybackState(stateBuilder.build()) - } - - override fun onDestroy() { - mediaPlayer?.release() - mediaSession.release() - DebugLogger.i(TAG, "AudioPlaybackService destroyed") - super.onDestroy() - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/MainActivity.kt b/app/src/main/java/com/app/traveldocs/presentation/MainActivity.kt deleted file mode 100644 index 23326de..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/MainActivity.kt +++ /dev/null @@ -1,588 +0,0 @@ -package com.app.traveldocs.presentation - -import java.io.File -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.FlightTakeoff -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.LockOpen -import androidx.compose.material.icons.filled.HealthAndSafety -import androidx.compose.material.icons.filled.Hotel -import androidx.compose.material.icons.filled.AccountBox -import androidx.compose.material.icons.filled.AirplanemodeActive -import androidx.compose.material.icons.filled.CreditCard -import androidx.compose.material.icons.filled.InsertDriveFile -import androidx.compose.material.icons.filled.LocalHospital -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Sell -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import androidx.hilt.navigation.compose.hiltViewModel -import com.app.traveldocs.debug.DebugLogScreen -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.debug.SystemTelemetry -import com.app.traveldocs.debug.TempFileCleanup -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.presentation.diagnostics.DiagnosticsScreen -import com.app.traveldocs.presentation.documents.DocumentListScreen -import com.app.traveldocs.presentation.documents.DocumentListViewModel -import com.app.traveldocs.presentation.documents.DocumentViewerScreen -import com.app.traveldocs.presentation.documents.ImportScreen -import com.app.traveldocs.presentation.search.SearchScreen -import com.app.traveldocs.presentation.settings.SettingsScreen -import com.app.traveldocs.presentation.tags.TagManagementScreen -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material.icons.filled.CheckCircle -import com.app.traveldocs.presentation.documents.ImportViewModel -import com.app.traveldocs.BuildConfig -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MainActivity : androidx.fragment.app.FragmentActivity() { - - private lateinit var telemetry: SystemTelemetry - - private val locationPermissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> - if (perms.values.any { it }) { telemetry.captureSnapshot("permission_granted"); telemetry.startLocationPolling() } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - telemetry = SystemTelemetry(applicationContext) - DebugLogger.i("MainActivity", "onCreate") - com.app.traveldocs.debug.UsageTelemetry.resetSession() - telemetry.captureSnapshot("onCreate") - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - locationPermissionRequest.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) - } else { telemetry.startLocationPolling() } - - setContent { - val eulaPrefs = remember { getSharedPreferences("eula_prefs", MODE_PRIVATE) } - val splashPrefs = remember { getSharedPreferences("splash_prefs", MODE_PRIVATE) } - val disclaimerPrefs = remember { getSharedPreferences("disclaimer_prefs", MODE_PRIVATE) } - // Check if launched via share intent (another app shared a doc to us) - val sharedUri = remember { - val action = intent?.action - if (action == android.content.Intent.ACTION_SEND) { - intent?.getParcelableExtra(android.content.Intent.EXTRA_STREAM) - } else if (action == android.content.Intent.ACTION_VIEW) { - intent?.data - } else null - } - - val initialScreen = when { - !eulaPrefs.getBoolean("eula_accepted", false) -> "eula" - !splashPrefs.getBoolean("skip_splash", false) -> "splash" - !disclaimerPrefs.getBoolean("disclaimer_accepted", false) -> "disclaimer" - else -> "pin" - } - var screen by remember { mutableStateOf(initialScreen) } - var showDebugLogs by remember { mutableStateOf(false) } - var selectedDoc by remember { mutableStateOf(null) } - var currentDocList by remember { mutableStateOf>(emptyList()) } - // Dynamic dark/light theme - reads from SharedPreferences, updates immediately - val darkMode = remember { mutableStateOf(getSharedPreferences("app_settings", MODE_PRIVATE).getBoolean("dark_mode", false)) } - val colorScheme = if (darkMode.value) androidx.compose.material3.darkColorScheme() else androidx.compose.material3.lightColorScheme() - MaterialTheme(colorScheme = colorScheme) { - when { - showDebugLogs -> DebugLogScreen(onClose = { showDebugLogs = false }) - screen == "eula" -> com.app.traveldocs.presentation.onboarding.EulaScreen( - onAccepted = { - val prefs = getSharedPreferences("eula_prefs", MODE_PRIVATE) - val timestamp = System.currentTimeMillis() - val location = try { - val lm = getSystemService(LOCATION_SERVICE) as android.location.LocationManager - @Suppress("MissingPermission") - val loc = lm.getLastKnownLocation(android.location.LocationManager.FUSED_PROVIDER) - ?: lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER) - if (loc != null) "${loc.latitude}, ${loc.longitude}" else "Not available" - } catch (_: Exception) { "Not available" } - prefs.edit() - .putBoolean("eula_accepted", true) - .putLong("accepted_timestamp", timestamp) - .putString("accepted_location", location) - .apply() - com.app.traveldocs.debug.DebugLogger.i("EULA", "Accepted at $timestamp, location=$location") - screen = if (!splashPrefs.getBoolean("skip_splash", false)) "splash" - else if (!disclaimerPrefs.getBoolean("disclaimer_accepted", false)) "disclaimer" - else "pin" - }, - onDeclined = { - com.app.traveldocs.debug.DebugLogger.w("EULA", "Declined — closing app") - finishAffinity() - } - ) - screen == "splash" -> com.app.traveldocs.presentation.onboarding.SplashScreen(onContinue = { skipInFuture -> - if (skipInFuture) { getSharedPreferences("splash_prefs", MODE_PRIVATE).edit().putBoolean("skip_splash", true).apply() } - screen = if (!disclaimerPrefs.getBoolean("disclaimer_accepted", false)) "disclaimer" - else "pin" - }) - screen == "disclaimer" -> com.app.traveldocs.presentation.onboarding.DisclaimerScreen(onAccepted = { telemetryConsent -> - getSharedPreferences("disclaimer_prefs", MODE_PRIVATE).edit().putBoolean("disclaimer_accepted", true).putLong("accepted_timestamp", System.currentTimeMillis()).putBoolean("telemetry_consented", telemetryConsent).apply() - com.app.traveldocs.debug.UsageTelemetry.setConsent(telemetryConsent) - screen = "consent" - }) - - screen == "pin" -> { - val fa = this@MainActivity as androidx.fragment.app.FragmentActivity - com.app.traveldocs.presentation.auth.BiometricAuthScreen( - activity = fa, - onAuthenticated = { screen = if (sharedUri != null) "import_shared" else "main" }, - onSkipped = { screen = if (sharedUri != null) "import_shared" else "main" } - ) - } - screen == "import_shared" -> { - // Received a doc from another app — auto-import it - val importVm: com.app.traveldocs.presentation.documents.ImportViewModel = hiltViewModel() - LaunchedEffect(sharedUri) { - if (sharedUri != null) { - com.app.traveldocs.debug.DebugLogger.i("ShareTarget", "Auto-importing shared doc: $sharedUri") - importVm.importFile(sharedUri) - } - } - val sState by importVm.state.collectAsState() - Column(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { - when { - sState.isImporting -> { CircularProgressIndicator(Modifier.size(64.dp)); Spacer(Modifier.height(16.dp)); Text("Importing shared document...") } - sState.importedDocument != null -> { - Icon(Icons.Filled.CheckCircle, null, tint = Color(0xFF4CAF50), modifier = Modifier.size(64.dp)) - Spacer(Modifier.height(16.dp)); Text("Imported!", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(24.dp)); Button(onClick = { importVm.clearState(); screen = "main" }) { Text("Done") } - } - sState.error != null -> { - Text("\u274C", fontSize = 48.sp) - Spacer(Modifier.height(16.dp)); Text("Import Failed", color = Color(0xFFF44336)) - Text(sState.error ?: "", fontSize = 13.sp, color = Color.Gray) - Spacer(Modifier.height(24.dp)); Button(onClick = { screen = "main" }) { Text("Go to Home") } - } - else -> { CircularProgressIndicator(); Text("Preparing...") } - } - } - } - screen == "import" -> ImportScreen(onDone = { screen = "main" }) - screen == "documents" -> DocumentListScreen(onBack = { screen = "main" }, onDocumentClick = { doc -> selectedDoc = doc; screen = "viewer" }, onSetDocList = { currentDocList = it }) - screen == "viewer" && selectedDoc != null -> { - val currentIdx = currentDocList.indexOfFirst { it.id == selectedDoc!!.id } - DocumentViewerScreen( - document = selectedDoc!!, - onBack = { screen = "documents" }, - onPrev = if (currentIdx > 0) { { selectedDoc = currentDocList[currentIdx - 1] } } else null, - onNext = if (currentIdx < currentDocList.size - 1) { { selectedDoc = currentDocList[currentIdx + 1] } } else null - ) - } - screen == "search" -> SearchScreen(onBack = { screen = "main" }, onDocumentClick = { doc -> selectedDoc = doc; screen = "viewer" }) - screen == "tags" -> TagManagementScreen(onBack = { screen = "main" }) - screen == "settings" -> SettingsScreen(onBack = { screen = "main" }) - screen == "about" -> com.app.traveldocs.presentation.about.AboutScreen(onBack = { screen = "main" }) - screen == "eula_view" -> com.app.traveldocs.presentation.onboarding.EulaViewScreen(onBack = { screen = "main" }) - screen == "review" -> com.app.traveldocs.presentation.review.ReviewScreen(onBack = { screen = "main" }) - screen == "webshare" -> com.app.traveldocs.presentation.webshare.WebShareScreen(onBack = { screen = "main" }) - screen == "feedback" -> { - // Launch GitHub Issues in browser for feedback - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/sethusrinivasan/document-manager/issues")) - intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - try { this@MainActivity.startActivity(intent) } catch (_: Exception) {} - screen = "main" - } - screen == "backup" -> com.app.traveldocs.presentation.backup.BackupOnlyScreen(onBack = { screen = "main" }) - screen == "restore" -> com.app.traveldocs.presentation.backup.RestoreOnlyScreen(onBack = { screen = "main" }) - screen == "diagnostics" -> DiagnosticsScreen(onBack = { screen = "main" }, onViewLogs = { showDebugLogs = true }) - else -> MainScreen( - onImport = { com.app.traveldocs.debug.UsageTelemetry.action("Main", "tap_import"); screen = "import" }, - onAllDocs = { com.app.traveldocs.debug.UsageTelemetry.action("Main", "tap_documents"); screen = "documents" }, - onSearch = { com.app.traveldocs.debug.UsageTelemetry.action("Main", "tap_search"); screen = "search" }, - onTags = { screen = "tags" }, - onSettings = { screen = "settings" }, - onDiagnostics = { screen = "diagnostics" }, - onBackup = { screen = "backup" }, - onRestore = { screen = "restore" }, - onAbout = { screen = "about" }, - onFeedback = { screen = "feedback" }, - onReview = { screen = "review" }, - onWebShare = { screen = "webshare" }, - onReset = { resetApp() }, - onDocClick = { doc -> selectedDoc = doc; screen = "viewer" }, - ) - } - } - } - } - - override fun onResume() { super.onResume(); telemetry.captureSnapshot("onResume"); telemetry.startLocationPolling(); Thread { TempFileCleanup.cleanSharedDocs(applicationContext) }.start() } - override fun onPause() { super.onPause(); com.app.traveldocs.debug.UsageTelemetry.emitSessionSummary() } - override fun onDestroy() { super.onDestroy(); telemetry.stopLocationPolling() } - - private fun resetApp(): String { - DebugLogger.w("App", "!!! FACTORY RESET triggered by user") - val report = StringBuilder() - - // Checkpoint WAL before any operations - val dbPath = getDatabasePath("traveldocs.db") - try { - if (dbPath.exists()) { - val flushDb = android.database.sqlite.SQLiteDatabase.openDatabase(dbPath.path, null, android.database.sqlite.SQLiteDatabase.OPEN_READWRITE) - flushDb.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).close() - flushDb.close() - report.appendLine("WAL checkpoint: done") - } - } catch (e: Exception) { report.appendLine("WAL checkpoint: skipped (${e.message})") } - - // Archive old database - if (dbPath.exists()) { - val archiveDir = java.io.File(filesDir, "db_archive") - archiveDir.mkdirs() - for (i in 10 downTo 2) { - val older = java.io.File(archiveDir, "traveldocs.db.${String.format("%03d", i - 1)}") - val newer = java.io.File(archiveDir, "traveldocs.db.${String.format("%03d", i)}") - if (older.exists()) older.renameTo(newer) - } - val archive = java.io.File(archiveDir, "traveldocs.db.001") - dbPath.copyTo(archive, overwrite = true) - report.appendLine("DB archived: ${archive.name} (${archive.length()/1024}KB)") - } - - // Delete database + journals - deleteDatabase("traveldocs.db") - java.io.File(dbPath.path + "-wal").delete() - java.io.File(dbPath.path + "-shm").delete() - report.appendLine("Database deleted: traveldocs.db + WAL/SHM") - - // Delete GPS database - val gpsDb = getDatabasePath("gps_tracks.db") - if (gpsDb.exists()) { gpsDb.delete(); report.appendLine("GPS database deleted") } - - // Clear preferences - val prefsToWipe = listOf("traveldocs_stats", "encryption_consent", "disclaimer_prefs", - "splash_prefs", "eula_prefs", "location_tracking_prefs", "feature_flags", - "app_settings", "tag_colors", "secure_doc_pins", "security_alert_prefs") - prefsToWipe.forEach { getSharedPreferences(it, MODE_PRIVATE).edit().clear().commit() } - report.appendLine("Preferences cleared: ${prefsToWipe.size} stores") - - // Delete document files - val docsDir = java.io.File(filesDir, "docs") - val docCount = if (docsDir.exists()) docsDir.walkTopDown().filter { it.isFile }.count() else 0 - docsDir.deleteRecursively() - report.appendLine("Document files deleted: $docCount files") - - // Delete debug logs - val logsDir = java.io.File(filesDir, "debug_logs") - logsDir.deleteRecursively() - report.appendLine("Debug logs deleted") - - // Delete crash report - java.io.File(filesDir, "last_crash_report.txt").delete() - report.appendLine("Crash report deleted") - - // Delete cache - cacheDir.deleteRecursively() - report.appendLine("Cache cleared") - - report.appendLine("") - report.appendLine("Reset complete. App needs restart to initialize fresh.") - return report.toString() - } -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainScreen( - onImport: () -> Unit, onAllDocs: () -> Unit, onSearch: () -> Unit, - onTags: () -> Unit, onSettings: () -> Unit, onDiagnostics: () -> Unit, onBackup: () -> Unit, onRestore: () -> Unit, onAbout: () -> Unit, onFeedback: () -> Unit, onReview: () -> Unit, onWebShare: () -> Unit, - onDocClick: (Document) -> Unit, onReset: () -> Unit -) { - val viewModel: DocumentListViewModel = hiltViewModel() - val context = androidx.compose.ui.platform.LocalContext.current - val documents by viewModel.documents.collectAsState() - var isRefreshing by remember { mutableStateOf(false) } - val refreshScope = rememberCoroutineScope() - var showGearMenu by remember { mutableStateOf(false) } - var showResetDialog by remember { mutableStateOf(false) } - - var resetStep by remember { mutableIntStateOf(0) } - - if (showResetDialog) { - AlertDialog( - onDismissRequest = { showResetDialog = false; resetStep = 0 }, - icon = { Icon(Icons.Filled.RestartAlt, null, tint = Color(0xFFF44336)) }, - title = { Text(if (resetStep < 2) "Reset App?" else "ARE YOU SURE?") }, - text = { Column { - if (resetStep == 3) { - Text("Reset complete. Details:", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) - Spacer(Modifier.height(8.dp)) - Text("", fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Gray) - Spacer(Modifier.height(12.dp)) - Text("Tap 'Restart App' to start fresh.", fontSize = 13.sp) - } else if (resetStep < 2) { - Text("This will PERMANENTLY DELETE:", fontWeight = FontWeight.Bold, fontSize = 14.sp) - Spacer(Modifier.height(8.dp)) - Text("• All imported documents (encrypted files)", fontSize = 13.sp) - Text("• All tags and metadata", fontSize = 13.sp) - Text("• All settings and preferences", fontSize = 13.sp) - Text("• EULA acceptance (will show again)", fontSize = 13.sp) - Text("• Debug logs and telemetry", fontSize = 13.sp) - Text("• PIN-protected documents (UNRECOVERABLE)", fontSize = 13.sp, color = Color(0xFFF44336)) - Spacer(Modifier.height(12.dp)) - Text("This CANNOT be undone. The app will restart as freshly installed.", fontWeight = FontWeight.Bold, color = Color(0xFFF44336), fontSize = 13.sp) - } else { - Text("Last chance. Tap 'Delete Everything' to proceed.", fontSize = 14.sp, color = Color(0xFFF44336)) - Spacer(Modifier.height(8.dp)) - Text("All data will be permanently erased.", fontSize = 13.sp) - } - } }, - confirmButton = { - if (resetStep < 2) { - TextButton(onClick = { resetStep = 2 }) { Text("I understand, continue", color = Color(0xFFF44336)) } - } else if (resetStep == 2) { - TextButton(onClick = { onReset(); resetStep = 3 }) { Text("DELETE EVERYTHING", color = Color(0xFFF44336), fontWeight = FontWeight.Bold) } - } else { - TextButton(onClick = { android.os.Process.killProcess(android.os.Process.myPid()) }) { Text("Restart App") } - } - }, - dismissButton = { TextButton(onClick = { showResetDialog = false; resetStep = 0 }) { Text("Cancel") } } - ) - } - - Scaffold( - topBar = { - TopAppBar( - title = { val customTitle = remember { context.getSharedPreferences("app_settings", 0).getString("home_title", "My Private Documents") ?: "My Private Documents" }; Text(customTitle) }, - actions = { - IconButton(onClick = { isRefreshing = true; viewModel.forceRefresh(); refreshScope.launch { kotlinx.coroutines.delay(800); isRefreshing = false } }) { Icon(Icons.Filled.Refresh, "Refresh") } - Box { - IconButton(onClick = { showGearMenu = true }) { Icon(Icons.Filled.Settings, "More") } - DropdownMenu(expanded = showGearMenu, onDismissRequest = { showGearMenu = false }) { - DropdownMenuItem(text = { Text("About") }, onClick = { showGearMenu = false; onAbout() }) - DropdownMenuItem(text = { Text("Feedback") }, onClick = { showGearMenu = false; onFeedback() }) - if (com.app.traveldocs.data.local.FeatureFlags.isExperimentalEnabled(context) && com.app.traveldocs.data.local.FeatureFlags.isBackupRestoreEnabled(context)) DropdownMenuItem(text = { Text("Backup (Preview)") }, onClick = { showGearMenu = false; onBackup() }) - if (com.app.traveldocs.data.local.FeatureFlags.isExperimentalEnabled(context)) DropdownMenuItem(text = { Text("Diagnostics") }, onClick = { showGearMenu = false; onDiagnostics() }) - if (com.app.traveldocs.data.local.FeatureFlags.isExperimentalEnabled(context) && com.app.traveldocs.data.local.FeatureFlags.isBackupRestoreEnabled(context)) DropdownMenuItem(text = { Text("Restore (Preview)") }, onClick = { showGearMenu = false; onRestore() }) - DropdownMenuItem(text = { Text("Review & Classify") }, onClick = { showGearMenu = false; onReview() }) - if (com.app.traveldocs.data.local.FeatureFlags.isExperimentalEnabled(context) && com.app.traveldocs.data.local.FeatureFlags.isWifiShareEnabled(context)) DropdownMenuItem(text = { Text("WiFi Share") }, onClick = { showGearMenu = false; onWebShare() }) - DropdownMenuItem(text = { Text("Settings") }, onClick = { showGearMenu = false; onSettings() }) - DropdownMenuItem(text = { Text("Tags") }, onClick = { showGearMenu = false; onTags() }) - DropdownMenuItem(text = { Text("Reset App", color = Color(0xFFF44336)) }, onClick = { showGearMenu = false; showResetDialog = true }) - } - } } - ) - } - ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { - // Pull to refresh indicator - if (isRefreshing) { androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) } - androidx.compose.material3.TextButton(onClick = { isRefreshing = true; refreshScope.launch { delay(1000); isRefreshing = false } }, modifier = Modifier.fillMaxWidth().height(4.dp)) {} // Action buttons row - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onImport, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1565C0))) { - Icon(Icons.Filled.Add, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Import", fontSize = 13.sp) - } - OutlinedButton(onClick = onAllDocs, modifier = Modifier.weight(1f)) { - Icon(Icons.Filled.Folder, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("All Docs", fontSize = 13.sp) - } - OutlinedButton(onClick = onSearch, modifier = Modifier.weight(1f)) { - Icon(Icons.Filled.Search, null, Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Search", fontSize = 13.sp) - } - } - - // Document count - - // Folder view by tags - // Filter out the internal __PIN_PROTECTED tag from visible folders - val allTags = documents.flatMap { it.tags }.map { it.name }.filter { it != "__PIN_PROTECTED" }.distinct().sorted() - val untaggedDocs = documents.filter { it.tags.isEmpty() || it.tags.all { t -> t.name == "__PIN_PROTECTED" } } - val protectedDocs = documents.filter { doc -> doc.tags.any { it.name == "__PIN_PROTECTED" } } - var selectedTag by remember { mutableStateOf(null) } - - if (selectedTag != null) { - // Show documents in selected folder - val folderDocs = when (selectedTag) { - "__untagged__" -> untaggedDocs - "__protected__" -> protectedDocs - else -> documents.filter { doc -> doc.tags.any { it.name == selectedTag } } - } - val folderTitle = when (selectedTag) { - "__untagged__" -> "Untagged" - "__protected__" -> "Protected" - else -> selectedTag!! - } - Row(Modifier.fillMaxWidth().padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { selectedTag = null }) { Icon(Icons.Filled.ArrowBack, "Back to folders") } - Text(folderTitle, fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(Modifier.weight(1f)) - Text("${folderDocs.size} docs", fontSize = 12.sp, color = Color.Gray) - } - LazyColumn { - items(folderDocs.take(50)) { doc -> - DocCard(doc = doc, onClick = { onDocClick(doc) }) - Spacer(Modifier.height(6.dp)) - } - } - } else { - // Show folder grid - Text("Organized by tags", fontWeight = FontWeight.Bold, fontSize = 16.sp, modifier = Modifier.padding(bottom = 8.dp)) - val columns = 3 - val folders = (if (protectedDocs.isNotEmpty()) listOf("__protected__") else emptyList()) + allTags + (if (untaggedDocs.isNotEmpty()) listOf("__untagged__") else emptyList()) - if (folders.isEmpty()) { - Column(Modifier.fillMaxWidth().padding(vertical = 40.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Filled.Description, null, Modifier.size(64.dp), tint = Color.LightGray) - Spacer(Modifier.height(12.dp)) - Text("No documents yet", fontSize = 16.sp, color = Color.Gray) - Text("Tap Import to add your first document", fontSize = 13.sp, color = Color.LightGray) - } - } else { - LazyColumn { - items(folders.chunked(columns).size) { rowIdx -> - val rowFolders = folders.chunked(columns)[rowIdx] - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - rowFolders.forEach { tag -> - val label = when (tag) { "__untagged__" -> "Untagged"; "__protected__" -> "Protected"; else -> tag } - val count = when (tag) { "__untagged__" -> untaggedDocs.size; "__protected__" -> protectedDocs.size; else -> documents.count { d -> d.tags.any { it.name == tag } } } - val isProtected = tag == "__protected__" - val isUntagged = tag == "__untagged__" - Card( - Modifier.weight(1f).clickable { selectedTag = tag }, - shape = RoundedCornerShape(12.dp), - elevation = CardDefaults.cardElevation(1.dp), - colors = if (isProtected) CardDefaults.cardColors(containerColor = Color(0xFFFFF3E0)) else CardDefaults.cardColors() - ) { - Column(Modifier.padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { - val folderIcon = when { - isProtected -> Icons.Filled.Lock - isUntagged -> Icons.Filled.Folder - else -> Icons.Filled.Folder - } - val folderTint = when { - isProtected -> Color(0xFFE65100) - isUntagged -> Color.Gray - else -> Color(0xFF1565C0) - } - Icon(folderIcon, label, tint = folderTint, modifier = Modifier.size(32.dp)) - Spacer(Modifier.height(4.dp)) - Text(label, fontSize = 12.sp, fontWeight = FontWeight.Medium, maxLines = 1) - Text("$count", fontSize = 11.sp, color = Color.Gray) - } - } - } - repeat(columns - rowFolders.size) { Spacer(Modifier.weight(1f)) } - } - Spacer(Modifier.height(8.dp)) - } - } - } - } } - } -} - -@Composable -private fun DocCard(doc: Document, onClick: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), shape = RoundedCornerShape(10.dp), elevation = CardDefaults.cardElevation(1.dp)) { - Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(iconFor(doc.type), doc.type.name, tint = colorFor(doc.type), modifier = Modifier.size(36.dp)) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text(doc.originalFileName ?: "Document", fontWeight = FontWeight.Medium, fontSize = 14.sp, maxLines = 1) - Text(doc.type.name, fontSize = 12.sp, color = Color.Gray) - if (doc.tags.isNotEmpty()) Text(doc.tags.joinToString(", ") { it.name }, fontSize = 11.sp, color = Color(0xFF1565C0), maxLines = 1) - } - Text("${((doc.extractionConfidence ?: 0f) * 100).toInt()}%", fontSize = 11.sp, color = Color.Gray) - } - } -} - -private fun iconFor(type: DocumentType): ImageVector = when (type) { - DocumentType.PASSPORT -> Icons.Filled.AccountBox - DocumentType.VISA -> Icons.Filled.CreditCard - DocumentType.TICKET -> Icons.Filled.AirplanemodeActive - DocumentType.HOTEL_BOOKING -> Icons.Filled.Hotel - DocumentType.HEALTH_INSURANCE -> Icons.Filled.LocalHospital - DocumentType.UNKNOWN -> Icons.Filled.InsertDriveFile -} - -private fun colorFor(type: DocumentType): Color = when (type) { - DocumentType.PASSPORT -> Color(0xFF1565C0) - DocumentType.VISA -> Color(0xFF4CAF50) - DocumentType.TICKET -> Color(0xFFFF9800) - DocumentType.HOTEL_BOOKING -> Color(0xFF9C27B0) - DocumentType.HEALTH_INSURANCE -> Color(0xFFF44336) - DocumentType.UNKNOWN -> Color.Gray -} - -private fun tagIcon(tag: String): androidx.compose.ui.graphics.vector.ImageVector { - return when (tag.lowercase()) { - "passport" -> androidx.compose.material.icons.Icons.Filled.AccountBox - "visa" -> androidx.compose.material.icons.Icons.Filled.CreditCard - "ticket", "flight" -> androidx.compose.material.icons.Icons.Filled.AirplanemodeActive - "accommodation", "hotel" -> androidx.compose.material.icons.Icons.Filled.Hotel - "health", "insurance" -> androidx.compose.material.icons.Icons.Filled.LocalHospital - "travel", "trip" -> androidx.compose.material.icons.Icons.Filled.FlightTakeoff - "family" -> androidx.compose.material.icons.Icons.Filled.Public - "business", "work" -> androidx.compose.material.icons.Icons.Filled.Description - else -> androidx.compose.material.icons.Icons.Filled.Sell - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/about/AboutScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/about/AboutScreen.kt deleted file mode 100644 index cc2fd3f..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/about/AboutScreen.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.app.traveldocs.presentation.about - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.sp - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AboutScreen(onBack: () -> Unit) { - Scaffold(topBar = { TopAppBar(title = { Text("About") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> - Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { - Text("Document Manager", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) - Text("Version 1.0", fontSize = 14.sp, color = Color.Gray) - Spacer(Modifier.height(16.dp)) - - Text("A secure, offline-first Android app for families to store, organize, and manage travel documents with OCR-based metadata extraction.", fontSize = 13.sp) - Spacer(Modifier.height(20.dp)) - - SectionTitle("Security") - InfoRow("File Encryption", "AES-256-GCM (Android KeyStore)") - InfoRow("Authentication", "Device Biometrics (fingerprint/face/PIN)") - InfoRow("Backup", "Encrypted ZIP (no plaintext leaves device)") - Spacer(Modifier.height(16.dp)) - - SectionTitle("Bill of Materials") - InfoRow("UI Framework", "Jetpack Compose + Material 3") - InfoRow("Language", "Kotlin 1.9.22") - InfoRow("DI", "Hilt (Dagger) 2.50") - InfoRow("Database", "Room 2.6.1") - InfoRow("OCR Engine", "ML Kit Text Recognition 16.0.0") - InfoRow("Document Scanner", "ML Kit Document Scanner 16.0.0-beta1") - InfoRow("Crypto", "BouncyCastle 1.77 (Argon2id, HKDF)") - InfoRow("Biometric", "AndroidX Biometric 1.1.0") - InfoRow("Drive API", "Google API Services Drive v3") - InfoRow("Build System", "Gradle 8.5, AGP 8.2.2, KSP 1.9.22") - InfoRow("Testing", "JUnit 5, Kotest 5.8.0, MockK 1.13.9") - Spacer(Modifier.height(16.dp)) - - SectionTitle("Acknowledgments") - Text("This app uses the following open-source libraries, all under Apache 2.0 or compatible licenses:", fontSize = 12.sp, color = Color.Gray) - Spacer(Modifier.height(6.dp)) - AckRow("Jetpack Compose", "Google", "Apache 2.0") - AckRow("ML Kit", "Google", "Free, no API key") - AckRow("BouncyCastle", "Legion of the Bouncy Castle", "MIT-like") - AckRow("Room", "Google (AndroidX)", "Apache 2.0") - AckRow("Hilt/Dagger", "Google", "Apache 2.0") - AckRow("Kotest", "Kotest Team", "Apache 2.0") - AckRow("MockK", "MockK Contributors", "Apache 2.0") - AckRow("Material Icons", "Google Fonts", "Apache 2.0") - Spacer(Modifier.height(16.dp)) - - SectionTitle("License") - Text("Apache License 2.0", fontSize = 13.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(4.dp)) - Text("Copyright 2026 Document Manager Contributors. Licensed under the Apache License, Version 2.0. You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0", fontSize = 11.sp, color = Color.Gray) - Spacer(Modifier.height(8.dp)) - val ctx = LocalContext.current - OutlinedButton(onClick = { val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/sethusrinivasan/document-manager/blob/main/LICENSE")); ctx.startActivity(intent) }, modifier = Modifier.fillMaxWidth()) { Text("View Full License Agreement", fontSize = 13.sp) } - Spacer(Modifier.height(16.dp)) - - SectionTitle("Permissions Required") - Text("This app requests the following permissions. All are optional and requested only when the feature is used:", fontSize = 12.sp, color = Color.Gray) - Spacer(Modifier.height(8.dp)) - PermRow("Camera", "Scan documents using phone camera (Take Photo)") - PermRow("Location (Fine/Coarse)", "Tag documents with GPS coordinates on import; optional background GPS tracking") - PermRow("Internet", "Cloud backup (Google Drive, S3); Import from URL; WiFi sharing") - PermRow("Notifications", "GPS tracking foreground service indicator; crash report notification") - PermRow("Foreground Service", "Background GPS location logging (when user enables it in Settings)") - Spacer(Modifier.height(4.dp)) - Text("Denying any permission does not break core functionality. Documents can still be imported, viewed, searched, and shared without any permissions.", fontSize = 11.sp, color = Color.Gray, fontStyle = androidx.compose.ui.text.font.FontStyle.Italic) - Spacer(Modifier.height(16.dp)) - - SectionTitle("Android Auto (Experimental)") - Text("This app supports Android Auto for audio playback (experimental, enable in Settings). When connected to a car head unit:", fontSize = 13.sp) - Spacer(Modifier.height(6.dp)) - InfoRow("Browsing", "Audio files organized by tags appear as folders") - InfoRow("Playback", "Play, pause, skip next/previous via car controls") - InfoRow("Display", "Track name and tags shown on head unit screen") - InfoRow("Requirement", "Import MP3/audio files and they auto-tag as playable media") - Spacer(Modifier.height(8.dp)) - Text("Audio files are decrypted on-the-fly for playback. Only files tagged as audio appear in Android Auto.", fontSize = 11.sp, color = Color.Gray) - Spacer(Modifier.height(24.dp)) - } - } -} - -@Composable -private fun SectionTitle(text: String) { - Text(text, fontWeight = FontWeight.Bold, fontSize = 15.sp) - Spacer(Modifier.height(6.dp)) -} - -@Composable -private fun InfoRow(label: String, value: String) { - Card(Modifier.fillMaxWidth().padding(vertical = 2.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5))) { - Column(Modifier.padding(10.dp)) { - Text(label, fontSize = 12.sp, color = Color.Gray) - Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium) - } - } -} - -@Composable -private fun AckRow(name: String, author: String, license: String) { - Text("• $name — $author ($license)", fontSize = 12.sp, color = Color(0xFF424242), modifier = Modifier.padding(vertical = 1.dp)) -} - - -@Composable -private fun PermRow(permission: String, purpose: String) { - Card(Modifier.fillMaxWidth().padding(vertical = 2.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF8E1))) { - Row(Modifier.padding(10.dp)) { - Text(permission, fontWeight = FontWeight.Medium, fontSize = 12.sp, modifier = Modifier.width(120.dp)) - Text(purpose, fontSize = 12.sp, color = Color(0xFF616161)) - } - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/auth/PinEntryScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/auth/PinEntryScreen.kt deleted file mode 100644 index c0c2c46..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/auth/PinEntryScreen.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.app.traveldocs.presentation.auth - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@Composable -fun PinEntryScreen(onPinSubmit: (String) -> Unit, error: String? = null, isLocked: Boolean = false, lockSeconds: Int = 0) { - var pin by remember { mutableStateOf("") } - - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon(Icons.Filled.Lock, null, modifier = Modifier.size(64.dp), tint = Color(0xFF1565C0)) - Spacer(Modifier.height(24.dp)) - Text("Enter PIN", fontSize = 22.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - Text("Unlock your documents", fontSize = 14.sp, color = Color.Gray) - Spacer(Modifier.height(32.dp)) - - if (isLocked) { - Text("Account locked", color = Color.Red, fontWeight = FontWeight.Bold) - Text("Try again in ${lockSeconds}s", color = Color.Gray, fontSize = 13.sp) - Spacer(Modifier.height(16.dp)) - } - - OutlinedTextField( - value = pin, - onValueChange = { if (it.length <= 8) pin = it.filter { c -> c.isDigit() } }, - label = { Text("PIN") }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - enabled = !isLocked, - modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp) - ) - - if (error != null) { - Spacer(Modifier.height(8.dp)) - Text(error, color = Color.Red, fontSize = 13.sp) - } - - Spacer(Modifier.height(24.dp)) - Button(onClick = { onPinSubmit(pin); pin = "" }, enabled = pin.length >= 4 && !isLocked, modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)) { - Text("Unlock") - } - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/backup/BackupScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/backup/BackupScreen.kt deleted file mode 100644 index 2f2d691..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/backup/BackupScreen.kt +++ /dev/null @@ -1,410 +0,0 @@ -package com.app.traveldocs.presentation.backup - -import android.app.Activity -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.RestoreFromTrash -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.documentfile.provider.DocumentFile -import com.app.traveldocs.data.backup.BackupManager -import com.app.traveldocs.data.backup.BackupRestore -import com.app.traveldocs.data.backup.S3BackupUploader -import com.app.traveldocs.data.backup.S3Config -import com.app.traveldocs.data.drive.GoogleDriveServiceProvider -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.debug.UsageTelemetry -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInOptions -import com.google.android.gms.common.api.Scope -import com.google.api.client.http.FileContent -import com.google.api.services.drive.DriveScopes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import com.google.api.services.drive.model.File as DriveFile - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BackupScreen(onBack: () -> Unit, mode: String = "backup") { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val backupManager = remember { BackupManager() } - var screenState by remember { mutableStateOf(if (mode == "restore") "restore_pick" else "choose") } - var resultMessage by remember { mutableStateOf("") } - var resultIsError by remember { mutableStateOf(false) } - var showS3Dialog by remember { mutableStateOf(false) } - var showPinDialog by remember { mutableStateOf(false) } - var backupPin by remember { mutableStateOf("") } - var pendingBackupUri by remember { mutableStateOf(null) } - var pendingRestoreUri by remember { mutableStateOf(null) } - var showRestorePinDialog by remember { mutableStateOf(false) } - var restorePin by remember { mutableStateOf("") } - val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> - if (uri != null) { - pendingBackupUri = uri; showPinDialog = true } - } - - val restorePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - if (uri != null) { - pendingRestoreUri = uri - showRestorePinDialog = true - } - } - - // Restore PIN dialog - shown before restore to ask for backup password - if (showRestorePinDialog && pendingRestoreUri != null) { - androidx.compose.material3.AlertDialog( - onDismissRequest = { showRestorePinDialog = false; pendingRestoreUri = null }, - title = { androidx.compose.material3.Text("Backup Password") }, - text = { Column { - androidx.compose.material3.Text("Enter the backup password to restore.", fontSize = 13.sp, color = Color.Gray) - Spacer(Modifier.height(4.dp)) - androidx.compose.material3.Text("All backups are password-protected. This is required.", fontSize = 12.sp, color = Color(0xFFF44336)) - Spacer(Modifier.height(12.dp)) - androidx.compose.material3.OutlinedTextField(value = restorePin, onValueChange = { restorePin = it }, label = { androidx.compose.material3.Text("Backup Password (required)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), - visualTransformation = if (restorePin.isNotEmpty()) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None) - } }, - confirmButton = { androidx.compose.material3.TextButton(onClick = { - showRestorePinDialog = false - val pin = restorePin - val uri = pendingRestoreUri!! - restorePin = "" - pendingRestoreUri = null - screenState = "running" - scope.launch { doRestore(context, uri, pin) { msg, err -> resultMessage = msg; resultIsError = err; screenState = "done" } } - }, enabled = restorePin.isNotBlank()) { androidx.compose.material3.Text("Restore") } }, - dismissButton = { androidx.compose.material3.TextButton(onClick = { showRestorePinDialog = false; pendingRestoreUri = null; restorePin = "" }) { androidx.compose.material3.Text("Cancel") } } - ) - } - - val driveSignIn = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - screenState = "running" - scope.launch { driveBackup(context, backupManager) { msg, err -> resultMessage = msg; resultIsError = err; screenState = "done" } } - } else { resultMessage = "Google Sign-In cancelled"; resultIsError = true; screenState = "done" } - } - - - // Backup PIN dialog - if (showPinDialog) { - var pinInput by remember { mutableStateOf("") } - AlertDialog(onDismissRequest = { showPinDialog = false }, title = { Text("Set Backup PIN") }, text = { - Column { - Text("This PIN protects your backup. You will need it to restore on any device.", fontSize = 12.sp, color = Color.Gray) - Spacer(Modifier.height(8.dp)) - OutlinedTextField(value = pinInput, onValueChange = { if (it.length <= 8) pinInput = it.filter { c -> c.isDigit() } }, label = { Text("PIN (4-8 digits)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) - } - }, confirmButton = { TextButton(onClick = { - backupPin = pinInput; showPinDialog = false; screenState = "running" - val uri = pendingBackupUri - if (uri != null) scope.launch { localBackup(context, backupManager, uri, backupPin) { msg, err -> resultMessage = msg; resultIsError = err; screenState = "done" } } - }, enabled = pinInput.length >= 4) { Text("Create Backup") } }, dismissButton = { TextButton(onClick = { showPinDialog = false }) { Text("Cancel") } }) - } // S3 config dialog - if (showS3Dialog) { - var isEmulator by remember { mutableStateOf(true) } - var usePresigned by remember { mutableStateOf(false) } - var presignedUrl by remember { mutableStateOf("") } - var endpoint by remember { mutableStateOf("http://10.0.0.126:9000") } - var bucket by remember { mutableStateOf("test-backup-bucket") } - var accessKey by remember { mutableStateOf("testkey") } - var secretKey by remember { mutableStateOf("testsecret") } - var region by remember { mutableStateOf("us-east-1") } - AlertDialog(onDismissRequest = { showS3Dialog = false }, title = { Text("S3 Configuration") }, text = { - Column { - Text("Target", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - androidx.compose.material3.RadioButton(selected = isEmulator, onClick = { - isEmulator = true - endpoint = "http://10.0.0.126:9000"; bucket = "test-backup-bucket"; accessKey = "testkey"; secretKey = "testsecret"; region = "us-east-1" - }) - Text("Local Emulator", fontSize = 13.sp, modifier = Modifier.weight(1f)) - androidx.compose.material3.RadioButton(selected = !isEmulator, onClick = { - isEmulator = false - endpoint = "https://s3.amazonaws.com"; bucket = ""; accessKey = ""; secretKey = "" - }) - Text("AWS S3", fontSize = 13.sp) - } - if (isEmulator) { Text("Auto-configured for emulator at 10.0.0.126:9000", fontSize = 10.sp, color = Color(0xFF4CAF50)) } - Spacer(Modifier.height(8.dp)) - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text("Use Presigned URL", modifier = Modifier.weight(1f), fontSize = 13.sp) - androidx.compose.material3.Switch(checked = usePresigned, onCheckedChange = { usePresigned = it }) - } - Spacer(Modifier.height(6.dp)) - if (usePresigned) { - Text("Paste a presigned PUT URL.", fontSize = 11.sp, color = Color.Gray) - OutlinedTextField(value = presignedUrl, onValueChange = { presignedUrl = it }, label = { Text("Presigned URL") }, singleLine = false, maxLines = 3, modifier = Modifier.fillMaxWidth()) - } else { - OutlinedTextField(value = endpoint, onValueChange = { endpoint = it }, label = { Text("Endpoint") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(4.dp)) - OutlinedTextField(value = bucket, onValueChange = { bucket = it }, label = { Text("Bucket") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(4.dp)) - OutlinedTextField(value = region, onValueChange = { region = it }, label = { Text("Region") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(4.dp)) - OutlinedTextField(value = accessKey, onValueChange = { accessKey = it }, label = { Text("Access Key") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(4.dp)) - OutlinedTextField(value = secretKey, onValueChange = { secretKey = it }, label = { Text("Secret Key") }, singleLine = true, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) - } - } - }, confirmButton = { TextButton(onClick = { - showS3Dialog = false; screenState = "running" - if (usePresigned) { - scope.launch { presignedUpload(context, backupManager, presignedUrl.trim()) { msg, err -> resultMessage = msg; resultIsError = err; screenState = "done" } } - } else { - val cfg = S3Config(endpoint.trim(), bucket.trim(), accessKey.trim(), secretKey.trim(), region.trim()) - scope.launch { s3Backup(context, backupManager, cfg) { msg, err -> resultMessage = msg; resultIsError = err; screenState = "done" } } - } - }) { Text("Upload") } }, dismissButton = { TextButton(onClick = { showS3Dialog = false }) { Text("Cancel") } }) - } - Scaffold(topBar = { TopAppBar(title = { Text(if (mode == "restore") "Restore" else "Backup") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> - Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { - when (screenState) { - "choose" -> { - Text("Backup", fontSize = 18.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(6.dp)) - Text("Encrypted files packaged into ZIP. No plaintext leaves device.", fontSize = 12.sp, color = Color.Gray) - Spacer(Modifier.height(12.dp)) - OptionCard(Icons.Filled.Folder, "Local Folder", "Save to phone storage", Color(0xFF4CAF50)) { folderPicker.launch(null) } - Spacer(Modifier.height(8.dp)) - if (com.app.traveldocs.data.local.FeatureFlags.isGoogleDriveEnabled(context)) OptionCard(Icons.Filled.Cloud, "Google Drive", "Sign in and upload to Drive", Color(0xFF1565C0)) { - val account = GoogleSignIn.getLastSignedInAccount(context) - if (account != null) { screenState = "running"; scope.launch { driveBackup(context, backupManager) { msg, err -> resultMessage = msg; resultIsError = err; screenState = "done" } } } - else { - val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).requestEmail().requestScopes(Scope(DriveScopes.DRIVE_FILE)).build() - driveSignIn.launch(GoogleSignIn.getClient(context, gso).signInIntent) - } - } - Spacer(Modifier.height(8.dp)) - if (com.app.traveldocs.data.local.FeatureFlags.isS3Enabled(context)) OptionCard(Icons.Filled.Storage, "S3 Compatible", "AWS S3, MinIO, Backblaze B2", Color(0xFFFF9800)) { showS3Dialog = true } - Spacer(Modifier.height(24.dp)) - Text("Restore", fontSize = 18.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(6.dp)) - Text("Select a backup ZIP to restore encrypted documents.", fontSize = 12.sp, color = Color.Gray) - Spacer(Modifier.height(12.dp)) - OptionCard(Icons.Filled.RestoreFromTrash, "Restore from Backup", "Pick a backup ZIP file", Color(0xFF795548)) { restorePicker.launch("application/zip") } - } - "running" -> Column(Modifier.fillMaxWidth().padding(vertical = 48.dp), horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator(Modifier.size(48.dp)); Spacer(Modifier.height(16.dp)); Text("Working...") } - "done" -> Column(Modifier.fillMaxWidth().padding(vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Icon(if (resultIsError) Icons.Filled.Error else Icons.Filled.CheckCircle, null, Modifier.size(64.dp), tint = if (resultIsError) Color(0xFFF44336) else Color(0xFF4CAF50)) - Spacer(Modifier.height(16.dp)); Text(if (resultIsError) "Failed" else "Done", fontSize = 18.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)); Text(resultMessage, fontSize = 13.sp, color = Color.Gray) - Spacer(Modifier.height(24.dp)); Button(onClick = { screenState = "choose"; resultIsError = false }) { Text("OK") } - } - } - } - } -} - -private suspend fun localBackup(context: Context, mgr: BackupManager, uri: Uri, pin: String, done: (String, Boolean) -> Unit) = withContext(Dispatchers.IO) { - try { - UsageTelemetry.funnelStart("backup_local") - DebugLogger.i("Backup", "=== LOCAL BACKUP STARTED ===") - DebugLogger.i("Backup", "Target folder URI: $uri") - DebugLogger.i("Backup", "PIN protected: ${pin.isNotEmpty()}") - - val r = mgr.createBackupZip(context, if (pin.isNotEmpty()) pin else null) - DebugLogger.i("Backup", "ZIP created: ${r.fileCount} files, ${r.totalBytes/1024}KB") - - val folder = DocumentFile.fromTreeUri(context, uri) - val fileName = mgr.suggestedFileName() - val out = folder?.createFile("application/zip", fileName) - if (out != null) { - context.contentResolver.openOutputStream(out.uri)?.use { os -> r.zipFile.inputStream().use { it.copyTo(os) } } - r.zipFile.delete() - UsageTelemetry.funnelComplete("backup_local") - - val report = buildString { - appendLine("=== Backup Report ===") - appendLine("File: $fileName") - appendLine("Documents: ${r.fileCount}") - appendLine("Total size: ${r.totalBytes / 1024}KB") - appendLine("Protected: ${if (pin.isNotEmpty()) "Yes (PIN-encrypted ZIP)" else "No"}") - appendLine("Location: ${folder.name}/") - appendLine("Completed: ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date())}") - } - DebugLogger.i("Backup", report) - done(report, false) - } else { - DebugLogger.e("Backup", "Failed to create file in target folder") - done("Failed to create file in folder", true) - } - } catch (e: Exception) { - DebugLogger.e("Backup", "Backup failed", e) - done("Backup error: ${e.message}", true) - } -} - -private suspend fun driveBackup(context: Context, mgr: BackupManager, done: (String, Boolean) -> Unit) = withContext(Dispatchers.IO) { - try { - UsageTelemetry.funnelStart("backup_drive") - val provider = GoogleDriveServiceProvider(context) - val service = provider.getDriveService() - if (service == null) { done("Not signed in to Google Drive", true); return@withContext } - val r = mgr.createBackupZip(context) - val metadata = DriveFile().apply { name = mgr.suggestedFileName(); mimeType = "application/zip" } - val content = FileContent("application/zip", r.zipFile) - val uploaded = service.files().create(metadata, content).setFields("id,name").execute() - r.zipFile.delete() - DebugLogger.i("DriveBackup", "Uploaded: ${uploaded.name} (id=${uploaded.id})") - UsageTelemetry.funnelComplete("backup_drive") - done("Uploaded to Drive: ${uploaded.name}", false) - } catch (e: Exception) { DebugLogger.e("DriveBackup", "Failed", e); done("Drive error: ${e.message}", true) } -} - -private suspend fun s3Backup(context: Context, mgr: BackupManager, config: S3Config, done: (String, Boolean) -> Unit) = withContext(Dispatchers.IO) { - try { - UsageTelemetry.funnelStart("backup_s3") - val r = mgr.createBackupZip(context) - val result = S3BackupUploader.upload(r.zipFile, mgr.suggestedFileName(), config) - r.zipFile.delete() - result.onSuccess { UsageTelemetry.funnelComplete("backup_s3"); done(it, false) } - result.onFailure { done("S3 error: ${it.message}", true) } - } catch (e: Exception) { done("S3 error: ${e.message}", true) } -} - -private suspend fun doRestore(context: Context, uri: Uri, password: String?, done: (String, Boolean) -> Unit) = withContext(Dispatchers.IO) { - try { - DebugLogger.i("Restore", "=== RESTORE STARTED ===") - DebugLogger.i("Restore", "Source URI: $uri") - DebugLogger.i("Restore", "Password provided: ${password != null}") - - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - if (bytes == null) { DebugLogger.e("Restore", "Cannot read backup file from URI"); done("Cannot read backup file", true); return@withContext } - DebugLogger.i("Restore", "Read ${bytes.size / 1024}KB from source") - - val tempZip = java.io.File(context.cacheDir, "restore_temp.zip") - tempZip.writeBytes(bytes) - - // Step 1: Inspect manifest BEFORE restoring - DebugLogger.i("Restore", "--- Step 1: Inspecting backup ---") - val inspection = BackupRestore.inspectBackup(context, tempZip, password) - if (!inspection.valid) { - tempZip.delete() - DebugLogger.e("Restore", "Inspection failed: ${inspection.errorMessage}") - done("Invalid backup: ${inspection.errorMessage}", true) - return@withContext - } - DebugLogger.i("Restore", "Inspection OK: schema=${inspection.schemaVersion}, files=${inspection.fileCount}, size=${inspection.totalSizeBytes/1024}KB, pinProtected=${inspection.pinProtectedCount}") - - // Step 2: Execute restore - DebugLogger.i("Restore", "--- Step 2: Restoring files ---") - val result = BackupRestore.restoreFromZip(context, tempZip, password) - tempZip.delete() - - // Step 3: Report - DebugLogger.i("Restore", "--- Step 3: Report ---") - val report = buildString { - appendLine("=== Restore Report ===") - appendLine("Backup: schema v${inspection.schemaVersion}, from ${inspection.timestamp}") - appendLine("Expected: ${inspection.fileCount} documents (${inspection.totalSizeBytes/1024}KB)") - appendLine("PIN-protected docs: ${inspection.pinProtectedCount}") - appendLine("") - appendLine("Processed: ${result.filesProcessed}") - appendLine("Restored: ${result.filesRestored}") - appendLine("Failed: ${result.filesFailedVerification}") - appendLine("Status: ${if (result.success) "SUCCESS" else "FAILED"}") - if (result.filesRestored < result.filesProcessed) { - appendLine("") - appendLine("WARNING: ${result.filesProcessed - result.filesRestored} of ${result.filesProcessed} document files not restored") - } - appendLine("") - appendLine("Tap Refresh on home page to see restored documents.") - } - DebugLogger.i("Restore", report) - - if (result.success) { - done(report + "\n\nApp will restart to load restored data...", false) - // Must restart: Room's Hilt singleton holds a stale DB connection. - // No way to swap DB file under a live Room connection without restart. - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - DebugLogger.i("Restore", "Restarting app to load restored database") - android.os.Process.killProcess(android.os.Process.myPid()) - }, 3000) - } else { - done("Restore failed: ${result.message}", true) - } - } catch (e: Exception) { - DebugLogger.e("Restore", "Restore exception: ${e.message}", e) - done("Restore error: ${e.message}", true) - } -} - -private suspend fun presignedUpload(context: android.content.Context, mgr: com.app.traveldocs.data.backup.BackupManager, presignedUrl: String, done: (String, Boolean) -> Unit) = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - try { - com.app.traveldocs.debug.DebugLogger.i("S3Presigned", "Uploading to presigned URL") - com.app.traveldocs.debug.UsageTelemetry.funnelStart("backup_s3_presigned") - val r = mgr.createBackupZip(context) - val bytes = r.zipFile.readBytes() - val conn = java.net.URL(presignedUrl).openConnection() as java.net.HttpURLConnection - conn.requestMethod = "PUT" - conn.setRequestProperty("Content-Type", "application/zip") - conn.setRequestProperty("Content-Length", bytes.size.toString()) - conn.doOutput = true - conn.outputStream.use { it.write(bytes) } - val code = conn.responseCode - r.zipFile.delete() - if (code in 200..299) { - com.app.traveldocs.debug.UsageTelemetry.funnelComplete("backup_s3_presigned") - done("Uploaded via presigned URL (HTTP $code)", false) - } else { done("Upload failed: HTTP $code", true) } - } catch (e: Exception) { done("Error: ${e.message}", true) } -} - -@Composable -private fun OptionCard(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, subtitle: String, iconColor: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { - androidx.compose.material3.Card(androidx.compose.ui.Modifier.fillMaxWidth().clickable(onClick = onClick), shape = RoundedCornerShape(12.dp), elevation = CardDefaults.cardElevation(2.dp)) { - Row(androidx.compose.ui.Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(icon, title, tint = iconColor, modifier = androidx.compose.ui.Modifier.size(32.dp)); Spacer(androidx.compose.ui.Modifier.width(14.dp)) - Column { Text(title, fontWeight = FontWeight.SemiBold, fontSize = 15.sp); Text(subtitle, fontSize = 12.sp, color = Color.Gray) } - } - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/documents/UrlImportScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/documents/UrlImportScreen.kt deleted file mode 100644 index cacddf6..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/documents/UrlImportScreen.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.app.traveldocs.presentation.documents - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.model.DocumentFormat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.HttpURLConnection -import java.net.URL - -/** - * Import a document from an HTTP/HTTPS URL. - * - * User pastes a URL, we download it in the background, detect the format - * from the URL extension or content-type header, and feed it into the - * standard import pipeline. - * - * This is gated behind the experimental features flag. - */ -@Composable -fun UrlImportSection( - importViewModel: ImportViewModel, - onDone: () -> Unit, - onBack: () -> Unit -) { - var url by remember { mutableStateOf("https://github.com/robyoung/dicom-test-files/blob/master/data/pydicom/693_J2KI.dcm") } - var downloading by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - var success by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (success) { - Spacer(Modifier.height(40.dp)) - Icon(Icons.Filled.CheckCircle, null, tint = Color(0xFF4CAF50), modifier = Modifier.size(64.dp)) - Spacer(Modifier.height(16.dp)) - Text("Downloaded & Imported!", fontSize = 18.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(24.dp)) - Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Done") } - } else { - Text("Import from URL", fontSize = 18.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - Text("Paste a direct link to a file (PDF, image, DICOM, etc.)", fontSize = 13.sp, color = Color.Gray) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - label = { Text("File URL") }, - placeholder = { Text("https://example.com/document.pdf") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - if (error != null) { - Spacer(Modifier.height(8.dp)) - Text(error!!, color = Color(0xFFF44336), fontSize = 13.sp) - } - - Spacer(Modifier.height(16.dp)) - - if (downloading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(8.dp)) - Text("Downloading...", fontSize = 13.sp, color = Color.Gray) - } else { - Button( - onClick = { - if (url.isBlank() || (!url.startsWith("http://") && !url.startsWith("https://"))) { - error = "Enter a valid HTTP or HTTPS URL" - return@Button - } - downloading = true - error = null - scope.launch { - val result = downloadAndImport(url.trim(), importViewModel) - downloading = false - if (result == null) { - success = true - } else { - error = result - } - } - }, - enabled = url.isNotBlank(), - modifier = Modifier.fillMaxWidth() - ) { Text("Download & Import") } - } - - Spacer(Modifier.height(12.dp)) - OutlinedButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Back") } - } - } -} - -/** - * Downloads a file from a URL and imports it. - * Returns null on success, or an error message string on failure. - */ -private suspend fun downloadAndImport(urlStr: String, importViewModel: ImportViewModel): String? { - return withContext(Dispatchers.IO) { - try { - DebugLogger.i("UrlImport", "Downloading: $urlStr") - - // Handle GitHub blob URLs — convert to raw - val actualUrl = if (urlStr.contains("github.com") && urlStr.contains("/blob/")) { - urlStr.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/") - } else { - urlStr - } - - val connection = URL(actualUrl).openConnection() as HttpURLConnection - connection.connectTimeout = 30000 - connection.readTimeout = 60000 - connection.instanceFollowRedirects = true - connection.connect() - - val responseCode = connection.responseCode - if (responseCode != 200) { - return@withContext "Server returned HTTP $responseCode" - } - - val bytes = connection.inputStream.use { it.readBytes() } - if (bytes.isEmpty()) { - return@withContext "Downloaded file is empty" - } - - DebugLogger.i("UrlImport", "Downloaded ${bytes.size} bytes") - - // Determine format from URL extension or content-type - val contentType = connection.contentType ?: "" - val fileName = actualUrl.substringAfterLast('/').substringBefore('?').ifEmpty { "download" } - val format = detectFormat(fileName, contentType) - - connection.disconnect() - - // Import via the standard pipeline (on main thread for ViewModel access) - withContext(Dispatchers.Main) { - importViewModel.importFromBytes(bytes, format, fileName) - } - - DebugLogger.i("UrlImport", "Import triggered for: $fileName ($format)") - null // success - } catch (e: Exception) { - DebugLogger.e("UrlImport", "Download failed", e) - "Download failed: ${e.message}" - } - } -} - -private fun detectFormat(fileName: String, contentType: String): DocumentFormat { - val ext = fileName.substringAfterLast('.', "").lowercase() - return when { - ext == "pdf" || contentType.contains("pdf") -> DocumentFormat.PDF - ext == "png" || contentType.contains("png") -> DocumentFormat.PNG - ext in listOf("jpg", "jpeg") || contentType.contains("jpeg") -> DocumentFormat.JPG - ext == "webp" || contentType.contains("webp") -> DocumentFormat.WEBP - ext in listOf("heic", "heif") -> DocumentFormat.HEIC - ext == "bmp" -> DocumentFormat.BMP - ext == "gif" || contentType.contains("gif") -> DocumentFormat.GIF - ext == "dcm" || ext == "dicom" || contentType.contains("dicom") -> DocumentFormat.DICOM - contentType.contains("video") -> DocumentFormat.VIDEO - ext in listOf("mp3", "m4a", "wav", "ogg", "flac") || contentType.contains("audio") -> DocumentFormat.AUDIO - else -> DocumentFormat.UNKNOWN // unsupported format, import as generic file - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/feedback/FeedbackScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/feedback/FeedbackScreen.kt deleted file mode 100644 index 922b7d4..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/feedback/FeedbackScreen.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.app.traveldocs.presentation.feedback - -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.app.traveldocs.debug.DebugLogger - -/** - * In-app feedback form. - * - * User types their feedback, optionally includes device info, then taps Send. - * This opens the system email client with the feedback pre-filled — nothing is - * sent silently. The user sees exactly what will be sent and must hit Send in - * their email app to actually transmit it. - * - * No data leaves the device without the user's explicit action in their email client. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FeedbackScreen(onBack: () -> Unit) { - val context = LocalContext.current - var feedbackText by remember { mutableStateOf("") } - var includeDeviceInfo by remember { mutableStateOf(true) } - var sent by remember { mutableStateOf(false) } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Send Feedback") }, - navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } } - ) - } - ) { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp) - ) { - if (sent) { - Spacer(Modifier.height(32.dp)) - Text("Thank you!", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - Text( - "Your email app should have opened with the feedback. " + - "Please hit Send in your email app to deliver it to the developer.", - fontSize = 14.sp, color = Color.Gray - ) - Spacer(Modifier.height(24.dp)) - Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Done") } - } else { - Text( - "We'd love to hear from you", - fontSize = 18.sp, fontWeight = FontWeight.SemiBold - ) - Spacer(Modifier.height(4.dp)) - Text( - "Bug reports, feature ideas, or general feedback — all welcome. " + - "This will open your email app so you can review before sending.", - fontSize = 13.sp, color = Color.Gray - ) - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = feedbackText, - onValueChange = { feedbackText = it }, - label = { Text("Your feedback") }, - placeholder = { Text("Describe the issue or suggestion...") }, - modifier = Modifier.fillMaxWidth().height(200.dp), - maxLines = 12 - ) - - Spacer(Modifier.height(12.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = includeDeviceInfo, onCheckedChange = { includeDeviceInfo = it }) - Text("Include device info (model, Android version, app version)", fontSize = 13.sp) - } - - Spacer(Modifier.height(8.dp)) - Text( - "Nothing is sent automatically. Your email app will open so you can review the message before sending.", - fontSize = 11.sp, color = Color.Gray - ) - - Spacer(Modifier.height(16.dp)) - - Button( - onClick = { - sendFeedbackEmail(context, feedbackText, includeDeviceInfo) - sent = true - }, - enabled = feedbackText.isNotBlank(), - modifier = Modifier.fillMaxWidth() - ) { - Text("Open Email to Send") - } - } - } - } -} - -private fun sendFeedbackEmail(context: Context, feedback: String, includeDevice: Boolean) { - val deviceInfo = if (includeDevice) { - buildString { - appendLine("\n\n--- Device Info ---") - appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") - appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") - appendLine("App: Document Manager") - try { - val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) - appendLine("Version: ${pInfo.versionName} (${pInfo.longVersionCode})") - } catch (_: Exception) {} - } - } else "" - - val body = feedback + deviceInfo - - val emailIntent = Intent(Intent.ACTION_SEND).apply { - type = "message/rfc822" - putExtra(Intent.EXTRA_EMAIL, arrayOf("developer@documentmanager.app")) - putExtra(Intent.EXTRA_SUBJECT, "Document Manager — User Feedback") - putExtra(Intent.EXTRA_TEXT, body) - } - - try { - context.startActivity(Intent.createChooser(emailIntent, "Send feedback via").apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) - DebugLogger.i("Feedback", "Email intent launched (${feedback.length} chars)") - } catch (e: Exception) { - DebugLogger.e("Feedback", "Failed to launch email", e) - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/onboarding/ConsentScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/onboarding/ConsentScreen.kt deleted file mode 100644 index d82137f..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/onboarding/ConsentScreen.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.app.traveldocs.presentation.onboarding - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@Composable -fun ConsentScreen(onConsented: (pin: String, phone: String, email: String) -> Unit) { - var pin by remember { mutableStateOf("") } - var pinConfirm by remember { mutableStateOf("") } - var phone by remember { mutableStateOf("") } - var email by remember { mutableStateOf("") } - var termsAccepted by remember { mutableStateOf(false) } - var pinWarningAcked by remember { mutableStateOf(false) } - - val pinValid = pin.length in 4..8 && pin == pinConfirm - val canProceed = pinValid && termsAccepted && pinWarningAcked - - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp) - ) { - Spacer(Modifier.height(32.dp)) - Text("Welcome", fontSize = 26.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) - Text("Document Manager", fontSize = 16.sp, color = Color.Gray) - Spacer(Modifier.height(24.dp)) - - Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Color(0xFFE3F2FD))) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Your documents will be encrypted with AES-256 and protected by a PIN.", fontSize = 13.sp) - Spacer(Modifier.height(8.dp)) - Text("All data stays on this device. No cloud, no servers.", fontSize = 13.sp) - } - } - - Spacer(Modifier.height(20.dp)) - Text("Create your PIN", fontWeight = FontWeight.SemiBold, fontSize = 15.sp) - Spacer(Modifier.height(8.dp)) - OutlinedTextField(value = pin, onValueChange = { if (it.length <= 8) pin = it.filter { c -> c.isDigit() } }, label = { Text("PIN (4-8 digits)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(8.dp)) - OutlinedTextField(value = pinConfirm, onValueChange = { if (it.length <= 8) pinConfirm = it.filter { c -> c.isDigit() } }, label = { Text("Confirm PIN") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - if (pin.isNotEmpty() && pinConfirm.isNotEmpty() && pin != pinConfirm) { - Text("PINs do not match", color = Color.Red, fontSize = 12.sp) - } - - Spacer(Modifier.height(20.dp)) - Text("Recovery contacts (for security alerts)", fontWeight = FontWeight.SemiBold, fontSize = 15.sp) - Spacer(Modifier.height(8.dp)) - com.app.traveldocs.presentation.common.PhoneInputWithCountryCode(fullPhone = phone, onPhoneChange = { phone = it }, label = "Phone number") - Spacer(Modifier.height(8.dp)) - OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("Email address") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - - Spacer(Modifier.height(20.dp)) - Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF3E0))) { - Column(modifier = Modifier.padding(12.dp)) { - Text("WARNING", fontWeight = FontWeight.Bold, color = Color(0xFFE65100), fontSize = 13.sp) - Spacer(Modifier.height(4.dp)) - Text("If you forget your PIN, your documents CANNOT be recovered. There is no reset option. Please write down your PIN and store it securely.", fontSize = 12.sp, color = Color(0xFFBF360C)) - } - } - - Spacer(Modifier.height(12.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = pinWarningAcked, onCheckedChange = { pinWarningAcked = it }) - Text("I understand my PIN cannot be recovered", fontSize = 13.sp) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = termsAccepted, onCheckedChange = { termsAccepted = it }) - Text("I agree to use encrypted storage", fontSize = 13.sp) - } - - Spacer(Modifier.height(20.dp)) - Button(onClick = { onConsented(pin, phone, email) }, enabled = canProceed, modifier = Modifier.fillMaxWidth()) { - Text("Set Up & Continue") - } - Spacer(Modifier.height(40.dp)) - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/onboarding/SplashScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/onboarding/SplashScreen.kt deleted file mode 100644 index ccebe5e..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/onboarding/SplashScreen.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.app.traveldocs.presentation.onboarding - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.CloudOff -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@Composable -fun SplashScreen(onContinue: (skipInFuture: Boolean) -> Unit) { - var skipInFuture by remember { mutableStateOf(false) } - - Box( - modifier = Modifier.fillMaxSize().background(Color(0xFF1565C0)), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Filled.Description, null, - tint = Color.White, - modifier = Modifier.size(72.dp) - ) - Spacer(Modifier.height(16.dp)) - Text( - "Document Manager", - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) - Spacer(Modifier.height(8.dp)) - Text( - "Your documents. Your device. Your control.", - fontSize = 14.sp, - color = Color.White.copy(alpha = 0.85f), - textAlign = TextAlign.Center - ) - - Spacer(Modifier.height(40.dp)) - - // Key principles - SplashPoint(Icons.Filled.Lock, "Private & Encrypted", "AES-256 encryption. Biometric lock.") - Spacer(Modifier.height(16.dp)) - SplashPoint(Icons.Filled.CloudOff, "No Data Collected", "Nothing uploaded. No servers. No tracking.") - Spacer(Modifier.height(16.dp)) - SplashPoint(Icons.Filled.LocationOn, "GPS Only With Consent", "Location logging is off by default.") - - Spacer(Modifier.height(40.dp)) - - // Skip checkbox - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Checkbox( - checked = skipInFuture, - onCheckedChange = { skipInFuture = it } - ) - Text( - "Don\u2019t show this again", - color = Color.White.copy(alpha = 0.9f), - fontSize = 14.sp - ) - } - - Spacer(Modifier.height(16.dp)) - - Button( - onClick = { onContinue(skipInFuture) }, - modifier = Modifier.fillMaxWidth().height(48.dp) - ) { - Text("Get Started", fontSize = 16.sp) - } - } - } -} - -@Composable -private fun SplashPoint(icon: ImageVector, title: String, subtitle: String) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Icon(icon, null, tint = Color.White.copy(alpha = 0.9f), modifier = Modifier.size(28.dp)) - Column(modifier = Modifier.padding(start = 12.dp)) { - Text(title, color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp) - Text(subtitle, color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp) - } - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/settings/SettingsScreen.kt deleted file mode 100644 index 1a02ef2..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/settings/SettingsScreen.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.app.traveldocs.presentation.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.app.traveldocs.data.local.FeatureFlags - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen(onBack: () -> Unit) { - val context = LocalContext.current - var experimental by remember { mutableStateOf(FeatureFlags.isExperimentalEnabled(context)) } - var driveEnabled by remember { mutableStateOf(FeatureFlags.isGoogleDriveEnabled(context)) } - var s3Enabled by remember { mutableStateOf(FeatureFlags.isS3Enabled(context)) } - var backupEnabled by remember { mutableStateOf(FeatureFlags.isBackupRestoreEnabled(context)) } - var gpsEnabled by remember { mutableStateOf(FeatureFlags.isGpsTrackingEnabled(context)) } - var extFormatsEnabled by remember { mutableStateOf(FeatureFlags.isExtendedFormatsEnabled(context)) } - - Scaffold(topBar = { TopAppBar(title = { Text("Settings") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { - Text("Experimental Features", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(Modifier.height(4.dp)) - Text("Enable features that are in development. May be unstable.", fontSize = 12.sp) - Spacer(Modifier.height(12.dp)) - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - ToggleRow("Enable Experimental Features", experimental) { experimental = it; FeatureFlags.setExperimental(context, it) } - if (experimental) { - Spacer(Modifier.height(12.dp)) - ToggleRow("Google Drive Support", driveEnabled) { driveEnabled = it; FeatureFlags.setGoogleDrive(context, it) } - Spacer(Modifier.height(8.dp)) - ToggleRow("S3 Compatible Storage", s3Enabled) { s3Enabled = it; FeatureFlags.setS3(context, it) } - ToggleRow("Backup & Restore (Preview)", backupEnabled) { backupEnabled = it; FeatureFlags.setBackupRestore(context, it) } - Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(8.dp)) - var wifiShareEnabled by remember { mutableStateOf(FeatureFlags.isWifiShareEnabled(context)) } - ToggleRow("WiFi Document Sharing (Preview)", wifiShareEnabled) { wifiShareEnabled = it; FeatureFlags.setWifiShare(context, it) } - Spacer(Modifier.height(8.dp)) - var audioEnabled by remember { mutableStateOf(FeatureFlags.isAudioPlaybackEnabled(context)) } - ToggleRow("Audio Playback & Android Auto (Preview)", audioEnabled) { audioEnabled = it; FeatureFlags.setAudioPlayback(context, it) } - Spacer(Modifier.height(8.dp)) - ToggleRow("Background GPS Tracking (Preview)", gpsEnabled) { gpsEnabled = it; FeatureFlags.setGpsTracking(context, it) } - Spacer(Modifier.height(8.dp)) - ToggleRow("Extended Image Formats", extFormatsEnabled) { extFormatsEnabled = it; FeatureFlags.setExtendedFormats(context, it) } - if (extFormatsEnabled) { - Spacer(Modifier.height(4.dp)) - var webpOn by remember { mutableStateOf(FeatureFlags.isFormatEnabled(context, "webp")) } - var heicOn by remember { mutableStateOf(FeatureFlags.isFormatEnabled(context, "heic")) } - var bmpOn by remember { mutableStateOf(FeatureFlags.isFormatEnabled(context, "bmp")) } - var gifOn by remember { mutableStateOf(FeatureFlags.isFormatEnabled(context, "gif")) } - var dicomOn by remember { mutableStateOf(FeatureFlags.isFormatEnabled(context, "dicom")) } - SubToggleRow("WebP", webpOn) { webpOn = it; FeatureFlags.setFormatEnabled(context, "webp", it) } - SubToggleRow("HEIC / HEIF (iOS photos)", heicOn) { heicOn = it; FeatureFlags.setFormatEnabled(context, "heic", it) } - SubToggleRow("BMP (legacy scans)", bmpOn) { bmpOn = it; FeatureFlags.setFormatEnabled(context, "bmp", it) } - SubToggleRow("GIF", gifOn) { gifOn = it; FeatureFlags.setFormatEnabled(context, "gif", it) } - SubToggleRow("DICOM (medical imaging)", dicomOn) { dicomOn = it; FeatureFlags.setFormatEnabled(context, "dicom", it) } - } - } - } - } - Spacer(Modifier.height(16.dp)) - Text("Personalization", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(Modifier.height(8.dp)) - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Home Page Title", fontSize = 14.sp) - Spacer(Modifier.height(4.dp)) - var homeTitle by remember { mutableStateOf(context.getSharedPreferences("app_settings", 0).getString("home_title", "My Private Documents") ?: "My Private Documents") } - OutlinedTextField( - value = homeTitle, - onValueChange = { homeTitle = it }, - label = { Text("Title") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(8.dp)) - Button(onClick = { context.getSharedPreferences("app_settings", 0).edit().putString("home_title", homeTitle).apply() }, modifier = Modifier.fillMaxWidth()) { Text("Save") } - } - } - - Spacer(Modifier.height(16.dp)) - Text("Privacy", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(Modifier.height(8.dp)) - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - var telemetryEnabled by remember { mutableStateOf(context.getSharedPreferences("app_settings", 0).getBoolean("telemetry_enabled", false)) } - ToggleRow("Collect usage telemetry (local)", telemetryEnabled) { - telemetryEnabled = it - context.getSharedPreferences("app_settings", 0).edit().putBoolean("telemetry_enabled", it).apply() - com.app.traveldocs.debug.UsageTelemetry.setConsent(it) - } - Spacer(Modifier.height(4.dp)) - Text("When enabled, anonymous usage stats are stored locally on your device. Nothing is sent automatically.", fontSize = 11.sp, color = Color.Gray) - if (telemetryEnabled) { - Spacer(Modifier.height(8.dp)) - Button(onClick = { shareTelemetry(context) }, modifier = Modifier.fillMaxWidth()) { Text("Send Telemetry via Email", fontSize = 13.sp) } - Text("Opens your email app with telemetry attached. You decide who receives it.", fontSize = 10.sp, color = Color.Gray) - } - } - } - - Spacer(Modifier.height(16.dp)) - Text("Appearance", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(Modifier.height(8.dp)) - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - var darkMode by remember { mutableStateOf(context.getSharedPreferences("app_settings", 0).getBoolean("dark_mode", false)) } - ToggleRow("Dark Theme", darkMode) { - darkMode = it - context.getSharedPreferences("app_settings", 0).edit().putBoolean("dark_mode", it).apply() - // Recreate activity to apply theme immediately - (context as? android.app.Activity)?.recreate() - } - Spacer(Modifier.height(4.dp)) - Text("Theme changes apply immediately.", fontSize = 11.sp, color = Color.Gray) - } - } - } - } -} - -@Composable -private fun ToggleRow(label: String, checked: Boolean, onChanged: (Boolean) -> Unit) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(label, modifier = Modifier.weight(1f), fontSize = 14.sp) - Switch(checked = checked, onCheckedChange = onChanged) - } -} - -@Composable -private fun SubToggleRow(label: String, checked: Boolean, onChanged: (Boolean) -> Unit) { - Row(modifier = Modifier.fillMaxWidth().padding(start = 24.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, modifier = Modifier.weight(1f), fontSize = 13.sp) - Switch(checked = checked, onCheckedChange = onChanged) - } -} - - -private fun shareTelemetry(context: android.content.Context) { - try { - // Package telemetry summary into a text file - val summary = com.app.traveldocs.debug.UsageTelemetry.getLocalSummary() - val cacheDir = java.io.File(context.cacheDir, "shared_docs") - cacheDir.mkdirs() - val file = java.io.File(cacheDir, "telemetry_report.txt") - file.writeText(summary) - val uri = androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) - val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(android.content.Intent.EXTRA_STREAM, uri) - putExtra(android.content.Intent.EXTRA_SUBJECT, "Document Manager - Usage Telemetry Report") - putExtra(android.content.Intent.EXTRA_TEXT, "Attached: anonymous usage telemetry from Document Manager app.") - addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - val resInfoList = context.packageManager.queryIntentActivities(intent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY) - for (ri in resInfoList) { context.grantUriPermission(ri.activityInfo.packageName, uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(android.content.Intent.createChooser(intent, "Send telemetry via").addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) - } catch (e: Exception) { - com.app.traveldocs.debug.DebugLogger.e("Settings", "Failed to share telemetry", e) - } -} diff --git a/app/src/main/java/com/app/traveldocs/presentation/webshare/WebShareScreen.kt b/app/src/main/java/com/app/traveldocs/presentation/webshare/WebShareScreen.kt deleted file mode 100644 index 8068c6a..0000000 --- a/app/src/main/java/com/app/traveldocs/presentation/webshare/WebShareScreen.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.app.traveldocs.presentation.webshare - -import android.content.Context -import android.net.wifi.WifiManager -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.WifiOff -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.app.traveldocs.data.webserver.DocumentWebServer -import com.app.traveldocs.debug.DebugLogger -import com.app.traveldocs.domain.repository.DocumentFileStorage -import com.app.traveldocs.domain.repository.DocumentRepository -import com.app.traveldocs.domain.repository.TagRepository - -@dagger.hilt.EntryPoint -@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) -interface WebShareEntryPoint { - fun documentRepository(): DocumentRepository - fun fileStorage(): DocumentFileStorage - fun tagRepository(): TagRepository -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WebShareScreen(onBack: () -> Unit) { - val context = LocalContext.current - // Access repositories via Hilt EntryPoint (no ViewModel needed for this screen) - val entryPoint = remember { dagger.hilt.android.EntryPointAccessors.fromApplication(context.applicationContext, WebShareEntryPoint::class.java) } - val documentRepository = remember { entryPoint.documentRepository() } - val fileStorage = remember { entryPoint.fileStorage() } - val tagRepository = remember { entryPoint.tagRepository() } - var serverRunning by remember { mutableStateOf(false) } - var serverUrl by remember { mutableStateOf("") } - var server by remember { mutableStateOf(null) } - - DisposableEffect(Unit) { - onDispose { server?.stop(); DebugLogger.i("WebShare", "Server stopped (screen disposed)") } - } - - Scaffold(topBar = { TopAppBar(title = { Text("Share via WiFi") }, navigationIcon = { IconButton(onClick = { server?.stop(); onBack() }) { Icon(Icons.Filled.ArrowBack, "Back") } }) }) { padding -> - Column(Modifier.fillMaxSize().padding(padding).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { - if (serverRunning) { - Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Color(0xFFE8F5E9)), shape = RoundedCornerShape(12.dp)) { - Column(Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Filled.Wifi, null, tint = Color(0xFF4CAF50), modifier = Modifier.size(48.dp)) - Spacer(Modifier.height(12.dp)) - Text("Sharing Active", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color(0xFF2E7D32)) - Spacer(Modifier.height(8.dp)) - Text("Open this URL in any browser on your WiFi:", fontSize = 13.sp, color = Color.Gray, textAlign = TextAlign.Center) - Spacer(Modifier.height(12.dp)) - Text(serverUrl, fontSize = 16.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFF1565C0)) - Spacer(Modifier.height(8.dp)) - Text("Access token is embedded in the URL. Only share with trusted devices.", fontSize = 11.sp, color = Color(0xFFE65100)) - } - } - Spacer(Modifier.height(16.dp)) - Text("From the web browser you can:", fontSize = 14.sp) - Spacer(Modifier.height(4.dp)) - listOf("Browse by tags", "Download files (decrypted)", "Upload new files", "Rename documents", "Add/remove tags").forEach { Text("• $it", fontSize = 13.sp, color = Color.Gray) } - Spacer(Modifier.height(24.dp)) - Button(onClick = { server?.stop(); serverRunning = false; DebugLogger.i("WebShare", "Stopped by user") }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF44336)), modifier = Modifier.fillMaxWidth().height(48.dp)) { - Icon(Icons.Filled.WifiOff, null, Modifier.size(20.dp)); Spacer(Modifier.width(8.dp)); Text("Stop Sharing", fontSize = 16.sp) - } - Spacer(Modifier.height(4.dp)) - Text("Stop to secure your documents.", fontSize = 11.sp, color = Color(0xFFF44336)) - } else { - Spacer(Modifier.height(40.dp)) - Icon(Icons.Filled.Wifi, null, tint = Color.Gray, modifier = Modifier.size(64.dp)) - Spacer(Modifier.height(16.dp)) - Text("WiFi Document Sharing", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - Text("Start a local web server. Any device on your WiFi can browse and manage your documents.", fontSize = 14.sp, color = Color.Gray, textAlign = TextAlign.Center) - Spacer(Modifier.height(12.dp)) - Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF3E0))) { - Text("⚠️ While active, anyone on your WiFi can access documents (decrypted). Use only on trusted networks.", fontSize = 12.sp, color = Color(0xFFE65100), modifier = Modifier.padding(12.dp)) - } - Spacer(Modifier.height(20.dp)) - val wifiIp = getWifiIp(context) - if (wifiIp != null) { - Text("WiFi IP: $wifiIp", fontSize = 13.sp, color = Color.Gray) - Spacer(Modifier.height(16.dp)) - Button(onClick = { - val s = DocumentWebServer(context, documentRepository, fileStorage, tagRepository) - s.start() - server = s - serverUrl = s.getServerUrl() - serverRunning = true - DebugLogger.i("WebShare", "Started at $serverUrl") - }, modifier = Modifier.fillMaxWidth().height(48.dp)) { - Icon(Icons.Filled.Wifi, null, Modifier.size(20.dp)); Spacer(Modifier.width(8.dp)); Text("Start Sharing", fontSize = 16.sp) - } - } else { - Text("Not connected to WiFi.", fontSize = 14.sp, color = Color(0xFFF44336), textAlign = TextAlign.Center) - } - } - } - } -} - -private fun getWifiIp(context: Context): String? { - return try { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val ip = wm.connectionInfo.ipAddress - if (ip == 0) return null - "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}" - } catch (e: Exception) { - com.app.traveldocs.debug.DebugLogger.e("WebShare", "Failed to get WiFi IP", e) - null - } -} diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d05dce8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 133abc3..9b67a7a 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -4,10 +4,7 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - + android:fillColor="#0C2041" + android:pathData="M0,0 h108 v108 h-108 z" /> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index da9e2c4..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..59f1fa2 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index d378acd..c78bee3 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index d378acd..c78bee3 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ - - + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..7303c78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-hdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..834b929 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..05751c7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.xml b/app/src/main/res/mipmap-mdpi/ic_launcher.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-mdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..d3d21b1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..927d09d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dd1f7e5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..12907f1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..3c0d381 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..d763287 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fc06157 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml deleted file mode 100644 index d378acd..0000000 --- a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 26553a9..d1740d3 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,6 @@ - #1565C0 - #FFFFFF + #0C2041 + #FFF8EE + #0C2041 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2104d5e..ba55323 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,29 +1,27 @@ - DocVault + Paperstow - Travel Documents + Paperstow Import All Docs Search %d documents managed - No documents yet - Tap Import to add your first document + Nothing stowed yet + Import a passport, visa, or ticket to get started Import Choose Import Method Single File - Pick one PDF, image, or video + Pick a PDF, image, or text file Local Folder Import all files from a phone folder - Google Drive Folder - Browse and import from Drive - Take Photo - Scan document with camera + Scan document + On-device edge detect, crop, and enhance Importing document… - Running OCR extraction + Trying to read the page Document Imported! Import Failed Done @@ -88,7 +86,7 @@ Security Alerts Recovery Phone Number Recovery Email - SMS alerts on PIN lockout + SMS alerts on lockout Save Saved! Last alert sent: %s @@ -104,35 +102,20 @@ Discharging Connected Offline - GPS Tracking Debug Logs View Debug Logs Welcome - DocVault - Your documents will be encrypted with AES-256 and protected by a PIN. All data stays on this device. No cloud, no servers. - Create your PIN - PIN (4–8 digits) - Confirm PIN - PINs do not match + Paperstow + Keep a copy of your family travel papers on this device. + Your papers stay encrypted on this phone. Only you can open them, with your fingerprint, face, or screen lock. Recovery contacts (for security alerts) +1234567890 you@example.com - WARNING - If you forget your PIN, your documents CANNOT be recovered. There is no reset option. Please write down your PIN and store it securely. - I understand my PIN cannot be recovered I agree to use encrypted storage Set Up & Continue - - Enter PIN - Unlock your documents - Unlock - Account locked - Try again in %ds - Incorrect PIN - Tags Settings @@ -153,7 +136,7 @@ Privacy Policy - https://github.com/sethusrinivasan/document-manager/blob/main/docs/PRIVACY_POLICY.md + https://sethusrinivasan.github.io/document-manager/privacy.html Send Crash Report Crash report ready to send No crash reports available @@ -162,6 +145,35 @@ Should we scan subfolders recursively? Each subfolder name will be added as a tag to imported documents. Yes, include subfolders No, root only + Back - + + My Trail + Places stay only on this phone. A notification stays visible while the trail is on. To stop later: menu → My Trail → Stop trail. + Use location for My Trail? + Paperstow will record unique places while you travel, keep them on this phone for 24 hours with the battery level at each save, and show a notification the whole time. Locations are not sent to Paperstow. You can share Maps links yourself when you need to. + Allow location + Start trail + Stop trail + Trail is recording + Trail is off + Unique places from the last 24 hours stay on this phone. Stops if you reboot until you start it again. + Start it when you want a last-known place. Location is used only while this is on. + Location permission is needed for the trail. You can grant it in system settings. + Open settings + Share last known + Share all + Paperstow My Trail + Unique places (%d) + No unique places in the last 24 hours. + Last known + Earlier + Open in Maps + Clear + Clear places? + This removes unique places stored on this phone. It does not stop the trail. + My Trail + Shown while Paperstow records unique places on this phone + My Trail is on + Recording unique places on this phone. Tap Stop to end. diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml deleted file mode 100644 index e64f442..0000000 --- a/app/src/main/res/xml/automotive_app_desc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/test/java/com/app/traveldocs/data/backup/EncryptionRoundTripTest.kt b/app/src/test/java/com/app/paperstow/data/backup/EncryptionRoundTripTest.kt similarity index 98% rename from app/src/test/java/com/app/traveldocs/data/backup/EncryptionRoundTripTest.kt rename to app/src/test/java/com/app/paperstow/data/backup/EncryptionRoundTripTest.kt index c7fdf8a..e9d35e4 100644 --- a/app/src/test/java/com/app/traveldocs/data/backup/EncryptionRoundTripTest.kt +++ b/app/src/test/java/com/app/paperstow/data/backup/EncryptionRoundTripTest.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.backup +package com.app.paperstow.data.backup import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test diff --git a/app/src/test/java/com/app/paperstow/data/backup/RoomDbFilesTest.kt b/app/src/test/java/com/app/paperstow/data/backup/RoomDbFilesTest.kt new file mode 100644 index 0000000..d71cb12 --- /dev/null +++ b/app/src/test/java/com/app/paperstow/data/backup/RoomDbFilesTest.kt @@ -0,0 +1,44 @@ +package com.app.paperstow.data.backup + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import java.io.File + +class RoomDbFilesTest { + @Test + fun `missing file is not a valid documents database`() { + val f = File.createTempFile("missing", ".db") + f.delete() + val check = RoomDbFiles.inspect(f) + assertFalse(check.validSqlite) + assertFalse(check.hasDocumentsTable) + } + + @Test + fun `tiny file is rejected before sqlite open`() { + val f = File.createTempFile("tiny", ".db") + f.writeBytes(ByteArray(20)) + try { + val check = RoomDbFiles.inspect(f) + assertFalse(check.validSqlite) + assertFalse(check.hasDocumentsTable) + } finally { + f.delete() + } + } + + @Test + fun `non sqlite header is rejected`() { + val f = File.createTempFile("text", ".db") + f.writeText("this is not a sqlite database file!!") + try { + val check = RoomDbFiles.inspect(f) + assertFalse(check.validSqlite) + assertFalse(check.hasDocumentsTable) + assertEquals("Not a SQLite file", check.error) + } finally { + f.delete() + } + } +} diff --git a/app/src/test/java/com/app/traveldocs/data/dicom/DicomParserTest.kt b/app/src/test/java/com/app/paperstow/data/dicom/DicomParserTest.kt similarity index 97% rename from app/src/test/java/com/app/traveldocs/data/dicom/DicomParserTest.kt rename to app/src/test/java/com/app/paperstow/data/dicom/DicomParserTest.kt index 5d13029..d04fee7 100644 --- a/app/src/test/java/com/app/traveldocs/data/dicom/DicomParserTest.kt +++ b/app/src/test/java/com/app/paperstow/data/dicom/DicomParserTest.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.dicom +package com.app.paperstow.data.dicom import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test diff --git a/app/src/test/java/com/app/traveldocs/data/importer/DocumentFormatValidatorTest.kt b/app/src/test/java/com/app/paperstow/data/importer/DocumentFormatValidatorTest.kt similarity index 87% rename from app/src/test/java/com/app/traveldocs/data/importer/DocumentFormatValidatorTest.kt rename to app/src/test/java/com/app/paperstow/data/importer/DocumentFormatValidatorTest.kt index 2995be5..3f5e07b 100644 --- a/app/src/test/java/com/app/traveldocs/data/importer/DocumentFormatValidatorTest.kt +++ b/app/src/test/java/com/app/paperstow/data/importer/DocumentFormatValidatorTest.kt @@ -1,6 +1,6 @@ -package com.app.traveldocs.data.importer +package com.app.paperstow.data.importer -import com.app.traveldocs.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentFormat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested @@ -164,24 +164,38 @@ class DocumentFormatValidatorTest { @Test fun `validates BMP magic bytes`() { val bmpHeader = byteArrayOf(0x42, 0x4D) + ByteArray(50) - assertEquals(com.app.traveldocs.domain.model.DocumentFormat.BMP, validator.detectFromMagicBytes(bmpHeader)) + assertEquals(com.app.paperstow.domain.model.DocumentFormat.BMP, validator.detectFromMagicBytes(bmpHeader)) } @Test fun `validates GIF magic bytes`() { val gifHeader = "GIF89a".toByteArray() + ByteArray(50) - assertEquals(com.app.traveldocs.domain.model.DocumentFormat.GIF, validator.detectFromMagicBytes(gifHeader)) + assertEquals(com.app.paperstow.domain.model.DocumentFormat.GIF, validator.detectFromMagicBytes(gifHeader)) } @Test fun `validates WebP magic bytes`() { val webp = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50) + ByteArray(50) - assertEquals(com.app.traveldocs.domain.model.DocumentFormat.WEBP, validator.detectFromMagicBytes(webp)) + assertEquals(com.app.paperstow.domain.model.DocumentFormat.WEBP, validator.detectFromMagicBytes(webp)) } @Test fun `UNKNOWN format always validates`() { - assertTrue(validator.validateContent(ByteArray(10), com.app.traveldocs.domain.model.DocumentFormat.UNKNOWN)) + assertTrue(validator.validateContent(ByteArray(10), com.app.paperstow.domain.model.DocumentFormat.UNKNOWN)) + } + + @Test + fun `detects markdown from file name`() { + assertEquals( + DocumentFormat.MARKDOWN, + validator.detect("# list\n- [ ] one\n".toByteArray(), "text/markdown", "plans.md") + ) + } + + @Test + fun `detects gpx from contents`() { + val gpx = """""" + assertEquals(DocumentFormat.GPX, validator.detect(gpx.toByteArray(), null, "walk.gpx")) } } \ No newline at end of file diff --git a/app/src/test/java/com/app/traveldocs/data/local/InputSanitizerTest.kt b/app/src/test/java/com/app/paperstow/data/local/InputSanitizerTest.kt similarity index 98% rename from app/src/test/java/com/app/traveldocs/data/local/InputSanitizerTest.kt rename to app/src/test/java/com/app/paperstow/data/local/InputSanitizerTest.kt index ca766c4..4131f33 100644 --- a/app/src/test/java/com/app/traveldocs/data/local/InputSanitizerTest.kt +++ b/app/src/test/java/com/app/paperstow/data/local/InputSanitizerTest.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.data.local +package com.app.paperstow.data.local import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test diff --git a/app/src/test/java/com/app/traveldocs/data/nlp/.gitkeep b/app/src/test/java/com/app/paperstow/data/nlp/.gitkeep similarity index 100% rename from app/src/test/java/com/app/traveldocs/data/nlp/.gitkeep rename to app/src/test/java/com/app/paperstow/data/nlp/.gitkeep diff --git a/app/src/test/java/com/app/traveldocs/data/nlp/BasicDocumentChecklistGeneratorTest.kt b/app/src/test/java/com/app/paperstow/data/nlp/BasicDocumentChecklistGeneratorTest.kt similarity index 96% rename from app/src/test/java/com/app/traveldocs/data/nlp/BasicDocumentChecklistGeneratorTest.kt rename to app/src/test/java/com/app/paperstow/data/nlp/BasicDocumentChecklistGeneratorTest.kt index 9ccb47d..9a60e17 100644 --- a/app/src/test/java/com/app/traveldocs/data/nlp/BasicDocumentChecklistGeneratorTest.kt +++ b/app/src/test/java/com/app/paperstow/data/nlp/BasicDocumentChecklistGeneratorTest.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.data.nlp - -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.RequiredDocument -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.model.TravelDocumentChecklist -import com.app.traveldocs.domain.model.TravelParameters +package com.app.paperstow.data.nlp + +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.RequiredDocument +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.model.TravelDocumentChecklist +import com.app.paperstow.domain.model.TravelParameters import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach diff --git a/app/src/test/java/com/app/traveldocs/data/tags/.gitkeep b/app/src/test/java/com/app/paperstow/data/tags/.gitkeep similarity index 100% rename from app/src/test/java/com/app/traveldocs/data/tags/.gitkeep rename to app/src/test/java/com/app/paperstow/data/tags/.gitkeep diff --git a/app/src/test/java/com/app/traveldocs/data/tags/AutoTagGeneratorImplTest.kt b/app/src/test/java/com/app/paperstow/data/tags/AutoTagGeneratorImplTest.kt similarity index 95% rename from app/src/test/java/com/app/traveldocs/data/tags/AutoTagGeneratorImplTest.kt rename to app/src/test/java/com/app/paperstow/data/tags/AutoTagGeneratorImplTest.kt index 9833e4b..818573b 100644 --- a/app/src/test/java/com/app/traveldocs/data/tags/AutoTagGeneratorImplTest.kt +++ b/app/src/test/java/com/app/paperstow/data/tags/AutoTagGeneratorImplTest.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs.data.tags +package com.app.paperstow.data.tags -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractedValue -import com.app.traveldocs.domain.model.MetadataField +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractedValue +import com.app.paperstow.domain.model.MetadataField import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach diff --git a/app/src/test/java/com/app/traveldocs/domain/model/.gitkeep b/app/src/test/java/com/app/paperstow/domain/model/.gitkeep similarity index 100% rename from app/src/test/java/com/app/traveldocs/domain/model/.gitkeep rename to app/src/test/java/com/app/paperstow/domain/model/.gitkeep diff --git a/app/src/test/java/com/app/traveldocs/domain/model/DocumentFormatTest.kt b/app/src/test/java/com/app/paperstow/domain/model/DocumentFormatTest.kt similarity index 82% rename from app/src/test/java/com/app/traveldocs/domain/model/DocumentFormatTest.kt rename to app/src/test/java/com/app/paperstow/domain/model/DocumentFormatTest.kt index ada3475..91578f0 100644 --- a/app/src/test/java/com/app/traveldocs/domain/model/DocumentFormatTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/model/DocumentFormatTest.kt @@ -1,4 +1,4 @@ -package com.app.traveldocs.domain.model +package com.app.paperstow.domain.model import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -11,6 +11,9 @@ class DocumentFormatTest { assertTrue("PDF" in formats) assertTrue("JPG" in formats) assertTrue("PNG" in formats) + assertTrue("MARKDOWN" in formats) + assertTrue("GPX" in formats) + assertTrue("TEXT" in formats) assertTrue("VIDEO" in formats) assertTrue("WEBP" in formats) assertTrue("HEIC" in formats) diff --git a/app/src/test/java/com/app/paperstow/domain/model/MarkdownChecklistTest.kt b/app/src/test/java/com/app/paperstow/domain/model/MarkdownChecklistTest.kt new file mode 100644 index 0000000..18a8d3e --- /dev/null +++ b/app/src/test/java/com/app/paperstow/domain/model/MarkdownChecklistTest.kt @@ -0,0 +1,36 @@ +package com.app.paperstow.domain.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MarkdownChecklistTest { + + @Test + fun `round trip keeps title notes and checks`() { + val original = MarkdownChecklist( + title = "Trip plans", + notes = "For John Doe and Jane Doe", + items = listOf( + ChecklistItem("Book flights", done = true), + ChecklistItem("Pack medicines", done = false) + ) + ) + val parsed = MarkdownChecklist.parse(original.toMarkdown()) + assertEquals(original.title, parsed.title) + assertEquals(original.notes, parsed.notes) + assertEquals(2, parsed.items.size) + assertTrue(parsed.items[0].done) + assertEquals("Book flights", parsed.items[0].text) + assertFalse(parsed.items[1].done) + } + + @Test + fun `plain bullets become open items`() { + val parsed = MarkdownChecklist.parse("# Packing\n\n- Passport\n* Tickets\n") + assertEquals("Packing", parsed.title) + assertEquals(listOf("Passport", "Tickets"), parsed.items.map { it.text }) + assertTrue(parsed.items.none { it.done }) + } +} diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/ChecklistGenerationPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/ChecklistGenerationPropertyTest.kt similarity index 95% rename from app/src/test/java/com/app/traveldocs/domain/properties/ChecklistGenerationPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/ChecklistGenerationPropertyTest.kt index bd4e8d1..d3211a0 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/ChecklistGenerationPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/ChecklistGenerationPropertyTest.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.nlp.BasicDocumentChecklistGenerator -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.TravelParameters +import com.app.paperstow.data.nlp.BasicDocumentChecklistGenerator +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.TravelParameters import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.element diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/DateRangeTagPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/DateRangeTagPropertyTest.kt similarity index 96% rename from app/src/test/java/com/app/traveldocs/domain/properties/DateRangeTagPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/DateRangeTagPropertyTest.kt index edddfd4..90367ec 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/DateRangeTagPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/DateRangeTagPropertyTest.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.tags.AutoTagGeneratorImpl -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractedValue -import com.app.traveldocs.domain.model.MetadataField +import com.app.paperstow.data.tags.AutoTagGeneratorImpl +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractedValue +import com.app.paperstow.domain.model.MetadataField import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.enum diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/DestinationTagPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/DestinationTagPropertyTest.kt similarity index 94% rename from app/src/test/java/com/app/traveldocs/domain/properties/DestinationTagPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/DestinationTagPropertyTest.kt index d98c372..431f3d2 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/DestinationTagPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/DestinationTagPropertyTest.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.tags.AutoTagGeneratorImpl -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractedValue -import com.app.traveldocs.domain.model.MetadataField +import com.app.paperstow.data.tags.AutoTagGeneratorImpl +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractedValue +import com.app.paperstow.domain.model.MetadataField import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.enum diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/DocumentTypeToTagMappingPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/DocumentTypeToTagMappingPropertyTest.kt similarity index 94% rename from app/src/test/java/com/app/traveldocs/domain/properties/DocumentTypeToTagMappingPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/DocumentTypeToTagMappingPropertyTest.kt index f889c9f..4528682 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/DocumentTypeToTagMappingPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/DocumentTypeToTagMappingPropertyTest.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.tags.AutoTagGeneratorImpl -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.ExtractedValue -import com.app.traveldocs.domain.model.MetadataField +import com.app.paperstow.data.tags.AutoTagGeneratorImpl +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.ExtractedValue +import com.app.paperstow.domain.model.MetadataField import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.element diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/FamilySizeScalingPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/FamilySizeScalingPropertyTest.kt similarity index 96% rename from app/src/test/java/com/app/traveldocs/domain/properties/FamilySizeScalingPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/FamilySizeScalingPropertyTest.kt index 4565e24..1b3e980 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/FamilySizeScalingPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/FamilySizeScalingPropertyTest.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.nlp.BasicDocumentChecklistGenerator -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.TravelParameters +import com.app.paperstow.data.nlp.BasicDocumentChecklistGenerator +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.TravelParameters import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.element diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/GlobalTagDeletionPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/GlobalTagDeletionPropertyTest.kt similarity index 97% rename from app/src/test/java/com/app/traveldocs/domain/properties/GlobalTagDeletionPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/GlobalTagDeletionPropertyTest.kt index b84885e..79ebbbb 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/GlobalTagDeletionPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/GlobalTagDeletionPropertyTest.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.local.TagRepositoryImpl -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.entity.DocumentTagEntity +import com.app.paperstow.data.local.TagRepositoryImpl +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.entity.DocumentTagEntity import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.int diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/MissingDocDetectionPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/MissingDocDetectionPropertyTest.kt similarity index 95% rename from app/src/test/java/com/app/traveldocs/domain/properties/MissingDocDetectionPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/MissingDocDetectionPropertyTest.kt index c5106b1..650ff1e 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/MissingDocDetectionPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/MissingDocDetectionPropertyTest.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.domain.properties - -import com.app.traveldocs.data.nlp.BasicDocumentChecklistGenerator -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.RequiredDocument -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.model.TravelDocumentChecklist +package com.app.paperstow.domain.properties + +import com.app.paperstow.data.nlp.BasicDocumentChecklistGenerator +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.RequiredDocument +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.model.TravelDocumentChecklist import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.element diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/MissingDocSuggestionsPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/MissingDocSuggestionsPropertyTest.kt similarity index 85% rename from app/src/test/java/com/app/traveldocs/domain/properties/MissingDocSuggestionsPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/MissingDocSuggestionsPropertyTest.kt index 2fe366f..2dbad07 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/MissingDocSuggestionsPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/MissingDocSuggestionsPropertyTest.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.nlp.BasicDocumentChecklistGenerator -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.RequiredDocument -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.model.TravelDocumentChecklist +import com.app.paperstow.data.nlp.BasicDocumentChecklistGenerator +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.RequiredDocument +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.model.TravelDocumentChecklist import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.element diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/MissingDocumentDetectionPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/MissingDocumentDetectionPropertyTest.kt similarity index 95% rename from app/src/test/java/com/app/traveldocs/domain/properties/MissingDocumentDetectionPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/MissingDocumentDetectionPropertyTest.kt index 6adbe59..6c1a00d 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/MissingDocumentDetectionPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/MissingDocumentDetectionPropertyTest.kt @@ -1,12 +1,12 @@ -package com.app.traveldocs.domain.properties - -import com.app.traveldocs.data.nlp.BasicDocumentChecklistGenerator -import com.app.traveldocs.domain.model.Document -import com.app.traveldocs.domain.model.DocumentFormat -import com.app.traveldocs.domain.model.DocumentType -import com.app.traveldocs.domain.model.RequiredDocument -import com.app.traveldocs.domain.model.Tag -import com.app.traveldocs.domain.model.TravelDocumentChecklist +package com.app.paperstow.domain.properties + +import com.app.paperstow.data.nlp.BasicDocumentChecklistGenerator +import com.app.paperstow.domain.model.Document +import com.app.paperstow.domain.model.DocumentFormat +import com.app.paperstow.domain.model.DocumentType +import com.app.paperstow.domain.model.RequiredDocument +import com.app.paperstow.domain.model.Tag +import com.app.paperstow.domain.model.TravelDocumentChecklist import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.element diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/TagAddGrowsListPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/TagAddGrowsListPropertyTest.kt similarity index 97% rename from app/src/test/java/com/app/traveldocs/domain/properties/TagAddGrowsListPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/TagAddGrowsListPropertyTest.kt index 0162755..30b19ec 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/TagAddGrowsListPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/TagAddGrowsListPropertyTest.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.local.TagRepositoryImpl -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.entity.DocumentTagEntity +import com.app.paperstow.data.local.TagRepositoryImpl +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.entity.DocumentTagEntity import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.string diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/TagDeduplicationPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/TagDeduplicationPropertyTest.kt similarity index 95% rename from app/src/test/java/com/app/traveldocs/domain/properties/TagDeduplicationPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/TagDeduplicationPropertyTest.kt index df8efaa..c7d25c6 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/TagDeduplicationPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/TagDeduplicationPropertyTest.kt @@ -1,9 +1,9 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.local.TagRepositoryImpl -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.entity.DocumentTagEntity -import com.app.traveldocs.domain.model.Tag +import com.app.paperstow.data.local.TagRepositoryImpl +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.entity.DocumentTagEntity +import com.app.paperstow.domain.model.Tag import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.int diff --git a/app/src/test/java/com/app/traveldocs/domain/properties/TagRemoveShrinksListPropertyTest.kt b/app/src/test/java/com/app/paperstow/domain/properties/TagRemoveShrinksListPropertyTest.kt similarity index 96% rename from app/src/test/java/com/app/traveldocs/domain/properties/TagRemoveShrinksListPropertyTest.kt rename to app/src/test/java/com/app/paperstow/domain/properties/TagRemoveShrinksListPropertyTest.kt index 8c8ee9e..40769db 100644 --- a/app/src/test/java/com/app/traveldocs/domain/properties/TagRemoveShrinksListPropertyTest.kt +++ b/app/src/test/java/com/app/paperstow/domain/properties/TagRemoveShrinksListPropertyTest.kt @@ -1,8 +1,8 @@ -package com.app.traveldocs.domain.properties +package com.app.paperstow.domain.properties -import com.app.traveldocs.data.local.TagRepositoryImpl -import com.app.traveldocs.data.local.dao.DocumentTagDao -import com.app.traveldocs.data.local.entity.DocumentTagEntity +import com.app.paperstow.data.local.TagRepositoryImpl +import com.app.paperstow.data.local.dao.DocumentTagDao +import com.app.paperstow.data.local.entity.DocumentTagEntity import io.kotest.property.Arb import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.int diff --git a/app/src/test/java/com/app/paperstow/domain/safety/GpxTrailTest.kt b/app/src/test/java/com/app/paperstow/domain/safety/GpxTrailTest.kt new file mode 100644 index 0000000..50e242c --- /dev/null +++ b/app/src/test/java/com/app/paperstow/domain/safety/GpxTrailTest.kt @@ -0,0 +1,29 @@ +package com.app.paperstow.domain.safety + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GpxTrailTest { + + @Test + fun `round trip keeps coordinates time and battery`() { + val places = listOf( + SafetyPlace(41.5, -72.5, 1_717_200_000_000, 84), + SafetyPlace(41.501, -72.498, 1_717_200_600_000, 81) + ) + val parsed = GpxTrail.parse(GpxTrail.toGpx(places)) + assertEquals(2, parsed.size) + assertEquals(41.5, parsed[0].latitude, 0.00001) + assertEquals(-72.5, parsed[0].longitude, 0.00001) + assertEquals(84, parsed[0].batteryPercent) + assertEquals(81, parsed[1].batteryPercent) + assertEquals(places[0].timestamp, parsed[0].timestamp) + } + + @Test + fun `looksLikeGpx detects header`() { + val bytes = GpxTrail.toGpx(emptyList()).toByteArray() + assertTrue(GpxTrail.looksLikeGpx(bytes)) + } +} diff --git a/app/src/test/java/com/app/paperstow/domain/safety/UniqueLocationsTest.kt b/app/src/test/java/com/app/paperstow/domain/safety/UniqueLocationsTest.kt new file mode 100644 index 0000000..a47dcf2 --- /dev/null +++ b/app/src/test/java/com/app/paperstow/domain/safety/UniqueLocationsTest.kt @@ -0,0 +1,99 @@ +package com.app.paperstow.domain.safety + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.Locale +import java.util.TimeZone + +class UniqueLocationsTest { + + @Test + fun `same coordinates are not a new place`() { + assertFalse(UniqueLocations.isNewPlace(10.0, 77.0, 10.0, 77.0)) + } + + @Test + fun `one-degree latitude is far more than 150 meters`() { + assertTrue(UniqueLocations.isNewPlace(0.0, 0.0, 1.0, 0.0)) + } + + @Test + fun `about 150 meters of latitude counts as a new place`() { + // Latitude-only haversine is R * Δrad; R matches UniqueLocations (6_371_000 m). + val metersPerDegree = 6_371_000.0 * Math.PI / 180.0 + val delta = UniqueLocations.MIN_SEPARATION_METERS / metersPerDegree + assertTrue(UniqueLocations.isNewPlace(0.0, 0.0, delta, 0.0)) + assertFalse(UniqueLocations.isNewPlace(0.0, 0.0, delta * 0.4, 0.0)) + } + + @Test + fun `haversine is symmetric`() { + val a = UniqueLocations.metersBetween(37.7749, -122.4194, 37.8044, -122.2711) + val b = UniqueLocations.metersBetween(37.8044, -122.2711, 37.7749, -122.4194) + assertEquals(a, b, 0.01) + assertTrue(a > 10_000) + } + + @Test + fun `inWindow drops points older than 24 hours`() { + val now = 1_700_000_000_000L + val places = listOf( + SafetyPlace(1.0, 2.0, now - 1_000), + SafetyPlace(3.0, 4.0, now - UniqueLocations.WINDOW_MS - 1) + ) + val kept = UniqueLocations.inWindow(places, now) + assertEquals(1, kept.size) + assertEquals(1.0, kept[0].latitude) + } + + @Test + fun `shareAll formats last known and earlier maps links`() { + val noon = 1_704_110_400_000L // 2024-01-01 12:00 UTC + val earlier = noon - 3 * 60 * 60 * 1000 + val text = UniqueLocations.shareAll( + listOf( + SafetyPlace(10.0, 20.0, noon), + SafetyPlace(11.0, 21.0, earlier) + ), + locale = Locale.US, + timeZone = TimeZone.getTimeZone("UTC") + ) + assertTrue(text.contains("Last known (12:00 PM):")) + assertTrue(text.contains("https://maps.google.com/?q=10.0,20.0")) + assertTrue(text.contains("Earlier:")) + assertTrue(text.contains("https://maps.google.com/?q=11.0,21.0")) + } + + @Test + fun `shareAll empty list is a clear message`() { + val text = UniqueLocations.shareAll(emptyList()) + assertTrue(text.contains("no unique places")) + } + + @Test + fun `shareLastKnown is a short maps link`() { + val noon = 1_704_110_400_000L + val text = UniqueLocations.shareLastKnown( + SafetyPlace(37.5, -122.1, noon), + locale = Locale.US, + timeZone = TimeZone.getTimeZone("UTC") + ) + assertTrue(text.startsWith("Paperstow — last known place")) + assertTrue(text.contains("https://maps.google.com/?q=37.5,-122.1")) + } + + @Test + fun `share includes battery when recorded`() { + val noon = 1_704_110_400_000L + val text = UniqueLocations.shareLastKnown( + SafetyPlace(37.5, -122.1, noon, batteryPercent = 64), + locale = Locale.US, + timeZone = TimeZone.getTimeZone("UTC") + ) + assertTrue(text.contains("Battery 64%")) + assertEquals("Battery 64%", UniqueLocations.batteryLabel(64)) + assertEquals(null, UniqueLocations.batteryLabel(-1)) + } +} diff --git a/app/src/test/java/com/app/traveldocs/domain/usecase/.gitkeep b/app/src/test/java/com/app/paperstow/domain/usecase/.gitkeep similarity index 100% rename from app/src/test/java/com/app/traveldocs/domain/usecase/.gitkeep rename to app/src/test/java/com/app/paperstow/domain/usecase/.gitkeep diff --git a/app/src/test/java/com/app/traveldocs/generators/.gitkeep b/app/src/test/java/com/app/paperstow/generators/.gitkeep similarity index 100% rename from app/src/test/java/com/app/traveldocs/generators/.gitkeep rename to app/src/test/java/com/app/paperstow/generators/.gitkeep diff --git a/app/src/test/java/com/app/paperstow/presentation/about/SbomParseTest.kt b/app/src/test/java/com/app/paperstow/presentation/about/SbomParseTest.kt new file mode 100644 index 0000000..203a1bd --- /dev/null +++ b/app/src/test/java/com/app/paperstow/presentation/about/SbomParseTest.kt @@ -0,0 +1,33 @@ +package com.app.paperstow.presentation.about + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SbomParseTest { + @Test + fun `parses cyclonedx metadata and components`() { + val json = """ + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "metadata": { + "timestamp": "2026-09-22T22:00:00Z", + "component": { "name": "Paperstow", "version": "1.1.0" }, + "properties": [ + { "name": "build:variant", "value": "debug" } + ] + }, + "components": [ + { "group": "androidx.core", "name": "core-ktx", "version": "1.18.0", "purl": "pkg:maven/androidx.core/core-ktx@1.18.0" } + ] + } + """.trimIndent() + val sbom = parseSbom(json) + assertEquals("1.5", sbom.specVersion) + assertEquals("Paperstow", sbom.appName) + assertEquals("1.1.0", sbom.appVersion) + assertEquals("debug", sbom.variant) + assertEquals(1, sbom.components.size) + assertEquals("core-ktx", sbom.components[0].name) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f14e115..b724817 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,8 @@ plugins { - id("com.android.application") version "8.5.0" apply false - id("com.android.library") version "8.5.0" apply false - id("org.jetbrains.kotlin.android") version "1.9.22" apply false - id("com.google.dagger.hilt.android") version "2.50" apply false - id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 56cfe02..488e435 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,7 +23,7 @@ │ │ GPS Service │ └──────────────────────────────────────────┘ │ │ └───────────────┘ │ ├─────────────────────────────────────────────────────────────────┤ -│ Storage: /data/data/com.app.traveldocs/ │ +│ Storage: /data/data/com.app.paperstow/ │ │ ├── databases/traveldocs.db (Room, unencrypted) │ │ ├── files/docs/{member}/{id}.enc (AES-256-GCM encrypted) │ │ ├── files/debug_logs/ (Debug log file) │ @@ -76,8 +76,8 @@ Jetpack Compose screens + Hilt ViewModels. ``` presentation/ ├── MainActivity.kt → Navigation host, screen router, biometric gate, pull-to-refresh -├── auth/ → BiometricAuthScreen, PinEntryScreen -├── onboarding/ → DisclaimerScreen, ConsentScreen +├── auth/ → BiometricAuthScreen +├── onboarding/ → DisclaimerScreen, SplashScreen, EulaScreen ├── documents/ → ImportScreen, DocumentListScreen, DocumentViewerScreen │ BatchImportViewModel, CameraImportViewModel ├── search/ → SearchScreen, SearchViewModel @@ -329,7 +329,7 @@ Gating rules: ## File System Layout ``` -/data/data/com.app.traveldocs/ +/data/data/com.app.paperstow/ ├── databases/ │ └── traveldocs.db # Room database ├── files/ diff --git a/docs/HACKATHON.md b/docs/HACKATHON.md index b4807af..d6e7d48 100644 --- a/docs/HACKATHON.md +++ b/docs/HACKATHON.md @@ -50,7 +50,7 @@ The `.kiro/` directory contains the full spec trail. | Document Import | Single file, camera scan, local folders with recursive subfolder tagging | | On-device OCR | ML Kit text recognition for automatic document classification | | Encrypted Storage | AES-256-GCM per-file, keys in Android KeyStore, biometric auth | -| Per-Document PIN | Additional PBKDF2-derived encryption per document (non-recoverable) | +| Device lock | Fingerprint, face, or screen lock — no per-document PIN | | Tag Organization | Auto-tagging from OCR + folder structure, color-coded, folder view on home | | Search | Free-text search across all documents | | Sharing | Android share sheet with proper URI permission grants | @@ -120,7 +120,7 @@ Kotlin 1.9.22 · Jetpack Compose · Material 3 · Hilt · Room 2.6.1 · ML Kit 1. **Privacy as architecture** — Encryption is the default state. No network calls for core functionality. Zero data leaves the device without explicit user action. 2. **DICOM from scratch** — Custom medical image parser (all existing Android DICOM libs are GPL incompatible). 3. **Spec-driven development** — 50 requirements, 70 task groups, component interfaces defined before implementation. Full `.kiro/` trail. -4. **Production details** — EULA, crash reporting, input sanitization, Android Auto, per-document PIN, Play Store compliance review. +4. **Production details** — EULA, crash reporting, input sanitization, Play Store compliance review. 5. **Built with AI, designed by human** — All processing happens locally. Classification errors documented honestly. Community invited to improve. --- diff --git a/docs/KIRO_GENERATION_PROMPT.md b/docs/KIRO_GENERATION_PROMPT.md index 1506b41..c5de7de 100644 --- a/docs/KIRO_GENERATION_PROMPT.md +++ b/docs/KIRO_GENERATION_PROMPT.md @@ -17,7 +17,7 @@ A privacy-first, offline-first document management app. No backend server. No cl - Domain layer: pure Kotlin, zero Android imports except android.net.Uri - Data layer: Room, ML Kit, AES-256-GCM file encryption, Hilt DI - Presentation: Jetpack Compose + Material 3, single Activity, state-driven navigation -- Package: com.app.traveldocs +- Package: com.app.paperstow - Min SDK: 26, Target SDK: 34, ABI: arm64-v8a only - JDK 17 (Zulu), Gradle 8.5, AGP 8.2.2 @@ -25,9 +25,8 @@ A privacy-first, offline-first document management app. No backend server. No cl - AES-256-GCM per-file encryption with Android KeyStore (alias: "travel_docs_file_encryption_key") - File format: [12-byte IV][AES-GCM ciphertext] stored as {fileId}.enc - Biometric authentication on every launch (BiometricPrompt, BIOMETRIC_STRONG | DEVICE_CREDENTIAL) -- Per-document optional PIN using PBKDF2-SHA256 (10K iterations) — non-recoverable by design - Input sanitization: tags max 50 chars [a-zA-Z0-9 _-], filenames strip /\:*?"<>| -- No global PIN or password — biometrics only for app access +- No per-document PIN and no app PIN — biometrics / device lock only for app access - FileProvider for all file sharing (never file:// URIs) - DebugLogger writes to background thread (never blocks main thread) @@ -47,7 +46,7 @@ EULA → Splash → Disclaimer (telemetry consent defaults ON) → Biometric Aut ### Home Screen - Title: "My Private Documents" (user-customizable in Settings) - Folders organized by tags (3-column grid) -- Special folders: "Protected" (lock icon, listed first for __PIN_PROTECTED docs), "Untagged" +- Special folders: "Untagged" - Pull-to-refresh with explicit Refresh button in top bar - Gear menu: About, Feedback (opens GitHub Issues in browser), Review & Classify, Settings, Tags, Reset App - Experimental menu items gated by feature flags @@ -67,7 +66,6 @@ EULA → Splash → Disclaimer (telemetry consent defaults ON) → Biometric Aut - Images: async decode with subsampling >4096px, pinch-to-zoom + pan - Properties collapsed by default ("Show Properties" button at bottom) - Share button in top bar: ACTION_SEND with grantUriPermission to all resolvers -- PIN lock: shows lock icon until correct PIN entered - Swipe up/down for next/previous doc in current folder ### Settings Screen @@ -90,10 +88,8 @@ EULA → Splash → Disclaimer (telemetry consent defaults ON) → Biometric Aut 4. Tag-based organization with color picker (12-color palette) 5. Free-text and tag-based search 6. Biometric auth -7. Per-document PIN lock/unlock/change (3-step change flow with consent) -8. Document sharing via Android share sheet -9. "Protected" folder for PIN-locked docs -10. Dark theme +7. Document sharing via Android share sheet +8. Dark theme 11. EULA with legal sections: warranties, liability, indemnification, pricing discretion, AI usage disclosure 12. Feedback → opens https://github.com/[owner]/document-manager/issues 13. Review & Classify: batch tag untagged docs, mark OCR review complete @@ -115,7 +111,6 @@ EULA → Splash → Disclaimer (telemetry consent defaults ON) → Biometric Aut 9. Import from URL: GitHub blob→raw URL conversion, auto-format detection ## System Tags (__ prefix — hidden from UI, non-removable by user) -- __PIN_PROTECTED: document has per-document PIN set - __AUDIO: audio file (MP3/M4A/etc) - __UNSUPPORTED: unknown/unsupported file format - Store these in document_tags table but filter from tag manager and tag chips display @@ -126,8 +121,6 @@ Each needs extension mapping, MIME type mapping, viewer handling, and share hand ## EULA Requirements 14+ sections covering: acceptance, license grant, warranty disclaimer (AS-IS), liability cap ($0), user responsibility and compliance, data/privacy, encryption notice (non-recoverable keys), indemnification, prohibited uses, termination, modifications without notice, pricing discretion, AI usage disclosure, governing law. -Per-document PIN section must state: non-recoverable, developer cannot decrypt, user must comply with laws, app developer has zero control. - ## Debug & Telemetry - DebugLogger: async file writes via single-thread executor, 3 destinations (logcat, memory buffer, file) - File: files/debug_logs/traveldocs_debug.log (5MB rotation) diff --git a/docs/PLAY_STORE.md b/docs/PLAY_STORE.md new file mode 100644 index 0000000..eb50af3 --- /dev/null +++ b/docs/PLAY_STORE.md @@ -0,0 +1,117 @@ +# Play Console checklist — Paperstow 1.1.0 (versionCode 3) + +## Store listing + +**Title (9 / 30):** `Paperstow` + +Package stays `com.app.paperstow`. Do not rename the applicationId. + +**Do not use these titles** + +| Avoid | Why | +|-------|-----| +| DocVault | OPPO/ColorOS `com.os.docvault` (100K+) and many clones | +| TravelDocs | Taken | +| TravelVault | Taken (`TravelVault: Trip Organizer`) | +| Travel Document Vault | Exact competitor | +| Dokomo / Docomo | NTT Docomo impersonation / trademark | +| Offpapers | Offpaper GmbH is already on Play | + +**Short description (73 / 80):** + +``` +Stow family passports, visas, tickets. OCR on-device. Encrypted. No cloud. +``` + +**Full description** (paste into Play Console; no Auto, GPS, medical, expiry, Schengen, or cloud-backup claims): + +``` +Paperstow is where the family stows travel papers: passports, visas, boarding passes, hotel bookings, and insurance. + +They live on this phone, encrypted, and stay searchable at the gate — even in airplane mode. No Paperstow account. No Paperstow servers. + +Import from the camera, a single file, or a whole folder. On-device OCR reads the page, suggests a type (passport, visa, ticket, and similar), and tags it. Search by name, tag, or words from the page. If you already keep scans in folders on the phone, subfolder names become tags. + +When a family member needs a copy, share through Android’s share sheet. When you change phones, archive everything as a password-protected ZIP to a folder you pick, then restore it. + +• AES-256-GCM encryption; keys stay in Android KeyStore +• Unlock with this device’s fingerprint, face, or screen lock +• Search tags and extracted text +• Folder import with subfolder tags +• Password-protected ZIP archive and restore +• No ads. No document cap. Open source (Apache 2.0) + +Documents never leave the phone unless you share or export them. + +Digital copies do not replace originals. Paperstow is not a government or border-control app and does not give immigration advice. Check entry rules with official sources. +``` + +**Category:** Tools (or Productivity). Not Travel & Local — that shelf is trip planners. + +## Android Auto (the current rejection) + +Play rejected version code 2 because reviewers opened Android Auto and the media browser failed. + +This release **removes Android Auto completely**: +- No `MediaBrowserService` +- No `com.google.android.gms.car.application` metadata +- Manifest uses `tools:node="remove"` so libraries cannot merge Auto back in +- `androidx.media` dependency removed + +### You must still do this in Play Console + +Google requires the compliant AAB on **every track**, not only Production. + +1. Open each track that still has version code 2: Internal / Closed / Open / Production. +2. Create a new release (or edit a draft). Discard any draft that still contains the old bundle. +3. Upload `app/build/outputs/bundle/release/app-release.aab` (versionCode **3**). +4. Confirm version code 2 is under **Not included**. +5. Save → Review release → roll out to 100%. +6. Repeat for every testing track. +7. In Play Console, turn off / leave the **Android Auto** program if it is still enrolled. Do not claim Android Auto in the listing. + +## Data safety form + +- Location: **no** +- Photos / files: user-provided, stored on device +- Shared with third parties: **no** +- Collected: none, except optional local telemetry the user can turn on +- Encryption in transit: only if the user exports a password-protected ZIP +- ML Kit: on-device; optional model download + +## Permissions (must match the binary) + +Declared and used: +- Camera — Take Photo only +- Internet — optional ML Kit OCR model refresh; documents are not uploaded + +Explicitly removed from the merged manifest: +- Location (fine/coarse/background) +- Foreground service / location FGS +- Wi-Fi state +- Notifications +- Android Auto (`com.google.android.gms.car.application`) +- Automotive hardware feature + +Do **not** declare location, SMS, Android Auto, or media-read in Play Console. + +## Privacy policy URL + +In-app and Console: `https://sethusrinivasan.github.io/document-manager/privacy.html` + +Enable GitHub Pages for this repo (Settings → Pages → Deploy from branch `main` / `/docs`). + +## Screenshots + +Use import, tags, preview, search, and settings only. Do not show Experimental Features, WiFi Share, GPS, or Android Auto. + +## Release signing + +Set these before `./gradlew bundleRelease`: + +``` +export DOCVAULT_KEYSTORE=$HOME/release.keystore +export DOCVAULT_STORE_PASSWORD=... +export DOCVAULT_KEY_ALIAS=upload_key +export DOCVAULT_KEY_PASSWORD=... +``` diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md index 1db9d3b..ed6cfb5 100644 --- a/docs/PRIVACY_POLICY.md +++ b/docs/PRIVACY_POLICY.md @@ -1,61 +1,48 @@ -# Privacy Policy — Document Manager +# Privacy Policy — Paperstow -**Last updated:** 2025-01-01 +**Last updated:** 2026-09-21 ## Overview -Document Manager ("the App") is a document storage and organization app for Android. We respect your privacy and are committed to protecting your personal data. +Paperstow ("the App") is a local vault for travel papers on Android. Passports, visas, tickets, and similar files stay on your device. The App has no backend server and does not create an account. -## Data Collection +## Data collection -### What we collect (with your consent) -- **Anonymous usage telemetry**: Feature usage counts (e.g., "import used 5 times") to improve the app. No document content is collected. This is opt-in only. +### Optional, on-device only +- **Usage telemetry** (opt-in): anonymous feature-use counts such as "import used 5 times." No document content, file names, or personal identifiers. Stored locally. Nothing is sent unless you choose to share a report. -### What we do NOT collect +### What we do not collect - Document content or files - Personal identification information -- Location history (GPS data stays on-device only) -- Photos or camera data +- Location or GPS history - Contacts or phone numbers +- Photos except those you explicitly import -## Data Storage +## Data storage -All documents, metadata, and settings are stored **locally on your device only**. The App does not have a backend server and does not transmit your documents anywhere unless you explicitly use the Share, Backup, or Export features. +All documents, metadata, and settings are stored **locally on your device**. Files are encrypted with AES-256-GCM using keys in Android KeyStore. The App does not transmit documents unless you use Android's share sheet or export a password-protected archive to a folder you choose. ## Permissions -| Permission | Purpose | When Requested | +| Permission | Purpose | When requested | |-----------|---------|----------------| -| Camera | Scan documents using phone camera | When you tap "Take Photo" | -| Location | Tag documents with import location; GPS tracking (opt-in) | When you first import a document | -| Internet | Google Drive backup/restore, S3 backup (opt-in) | When you configure cloud backup | -| Notifications | GPS tracking indicator, crash report notification | When GPS tracking is enabled | +| Camera | Scan a document | When you tap Take Photo | +| Internet | Optional first-run download of the on-device ML Kit OCR model | Not prompted; used only if the model is not already present | -All permissions are requested at the time of use, not on first launch. Denying any permission does not break core app functionality. +Denying camera permission does not break file or folder import, viewing, search, or share. -## Third-Party Services +## Third-party libraries -- **Google Drive API** (optional): Used only when you choose to backup/restore via Google Drive. Only accesses files created by this app. -- **ML Kit** (on-device): Text recognition runs entirely on your device. No data is sent to Google servers. +- **ML Kit Text Recognition** (on-device): OCR runs on your device. Google's ML Kit may download its recognition model on first use. Document images are not uploaded to a Paperstow server. -## Data Sharing +## Data sharing -We do not sell, trade, or share your data with any third parties. When you use the Share feature, data is shared only with the app you choose (email, WhatsApp, etc.) via Android's standard share mechanism. +We do not sell or share your data. If you use Share or Archive, you choose the destination app or folder. That destination is then subject to its own policies. -## Security +## Children's privacy -- Documents are encrypted with AES-256-GCM using keys stored in Android KeyStore (hardware-backed) -- Biometric authentication protects access to the app -- Temporary shared files are cleaned up automatically - -## Children's Privacy - -This app is not directed at children under 13 and does not knowingly collect data from children. - -## Changes - -We may update this policy. Changes will be noted in the app's About section. +This App is not directed at children under 13 and does not knowingly collect data from children. ## Contact -For questions about this privacy policy, contact via: https://github.com/sethusrinivasan/document-manager/issues +https://github.com/sethusrinivasan/document-manager/issues diff --git a/docs/THIRD_PARTY_LICENSES.md b/docs/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..7bd4a85 --- /dev/null +++ b/docs/THIRD_PARTY_LICENSES.md @@ -0,0 +1,63 @@ +# Third-party licenses + +Paperstow source is Apache License 2.0. See [LICENSE](../LICENSE) and [NOTICE](../NOTICE). + +Nothing below copylefts the app. Apache 2.0 and MIT are compatible. Google ML Kit / Play services are proprietary SDKs used at runtime; they stay under Google’s terms. JUnit’s EPL-2.0 applies only to tests. + +## Production (in the APK / AAB) + +| Component | Version pin | License | Notes | +|-----------|-------------|---------|--------| +| Paperstow | 1.1.0 | Apache-2.0 | This repository | +| Android Gradle Plugin | 8.13.2 | Apache-2.0 | Build | +| Kotlin / Compose compiler | 2.2.21 | Apache-2.0 | | +| KSP | 2.2.21-2.0.5 | Apache-2.0 | Build | +| AndroidX Core KTX | 1.18.0 | Apache-2.0 | Last line on AGP 8 / compileSdk 36 | +| AndroidX Activity | 1.13.0 | Apache-2.0 | | +| AndroidX Lifecycle | 2.10.0 | Apache-2.0 | Last line on AGP 8 / compileSdk 36 | +| AndroidX Navigation | 2.9.8 | Apache-2.0 | Last line on AGP 8 / compileSdk 36 | +| AndroidX Hilt Navigation | 1.3.0 | Apache-2.0 | Last line on AGP 8 / compileSdk 36 | +| Compose BOM | 2026.06.01 | Apache-2.0 | Compose UI 1.11.x; 2026.08+ needs AGP 9 | +| AndroidX Room | 2.8.5 | Apache-2.0 | | +| AndroidX Biometric | 1.1.0 | Apache-2.0 | | +| AndroidX DocumentFile | 1.1.0 | Apache-2.0 | | +| AndroidX Security Crypto | 1.1.0 | Apache-2.0 | | +| Dagger Hilt | 2.58 | Apache-2.0 | | +| kotlinx.coroutines | 1.11.0 | Apache-2.0 | | +| Zip4j | 2.11.6 | Apache-2.0 | Password ZIP archive | +| Gson | 2.14.0 | Apache-2.0 | Forced over Tink’s 2.8.9 | +| Tink Android | 1.8.0 (transitive) | Apache-2.0 | Via security-crypto | +| JSpecify | 1.0.0 (transitive) | Apache-2.0 | | +| Bouncy Castle `bcprov-jdk18on` | 1.86 | [Bouncy Castle Licence](https://www.bouncycastle.org/licence.html) (MIT-style) | Argon2id / HKDF | +| ML Kit Text Recognition | 16.0.1 | [ML Kit Terms](https://developers.google.com/ml-kit/terms) | On-device OCR | +| Play services ML Kit Document Scanner | 16.0.0 | [ML Kit Terms](https://developers.google.com/ml-kit/terms) | On-device scan UI | +| Google Play services (base, basement, tasks, …) | transitive | [Android SDK License](https://developer.android.com/studio/terms) | Pulled by ML Kit | + +Packaging excludes duplicate `META-INF/NOTICE`, `AL2.0`, and `LGPL2.1` files from AARs so the APK merges. That does **not** mean Paperstow is LGPL. Attribution for Apache components is this file and [NOTICE](../NOTICE). + +## Test / CI only (not shipped) + +| Component | Version pin | License | +|-----------|-------------|---------| +| JUnit BOM / Jupiter / Platform | 5.14.4 | EPL-2.0 | +| Kotest | 6.2.5 | Apache-2.0 | +| MockK | 1.14.11 | Apache-2.0 | +| AndroidX Test JUnit / Espresso | 1.3.0 / 3.7.0 | Apache-2.0 | +| Compose UI Test | via BOM | Apache-2.0 | + +## Why the project license stays Apache 2.0 + +- Direct open-source libraries are Apache-2.0 or MIT-style. +- MIT (Bouncy Castle) is compatible with Apache-2.0; the copyright and permission notice are reproduced in [NOTICE](../NOTICE). +- ML Kit and Play services are binary SDKs with Google terms. They do not relicense Paperstow source. +- EPL-2.0 JUnit is test-only and is not linked into release builds. + +## Bouncy Castle Licence (verbatim) + +Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/brand/paperstow-icon-1024.png b/docs/brand/paperstow-icon-1024.png new file mode 100644 index 0000000..d05dce8 Binary files /dev/null and b/docs/brand/paperstow-icon-1024.png differ diff --git a/docs/privacy.html b/docs/privacy.html new file mode 100644 index 0000000..18a5acd --- /dev/null +++ b/docs/privacy.html @@ -0,0 +1,40 @@ + + + + + + Privacy Policy — Paperstow + + + +

Privacy Policy — Paperstow

+

Last updated: 21 September 2026

+

Paperstow ("the App") is a local vault for travel papers on Android. Passports, visas, tickets, and similar files stay on your device. The App has no backend server and does not create an account.

+

Data collection

+

Optional, on-device only: usage telemetry (opt-in) stores anonymous feature-use counts locally. No document content, file names, or personal identifiers. Nothing is sent unless you choose to share a report.

+

The App does not collect document content, personal identification information, location or GPS history, contacts, or phone numbers. Photos are imported only when you choose them.

+

Data storage

+

Documents, metadata, and settings are stored locally. Files are encrypted with AES-256-GCM using Android KeyStore. The App does not transmit documents unless you use Android's share sheet or export a password-protected archive to a folder you choose.

+

Permissions

+ + + + +
PermissionPurposeWhen requested
CameraScan a documentWhen you tap Take Photo
InternetOptional first-run download of the on-device ML Kit OCR modelUsed only if the model is not already present
+

Denying camera permission does not break file or folder import, viewing, search, or share.

+

Third-party libraries

+

ML Kit Text Recognition runs on your device. Google's ML Kit may download its recognition model on first use. Document images are not uploaded to a Paperstow server.

+

Data sharing

+

We do not sell or share your data. If you use Share or Archive, you choose the destination.

+

Children's privacy

+

This App is not directed at children under 13 and does not knowingly collect data from children.

+

Contact

+

github.com/sethusrinivasan/document-manager/issues

+ + diff --git a/gradle.properties b/gradle.properties index f0a2e55..a8640c7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true +org.gradle.caching=true + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4040b24 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,83 @@ +# Ceiling: last AGP 8.x is 8.13.2 (compileSdk 36). AndroidX Core 1.19, +# Lifecycle 2.11, Navigation 2.10, Hilt Navigation 1.4, Compose BOM 2026.08+ +# require AGP 9 + compileSdk 37. +[versions] +agp = "8.13.2" +kotlin = "2.2.21" +ksp = "2.2.21-2.0.5" +hilt = "2.58" +composeBom = "2026.06.01" +coroutines = "1.11.0" +room = "2.8.5" +lifecycle = "2.10.0" +activity = "1.13.0" +navigation = "2.9.8" +coreKtx = "1.18.0" +hiltNavigation = "1.3.0" +documentFile = "1.1.0" +zip4j = "2.11.6" +bouncycastle = "1.86" +securityCrypto = "1.1.0" +gson = "2.14.0" +mlkitText = "16.0.1" +mlkitDocScanner = "16.0.0" +biometric = "1.1.0" +junitBom = "5.14.4" +kotest = "6.2.5" +mockk = "1.14.11" +androidxJunit = "1.3.0" +espresso = "3.7.0" + +[libraries] +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +compose-ui = { module = "androidx.compose.ui:ui" } +compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +compose-material3 = { module = "androidx.compose.material3:material3" } +compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } +lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentFile" } +zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" } +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +mlkit-text-recognition = { module = "com.google.mlkit:text-recognition", version.ref = "mlkitText" } +mlkit-document-scanner = { module = "com.google.android.gms:play-services-mlkit-document-scanner", version.ref = "mlkitDocScanner" } +bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +junit-bom = { module = "org.junit:junit-bom", version.ref = "junitBom" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } +kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ec40e7b..ebc9c9a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ -#Fri Sep 04 23:19:09 PDT 2026 +#Fri Sep 21 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 08b0720..a74dbfb 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,20 +1,24 @@ #!/bin/bash -# Deploy Document Manager to connected Android device +# Deploy Paperstow to a connected device, or start the local emulator AVD. # Usage: ./scripts/deploy.sh [debug|release] -# Default: release +# Default: debug (for manual testing) set -e -VARIANT="${1:-release}" +VARIANT="${1:-debug}" PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +AVD_NAME="${AVD_NAME:-Paperstow_API36}" -echo "=== Document Manager Deploy ===" +export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-dev-tools/android-sdk}" +export ANDROID_SDK_ROOT="$ANDROID_HOME" +export JAVA_HOME="${JAVA_HOME:-/usr/lib/jvm/java-17-openjdk-amd64}" +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + +echo "=== Paperstow Deploy ===" echo "Variant: $VARIANT" echo "Project: $PROJECT_DIR" echo "" -# Build -echo "Building $VARIANT APK..." if [ "$VARIANT" = "debug" ]; then "$PROJECT_DIR/gradlew" -p "$PROJECT_DIR" assembleDebug --no-daemon APK="$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk" @@ -31,22 +35,47 @@ fi echo "" echo "APK: $APK ($(du -h "$APK" | cut -f1))" -# Check device -if ! adb devices | grep -q "device$"; then - echo "" - echo "ERROR: No Android device connected." - echo "Connect via USB and enable USB debugging, then retry." +adb start-server >/dev/null + +if ! adb devices | grep -q $'\tdevice$'; then + if [ ! -x "$ANDROID_HOME/emulator/emulator" ]; then + echo "ERROR: No device connected and emulator is not installed." + echo "Run: bash scripts/setup.sh" + exit 1 + fi + if [ ! -f "$HOME/.android/avd/${AVD_NAME}.ini" ]; then + echo "ERROR: AVD $AVD_NAME not found. Run: bash scripts/setup.sh" + exit 1 + fi + echo "No device connected — starting $AVD_NAME" + # Do not relaunch if this AVD is already booting. + if pgrep -f "qemu-system.*${AVD_NAME}|emulator.*-avd ${AVD_NAME}" >/dev/null 2>&1; then + echo "Emulator process already running" + else + nohup "$ANDROID_HOME/emulator/emulator" -avd "$AVD_NAME" -gpu auto -no-snapshot-save \ + >"$HOME/.android/${AVD_NAME}.log" 2>&1 & + fi + echo "Waiting for emulator to finish booting..." + adb wait-for-device + for _ in $(seq 1 90); do + if [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then + break + fi + sleep 2 + done +fi + +if ! adb devices | grep -q $'\tdevice$'; then + echo "ERROR: Emulator did not reach device state. See $HOME/.android/${AVD_NAME}.log" exit 1 fi -# Install echo "" echo "Installing on device..." adb install -r "$APK" -# Launch echo "Launching..." -adb shell am start -n com.app.traveldocs/.presentation.MainActivity +adb shell am start -n com.app.paperstow/.presentation.MainActivity echo "" echo "=== Done ===" diff --git a/scripts/generate-demo-assets.py b/scripts/generate-demo-assets.py new file mode 100644 index 0000000..eadede3 --- /dev/null +++ b/scripts/generate-demo-assets.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Generate bundled Paperstow sample-trip documents (valid image PDFs, images, text, markdown).""" + +from __future__ import annotations + +import io +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +OUT = Path(__file__).resolve().parents[1] / "app/src/main/assets/demo" +NAVY = (12, 32, 65) +GOLD = (201, 162, 39) +CREAM = (250, 247, 240) +TEAL = (0, 105, 120) +RED = (176, 42, 42) +GREEN = (36, 120, 72) +INK = (28, 28, 28) +MUTED = (90, 90, 90) + + +def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for path in ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + ): + if Path(path).exists(): + return ImageFont.truetype(path, size) + return ImageFont.load_default() + + +def write_jpeg_pdf(path: Path, jpeg: bytes, width: int, height: int) -> None: + """Write a one-page PDF that embeds a JPEG. Pdfium / Android PdfRenderer accept this reliably.""" + content = f"q {width} 0 0 {height} 0 0 cm /Im0 Do Q\n".encode("ascii") + + def pack(n: int, payload: bytes) -> bytes: + return f"{n} 0 obj\n".encode("ascii") + payload + b"\nendobj\n" + + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] " + f"/Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>" + ).encode("ascii"), + b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream", + ( + b"<< /Type /XObject /Subtype /Image /Width %d /Height %d " + b"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode " + b"/Length %d >>\nstream\n" % (width, height, len(jpeg)) + + jpeg + + b"\nendstream" + ), + ] + body = b"%PDF-1.4\n" + xref = [0] + for i, obj in enumerate(objects, start=1): + xref.append(len(body)) + body += pack(i, obj) + startxref = len(body) + table = f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode("ascii") + for offset in xref[1:]: + table += f"{offset:010d} 00000 n \n".encode("ascii") + trailer = ( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" + ).encode("ascii") + path.write_bytes(body + table + trailer) + + +def pdf(path: Path, title: str, lines: list[str], accent: tuple[int, int, int] = NAVY) -> None: + width, height = 1240, 1754 + img = Image.new("RGB", (width, height), CREAM) + draw = ImageDraw.Draw(img) + draw.rectangle((36, 36, width - 37, height - 37), outline=accent, width=8) + draw.rectangle((36, 36, width - 37, 150), fill=accent) + draw.text((64, 70), title, fill=(255, 255, 255), font=font(36)) + y = 196 + for line in lines: + color = RED if line.startswith("SAMPLE") or line.startswith("Fictional") else INK + size = 20 if line.startswith("SAMPLE") or line.startswith("Fictional") else 28 + draw.text((72, y), line, fill=color, font=font(size)) + y += 40 + draw.text( + (72, height - 90), + "Paperstow sample · fictional · not an official document", + fill=MUTED, + font=font(20), + ) + buf = io.BytesIO() + img.save(buf, "JPEG", quality=86) + write_jpeg_pdf(path, buf.getvalue(), width, height) + + +def card(size: tuple[int, int], bg: tuple[int, int, int]) -> tuple[Image.Image, ImageDraw.ImageDraw]: + img = Image.new("RGB", size, bg) + draw = ImageDraw.Draw(img) + draw.rectangle((16, 16, size[0] - 17, size[1] - 17), outline=GOLD, width=4) + return img, draw + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + for old in OUT.iterdir(): + if old.is_file(): + old.unlink() + + pdf( + OUT / "john_doe_passport.pdf", + "PASSPORT — SAMPLE", + [ + "SAMPLE · not a real passport", + "", + "Type: P Code: XX Passport No: X0000001", + "Surname: DOE", + "Given names: JOHN", + "Nationality: SAMPLE", + "Date of birth: 01 JAN 1980", + "Sex: M Place of birth: SAMPLE CITY", + "Date of issue: 01 JAN 2024 Date of expiry: 01 JAN 2034", + "Authority: SAMPLE ISSUING OFFICE", + "", + "Machine readable zone (sample):", + "P + + + Sample trail + + + Sample trail + + + + battery 84% + + + + battery 81% + + + + battery 76% + + + + +""", + encoding="utf-8", + ) + (OUT / "trip_plans.md").write_text( + "# Lakeside trip plans\n" + "\n" + "Simple sample checklist for John Doe and Jane Doe.\n" + "\n" + "- [x] Book flights and hotel\n" + "- [x] Buy travel insurance\n" + "- [ ] Pack medicines and copies of health records\n" + "- [ ] Download boarding passes\n" + "- [ ] Share itinerary with Alex Doe\n" + "- [ ] Confirm Lakeside Inn late check-in\n" + "- [ ] Review vaccination record before departure\n", + encoding="utf-8", + ) + + print(f"Wrote {len(list(OUT.iterdir()))} files to {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-tag-stock.py b/scripts/generate-tag-stock.py new file mode 100644 index 0000000..75cea83 --- /dev/null +++ b/scripts/generate-tag-stock.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Original folder covers for Manage Tags. Mid-tone so they read in light and dark themes.""" + +from pathlib import Path +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +OUT = Path(__file__).resolve().parents[1] / "app/src/main/assets/tag_stock" +SIZE = 512 + + +def font(size: int): + for path in ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ): + if Path(path).exists(): + return ImageFont.truetype(path, size) + return ImageFont.load_default() + + +def canvas(top, bottom): + img = Image.new("RGB", (SIZE, SIZE), top) + draw = ImageDraw.Draw(img) + for y in range(SIZE): + t = y / (SIZE - 1) + r = int(top[0] + (bottom[0] - top[0]) * t) + g = int(top[1] + (bottom[1] - top[1]) * t) + b = int(top[2] + (bottom[2] - top[2]) * t) + draw.line([(0, y), (SIZE, y)], fill=(r, g, b)) + return img, draw + + +def save(img: Image.Image, name: str) -> None: + OUT.mkdir(parents=True, exist_ok=True) + img = img.filter(ImageFilter.SMOOTH) + img.save(OUT / f"{name}.jpg", "JPEG", quality=88) + + +def passport(): + img, d = canvas((46, 92, 110), (28, 62, 78)) + d.rounded_rectangle((118, 70, 394, 452), 22, fill=(132, 42, 52), outline=(201, 162, 39), width=6) + d.ellipse((196, 130, 316, 250), outline=(201, 162, 39), width=8) + d.polygon([(256, 148), (276, 210), (214, 174), (298, 174), (236, 210)], fill=(201, 162, 39)) + d.text((168, 290), "PASSPORT", font=font(28), fill=(236, 220, 170)) + d.rounded_rectangle((160, 350, 352, 400), 8, fill=(92, 28, 36)) + save(img, "passport") + + +def visa(): + img, d = canvas((92, 78, 118), (58, 48, 82)) + d.rounded_rectangle((80, 90, 432, 430), 16, fill=(236, 228, 210), outline=(90, 70, 50), width=4) + d.ellipse((150, 140, 362, 352), outline=(176, 48, 48), width=14) + d.text((188, 220), "ENTRY", font=font(36), fill=(176, 48, 48)) + d.line([(120, 380), (390, 380)], fill=(90, 70, 50), width=4) + save(img, "visa") + + +def tickets(): + img, d = canvas((36, 78, 128), (24, 48, 88)) + d.rounded_rectangle((70, 150, 442, 360), 18, fill=(240, 236, 220), outline=(36, 78, 128), width=4) + d.ellipse((70, 220, 110, 290), fill=(36, 78, 128)) + d.ellipse((402, 220, 442, 290), fill=(36, 78, 128)) + d.polygon([(140, 230), (300, 200), (360, 230), (300, 250), (140, 250)], fill=(36, 78, 128)) + d.polygon([(300, 200), (390, 170), (400, 190), (330, 220)], fill=(201, 162, 39)) + d.text((150, 280), "BOARDING", font=font(26), fill=(36, 78, 128)) + save(img, "tickets") + + +def hotel(): + img, d = canvas((48, 88, 108), (32, 56, 74)) + d.rectangle((150, 140, 362, 430), fill=(232, 220, 196), outline=(90, 70, 48), width=4) + d.polygon([(128, 150), (256, 70), (384, 150)], fill=(176, 72, 62)) + for x in (180, 230, 280, 320): + for y in (180, 240, 300, 360): + d.rounded_rectangle((x, y, x + 28, y + 36), 4, fill=(90, 150, 176)) + d.rectangle((232, 370, 280, 430), fill=(90, 70, 48)) + save(img, "hotel") + + +def health(): + img, d = canvas((36, 102, 98), (24, 70, 72)) + d.ellipse((96, 96, 416, 416), fill=(236, 244, 238), outline=(36, 120, 88), width=8) + d.rectangle((226, 150, 286, 362), fill=(196, 52, 58)) + d.rectangle((150, 226, 362, 286), fill=(196, 52, 58)) + save(img, "health") + + +def insurance(): + img, d = canvas((58, 72, 118), (38, 48, 86)) + d.polygon([(256, 80), (420, 150), (390, 340), (256, 430), (122, 340), (92, 150)], fill=(232, 228, 214), outline=(58, 72, 118), width=6) + d.ellipse((186, 170, 326, 310), outline=(36, 120, 88), width=12) + d.line([(220, 250), (250, 286), (304, 210)], fill=(36, 120, 88), width=12) + save(img, "insurance") + + +def notes(): + img, d = canvas((118, 92, 58), (86, 64, 40)) + d.rounded_rectangle((110, 70, 402, 450), 10, fill=(246, 238, 210), outline=(90, 70, 40), width=4) + d.rectangle((110, 70, 402, 120), fill=(201, 162, 39)) + for y in (170, 220, 270, 320, 370): + d.line([(150, y), (360, y)], fill=(140, 120, 80), width=4) + save(img, "notes") + + +def plans(): + img, d = canvas((46, 96, 86), (30, 68, 62)) + d.rounded_rectangle((100, 80, 412, 440), 16, fill=(236, 242, 228), outline=(46, 96, 86), width=5) + boxes = [(140, 140), (140, 220), (140, 300)] + for i, (x, y) in enumerate(boxes): + d.rounded_rectangle((x, y, x + 48, y + 48), 8, outline=(46, 96, 86), width=5) + if i < 2: + d.line([(x + 10, y + 26), (x + 20, y + 36), (x + 38, y + 12)], fill=(36, 120, 72), width=6) + d.line([(x + 70, y + 24), (x + 230, y + 24)], fill=(90, 110, 90), width=5) + save(img, "plans") + + +def trail(): + img, d = canvas((48, 92, 72), (32, 58, 48)) + d.polygon([(0, 360), (120, 220), (220, 300), (340, 160), (512, 280), (512, 512), (0, 512)], fill=(92, 140, 78)) + d.polygon([(80, 512), (180, 280), (240, 300), (200, 512)], fill=(168, 132, 72)) + d.ellipse((236, 90, 300, 154), fill=(196, 52, 58), outline=(236, 220, 210), width=6) + d.polygon([(256, 148), (300, 250), (212, 250)], fill=(196, 52, 58)) + save(img, "trail") + + +def family(): + img, d = canvas((86, 70, 118), (56, 46, 86)) + d.ellipse((150, 120, 230, 200), fill=(236, 210, 176)) + d.ellipse((282, 110, 372, 200), fill=(236, 210, 176)) + d.rounded_rectangle((140, 200, 242, 380), 40, fill=(72, 120, 168)) + d.rounded_rectangle((274, 196, 382, 380), 40, fill=(168, 80, 98)) + d.ellipse((230, 250, 282, 302), fill=(236, 210, 176)) + save(img, "family") + + +def car(): + img, d = canvas((58, 88, 108), (40, 60, 78)) + d.rounded_rectangle((80, 230, 432, 330), 28, fill=(201, 162, 39)) + d.polygon([(150, 230), (210, 160), (330, 160), (390, 230)], fill=(72, 120, 150)) + d.ellipse((130, 300, 210, 380), fill=(40, 40, 48)) + d.ellipse((310, 300, 390, 380), fill=(40, 40, 48)) + d.ellipse((150, 320, 190, 360), fill=(180, 190, 200)) + d.ellipse((330, 320, 370, 360), fill=(180, 190, 200)) + save(img, "car") + + +def trip(): + img, d = canvas((36, 82, 118), (24, 52, 86)) + d.ellipse((116, 90, 396, 370), fill=(72, 150, 150), outline=(236, 220, 180), width=8) + d.arc((160, 110, 352, 350), 200, 340, fill=(236, 220, 180), width=8) + d.line((256, 90, 256, 370), fill=(236, 220, 180), width=6) + d.rounded_rectangle((180, 340, 332, 450), 16, fill=(168, 92, 52)) + d.rectangle((232, 318, 280, 350), fill=(201, 162, 39)) + save(img, "trip") + + +def folder(): + img, d = canvas((78, 96, 118), (52, 68, 88)) + d.rounded_rectangle((90, 160, 250, 210), 10, fill=(201, 162, 39)) + d.rounded_rectangle((90, 190, 422, 430), 18, fill=(232, 220, 188), outline=(90, 78, 52), width=4) + d.rounded_rectangle((130, 150, 380, 390), 12, fill=(246, 238, 214)) + save(img, "folder") + + +def main() -> None: + passport() + visa() + tickets() + hotel() + health() + insurance() + notes() + plans() + trail() + family() + car() + trip() + folder() + print(f"Wrote {len(list(OUT.glob('*.jpg')))} covers to {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/pull-logs.sh b/scripts/pull-logs.sh index 733f10c..a2f6c26 100755 --- a/scripts/pull-logs.sh +++ b/scripts/pull-logs.sh @@ -7,7 +7,7 @@ set -e OUTPUT_DIR="${1:-./logs}" TIMESTAMP=$(date +%Y%m%d_%H%M%S) -PKG="com.app.traveldocs" +PKG="com.app.paperstow" echo "=== Document Manager — Pull Logs ===" echo "Output: $OUTPUT_DIR" diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh index 283022d..66169ff 100755 --- a/scripts/setup-env.sh +++ b/scripts/setup-env.sh @@ -2,6 +2,10 @@ # Source this file before building: source setup-env.sh # This script works on both Ubuntu (Linux) and macOS +command_exists() { + command -v "$1" &> /dev/null +} + # Detect OS OS_NAME="" if [[ "$OSTYPE" == "linux-gnu"* ]]; then @@ -46,8 +50,18 @@ if [ -z "$JAVA_HOME" ]; then fi fi +# cmdline-tools/latest is required so avdmanager resolves the SDK root correctly. +if [ -d "$ANDROID_HOME/cmdline-tools/bin" ] && [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + mkdir -p "$ANDROID_HOME/cmdline-tools/latest" + for item in "$ANDROID_HOME/cmdline-tools"/*; do + name=$(basename "$item") + [ "$name" = "latest" ] && continue + mv "$item" "$ANDROID_HOME/cmdline-tools/latest/" + done +fi + # Update PATH with Java and Android tools -export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH" # Print environment configuration echo "Environment configured for $OS_NAME:" diff --git a/scripts/setup.sh b/scripts/setup.sh index 72c9e90..1deb613 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,33 +1,31 @@ #!/bin/bash -# Development environment setup script for Document Manager -# Works on Ubuntu (Linux) and macOS +# Development environment setup for Paperstow. +# Works on Ubuntu (Linux) and macOS. +# Re-runs skip any SDK package or zip that is already on disk. -# Check if script is being run with bash if [ -z "$BASH_VERSION" ]; then echo -e "\033[0;31mERROR: This script must be run with bash!\033[0m" echo "" - echo "Please run the script with the following command:" - echo " \033[0;34mbash scripts/setup.sh\033[0m" - echo "" - echo "Or make it executable and run directly:" - echo " \033[0;34mchmod +x scripts/setup.sh && ./scripts/setup.sh\033[0m" - echo "" - echo "The script uses bash-specific features (like [[ ]] syntax)" - echo "that are not available in other shells (dash, sh, etc.)" - echo "" + echo "Please run: bash scripts/setup.sh" + echo "Or: chmod +x scripts/setup.sh && ./scripts/setup.sh" exit 1 fi set -e -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' + +ANDROID_API="36" +BUILD_TOOLS_VER="35.0.0" +AVD_NAME="Paperstow_API36" +AVD_DEVICE="pixel_7" +CMDLINE_TOOLS_REV="11076708" +CACHE_DIR="${SETUP_CACHE_DIR:-$HOME/.kiro/cache}" -# Detect OS OS_NAME="" if [[ "$OSTYPE" == "linux-gnu"* ]]; then OS_NAME="Linux" @@ -39,33 +37,19 @@ else fi echo -e "${BLUE}========================================${NC}" -echo -e "${BLUE} Document Manager Setup${NC}" +echo -e "${BLUE} Paperstow Setup${NC}" echo -e "${BLUE} OS: $OS_NAME${NC}" echo -e "${BLUE}========================================${NC}" echo "" -# Function to check if command exists command_exists() { command -v "$1" &> /dev/null } -# Function to check if package manager commands exist -has_package_manager() { - if command_exists apt || command_exists apt-get; then - echo "apt" - elif command_exists brew; then - echo "brew" - else - echo "" - fi -} - -# Function to check if homebrew is installed has_homebrew() { command_exists brew } -# Function to check if curl/wget are available has_curl() { command_exists curl } @@ -74,55 +58,54 @@ has_wget() { command_exists wget } -# Function to print success message print_success() { echo -e "${GREEN}✓ $1${NC}" } -# Function to print warning message print_warning() { echo -e "${YELLOW}⚠ $1${NC}" } -# Function to print info message print_info() { echo -e "${BLUE}ℹ $1${NC}" } -# Function to prompt user for yes/no +# SETUP_YES=1 skips prompts (always the default). Non-tty stdin uses the default. ask_yes_no() { local prompt="$1" local default="${2:-y}" - + + if [ "${SETUP_YES:-}" = "1" ]; then + [ "$default" = "y" ] + return $? + fi + if [ ! -t 0 ]; then + [ "$default" = "y" ] + return $? + fi + while true; do if [ "$default" == "y" ]; then echo -n -e "${BLUE}$prompt [Y/n]: ${NC}" else echo -n -e "${BLUE}$prompt [y/N]: ${NC}" fi - + read -r answer answer="${answer:-$default}" - + case "$answer" in - [Yy]*) - return 0 - ;; - [Nn]*) - return 1 - ;; - *) - echo -e "${RED}Please answer yes (y) or no (n)${NC}" - ;; + [Yy]*) return 0 ;; + [Nn]*) return 1 ;; + *) echo -e "${RED}Please answer yes (y) or no (n)${NC}" ;; esac done } -# Function to wait for manual installation wait_for_manual_install() { local tool_name="$1" local install_url="$2" - + echo "" echo -e "${BLUE}========================================${NC}" echo -e "${BLUE} Manual Installation Required${NC}" @@ -134,33 +117,24 @@ wait_for_manual_install() { echo "" echo -e " ${BLUE}Download URL:${NC} $install_url" echo "" - echo -e " ${BLUE}After downloading:${NC}" - echo " 1. Extract the archive to the appropriate location" - echo " 2. Make sure the binaries are in your PATH" - echo "" echo -e "${YELLOW}Once installed, press Enter to continue...${NC}" - - # Wait for user input read -r - - # Check if tool is now available + if command_exists "$tool_name"; then print_success "${tool_name} is now available" return 0 - else - print_warning "${tool_name} not found in PATH" - echo -e "${YELLOW}Please verify the installation and ensure the tools are in your PATH.${NC}" - return 1 fi + print_warning "${tool_name} not found in PATH" + return 1 } -# Function to check if a file is cached and up to date is_cached() { local file_path="$1" local min_size="$2" - + if [ -f "$file_path" ] && [ -s "$file_path" ]; then - local file_size=$(stat -c%s "$file_path" 2>/dev/null || stat -f%z "$file_path" 2>/dev/null) + local file_size + file_size=$(stat -c%s "$file_path" 2>/dev/null || stat -f%z "$file_path" 2>/dev/null) if [ "$file_size" -ge "$min_size" ]; then return 0 fi @@ -168,11 +142,174 @@ is_cached() { return 1 } -# Function to get cached download path get_cached_path() { local url="$1" - local basename=$(basename "$url") - echo "$HOME/.kiro/cache/$basename" + local basename + basename=$(basename "$url") + echo "$CACHE_DIR/$basename" +} + +# Download once into $CACHE_DIR; later runs reuse the zip. +download_cached() { + local url="$1" + local min_size="${2:-1000000}" + local dest + dest=$(get_cached_path "$url") + mkdir -p "$(dirname "$dest")" + + if is_cached "$dest" "$min_size"; then + print_success "Using cached $(basename "$dest")" + printf '%s\n' "$dest" + return 0 + fi + + print_info "Downloading $url" + local tmp="$dest.partial.$$" + if has_curl; then + curl -L "$url" -o "$tmp" --fail + elif has_wget; then + wget "$url" -O "$tmp" --show-progress + else + print_warning "curl or wget required to download $url" + return 1 + fi + mv "$tmp" "$dest" + printf '%s\n' "$dest" +} + +sdk_sysimage_pkg() { + local m + m=$(uname -m) + if [ "$m" = "arm64" ] || [ "$m" = "aarch64" ]; then + echo "system-images;android-${ANDROID_API};google_apis_playstore;arm64-v8a" + else + echo "system-images;android-${ANDROID_API};google_apis_playstore;x86_64" + fi +} + +sdk_pkg_present() { + local pkg="$1" + local root="${ANDROID_HOME:-$ANDROID_SDK_PATH}" + case "$pkg" in + platform-tools) + [ -x "$root/platform-tools/adb" ] + ;; + emulator) + [ -x "$root/emulator/emulator" ] + ;; + platforms\;android-*) + [ -d "$root/platforms/${pkg#platforms;}" ] + ;; + build-tools\;*) + [ -d "$root/build-tools/${pkg#build-tools;}" ] + ;; + system-images\;*) + local rest="${pkg#system-images;}" + local api="${rest%%;*}" + local rest2="${rest#*;}" + local tag="${rest2%%;*}" + local abi="${rest2#*;}" + [ -f "$root/system-images/$api/$tag/$abi/package.xml" ] + ;; + *) + return 1 + ;; + esac +} + +# avdmanager treats cmdline-tools/bin as if the SDK root were the parent +# of cmdline-tools. Pin tools under cmdline-tools/latest so --sdk_root works. +ensure_cmdline_tools_layout() { + local root="${ANDROID_HOME:-$ANDROID_SDK_PATH}" + local latest="$root/cmdline-tools/latest" + if [ -x "$latest/bin/sdkmanager" ]; then + return 0 + fi + if [ -x "$root/cmdline-tools/bin/sdkmanager" ]; then + print_info "Moving cmdline-tools into cmdline-tools/latest (required by avdmanager)" + mkdir -p "$latest" + local item name + for item in "$root/cmdline-tools"/*; do + name=$(basename "$item") + [ "$name" = "latest" ] && continue + mv "$item" "$latest/" + done + fi +} + +sdkmanager_bin() { + local root="${ANDROID_HOME:-$ANDROID_SDK_PATH}" + if [ -x "$root/cmdline-tools/latest/bin/sdkmanager" ]; then + echo "$root/cmdline-tools/latest/bin/sdkmanager" + elif [ -x "$root/cmdline-tools/bin/sdkmanager" ]; then + echo "$root/cmdline-tools/bin/sdkmanager" + elif command_exists sdkmanager; then + command -v sdkmanager + else + echo "" + fi +} + +avdmanager_bin() { + local root="${ANDROID_HOME:-$ANDROID_SDK_PATH}" + if [ -x "$root/cmdline-tools/latest/bin/avdmanager" ]; then + echo "$root/cmdline-tools/latest/bin/avdmanager" + elif [ -x "$root/cmdline-tools/bin/avdmanager" ]; then + echo "$root/cmdline-tools/bin/avdmanager" + else + echo "" + fi +} + +ensure_sdk_package() { + local pkg="$1" + if sdk_pkg_present "$pkg"; then + print_success "$pkg already present — skip download" + return 0 + fi + local sm + sm=$(sdkmanager_bin) + if [ -z "$sm" ]; then + print_warning "sdkmanager not found; cannot install $pkg" + return 1 + fi + print_info "Installing $pkg" + yes | "$sm" --sdk_root="${ANDROID_HOME:-$ANDROID_SDK_PATH}" "$pkg" +} + +install_cmdline_tools() { + local root="$ANDROID_SDK_PATH" + if [ -x "$root/cmdline-tools/latest/bin/sdkmanager" ] || [ -x "$root/cmdline-tools/bin/sdkmanager" ]; then + print_success "Command-line tools already installed — skip zip download" + ensure_cmdline_tools_layout + return 0 + fi + + local zip_name url + if [ "$OS_NAME" == "macOS" ]; then + zip_name="commandlinetools-mac-${CMDLINE_TOOLS_REV}_latest.zip" + else + zip_name="commandlinetools-linux-${CMDLINE_TOOLS_REV}_latest.zip" + fi + url="https://dl.google.com/android/repository/$zip_name" + + local archive + if ! archive=$(download_cached "$url" 10000000); then + wait_for_manual_install "sdkmanager" "https://developer.android.com/studio#command-tools" + return 1 + fi + + local temp_dir + temp_dir=$(mktemp -d) + unzip -q "$archive" -d "$temp_dir" + mkdir -p "$root/cmdline-tools/latest" + if [ -d "$temp_dir/cmdline-tools" ]; then + cp -a "$temp_dir/cmdline-tools/." "$root/cmdline-tools/latest/" + else + cp -a "$temp_dir/." "$root/cmdline-tools/latest/" + fi + rm -rf "$temp_dir" + print_success "Android SDK Command Line Tools installed under cmdline-tools/latest" } # ============================================================================ @@ -184,8 +321,7 @@ JAVA_INSTALLED=false if command_exists java; then JAVA_VERSION=$(java -version 2>&1 | head -1 | cut -d'"' -f2) print_success "Java found: $JAVA_VERSION" - - # Check if it's Java 17 + if [[ "$JAVA_VERSION" == 17.* ]]; then print_success "Java version is 17 (required)" JAVA_INSTALLED=true @@ -193,38 +329,24 @@ if command_exists java; then print_warning "Java version is $JAVA_VERSION, but version 17 is recommended" if ask_yes_no "Install Java 17?" "y"; then print_info "Installing Java 17..." - + if [ "$OS_NAME" == "macOS" ]; then if has_homebrew; then - echo "Using Homebrew to install Java 17..." - if brew install openjdk@17; then - print_success "Java 17 installed via Homebrew" - JAVA_INSTALLED=true - else - echo -e "${RED}Failed to install Java via Homebrew${NC}" - wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" - fi + brew install openjdk@17 + print_success "Java 17 installed via Homebrew" + JAVA_INSTALLED=true else - echo -e "${RED}Homebrew not found. Please install Java 17 manually.${NC}" - echo "" - echo " ${BLUE}Download URL:${NC} https://adoptium.net/temurin/releases?version=17" - echo " ${BLUE}Or:${NC} brew install --cask temurin" wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" fi else - # Ubuntu if [ "$(id -u)" -eq 0 ]; then apt update && apt install -y openjdk-17-jdk JAVA_INSTALLED=true + elif sudo apt update && sudo apt install -y openjdk-17-jdk; then + print_success "Java 17 installed" + JAVA_INSTALLED=true else - echo "Attempting to install Java 17 with sudo..." - if sudo apt update && sudo apt install -y openjdk-17-jdk; then - print_success "Java 17 installed" - JAVA_INSTALLED=true - else - echo -e "${RED}Failed to install Java${NC}" - wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" - fi + wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" fi fi else @@ -235,38 +357,24 @@ else print_warning "Java not found in PATH" if ask_yes_no "Install Java 17?" "y"; then print_info "Installing Java 17..." - + if [ "$OS_NAME" == "macOS" ]; then if has_homebrew; then - echo "Using Homebrew to install Java 17..." - if brew install openjdk@17; then - print_success "Java 17 installed via Homebrew" - JAVA_INSTALLED=true - else - echo -e "${RED}Failed to install Java via Homebrew${NC}" - wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" - fi + brew install openjdk@17 + print_success "Java 17 installed via Homebrew" + JAVA_INSTALLED=true else - echo -e "${RED}Homebrew not found. Please install Java 17 manually.${NC}" - echo "" - echo " ${BLUE}Download URL:${NC} https://adoptium.net/temurin/releases?version=17" - echo " ${BLUE}Or:${NC} brew install --cask temurin" wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" fi else - # Ubuntu if [ "$(id -u)" -eq 0 ]; then apt update && apt install -y openjdk-17-jdk JAVA_INSTALLED=true + elif sudo apt update && sudo apt install -y openjdk-17-jdk; then + print_success "Java 17 installed" + JAVA_INSTALLED=true else - echo "Attempting to install Java 17 with sudo..." - if sudo apt update && sudo apt install -y openjdk-17-jdk; then - print_success "Java 17 installed" - JAVA_INSTALLED=true - else - echo -e "${RED}Failed to install Java${NC}" - wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" - fi + wait_for_manual_install "java" "https://adoptium.net/temurin/releases?version=17" fi fi else @@ -277,475 +385,182 @@ fi echo "" # ============================================================================ -# Step 2: Check Android SDK +# Step 2: Android SDK root + command-line tools (one zip, cached) # ============================================================================ echo -e "${BLUE}Step 2: Checking Android SDK...${NC}" ANDROID_SDK_PATH="${ANDROID_HOME:-$HOME/android-dev-tools/android-sdk}" +mkdir -p "$ANDROID_SDK_PATH" if [ -d "$ANDROID_SDK_PATH" ]; then - print_success "Android SDK found at: $ANDROID_SDK_PATH" - - # Check for required SDK components - if [ -d "$ANDROID_SDK_PATH/platforms" ]; then - if [ -d "$ANDROID_SDK_PATH/platforms/android-34" ]; then - print_success "Android API 34 platform installed" - else - print_warning "Android API 34 platform NOT installed" - if ask_yes_no "Install Android API 34?" "y"; then - if command_exists sdkmanager; then - echo "Installing Android API 34..." - echo -e "y\n" | sdkmanager "platforms;android-34" - print_success "Android API 34 installed" - else - print_info "sdkmanager not found. Install Android SDK Command Line Tools first." - fi - fi - fi - else - print_warning "Android platforms directory NOT found" - fi - - if [ -d "$ANDROID_SDK_PATH/build-tools" ]; then - BUILD_TOOLS_VERSION=$(ls -1 "$ANDROID_SDK_PATH/build-tools" | sort -V | tail -1) - print_success "Build tools found: $BUILD_TOOLS_VERSION" - - if [ -d "$ANDROID_SDK_PATH/build-tools/34.0.0" ]; then - print_success "Build Tools 34.0.0 installed" - else - print_warning "Build Tools 34.0.0 NOT installed (recommended)" - if ask_yes_no "Install Build Tools 34.0.0?" "y"; then - if command_exists sdkmanager; then - echo "Installing Build Tools 34.0.0..." - echo -e "y\n" | sdkmanager "build-tools;34.0.0" - print_success "Build Tools 34.0.0 installed" - else - print_info "sdkmanager not found. Install Android SDK Command Line Tools first." - fi - fi - fi - else - print_warning "Build tools directory NOT found" - fi - - if [ -d "$ANDROID_SDK_PATH/cmdline-tools" ]; then - print_success "Command line tools installed" - else - print_warning "Command line tools directory NOT found" - fi - + print_success "Android SDK root: $ANDROID_SDK_PATH" +fi + +if [ -x "$ANDROID_SDK_PATH/cmdline-tools/latest/bin/sdkmanager" ] || [ -x "$ANDROID_SDK_PATH/cmdline-tools/bin/sdkmanager" ]; then + print_success "Command-line tools found" + ensure_cmdline_tools_layout else - print_warning "Android SDK NOT found at: $ANDROID_SDK_PATH" - if ask_yes_no "Install Android SDK?" "n"; then - print_info "Downloading Android SDK Command Line Tools..." - - # Use the stable URL format - if [ "$OS_NAME" == "macOS" ]; then - SDK_ZIP="commandlinetools-mac-11076708_latest.zip" - SDK_URL="https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip" - else - SDK_ZIP="commandlinetools-linux-11076708_latest.zip" - SDK_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" - fi - - TEMP_DIR=$(mktemp -d) - SDK_FILE="$TEMP_DIR/$SDK_ZIP" - - print_info "Downloading from: $SDK_URL" - - if has_curl; then - if curl -L "$SDK_URL" -o "$SDK_FILE" --fail; then - mkdir -p "$ANDROID_SDK_PATH/cmdline-tools" - unzip -q "$SDK_FILE" -d "$TEMP_DIR" - if [ -d "$TEMP_DIR/cmdline-tools" ]; then - # Copy contents instead of mv to avoid inter-device issues - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR/cmdline-tools"/* "$ANDROID_SDK_PATH/cmdline-tools/" - else - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR"/* "$ANDROID_SDK_PATH/cmdline-tools/" - fi - rm -rf "$TEMP_DIR" - print_success "Android SDK Command Line Tools installed" - - # Ask about installing sdkmanager packages - if ask_yes_no "Install Android SDK packages (platforms, build tools) using sdkmanager?" "n"; then - if command_exists sdkmanager; then - echo "Installing Android SDK packages..." - echo -e "y\n" | sdkmanager "platforms;android-34" 2>/dev/null || print_warning "Failed to install platforms" - echo -e "y\n" | sdkmanager "build-tools;34.0.0" 2>/dev/null || print_warning "Failed to install build tools" - else - print_warning "sdkmanager not available" - fi - fi - else - echo -e "${RED}Failed to download Android SDK${NC}" - wait_for_manual_install "sdkmanager" "https://developer.android.com/studio#command-tools" - fi - elif has_wget; then - if wget "$SDK_URL" -O "$SDK_FILE" --show-progress; then - mkdir -p "$ANDROID_SDK_PATH/cmdline-tools" - unzip -q "$SDK_FILE" -d "$TEMP_DIR" - if [ -d "$TEMP_DIR/cmdline-tools" ]; then - # Copy contents instead of mv to avoid inter-device issues - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR/cmdline-tools"/* "$ANDROID_SDK_PATH/cmdline-tools/" - else - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR"/* "$ANDROID_SDK_PATH/cmdline-tools/" - fi - rm -rf "$TEMP_DIR" - print_success "Android SDK Command Line Tools installed" - else - echo -e "${RED}Failed to download Android SDK${NC}" - wait_for_manual_install "sdkmanager" "https://developer.android.com/studio#command-tools" - fi - else - echo -e "${RED}curl or wget required to download SDK${NC}" - wait_for_manual_install "sdkmanager" "https://developer.android.com/studio#command-tools" - fi + print_warning "Command-line tools not found" + if ask_yes_no "Install Android SDK Command Line Tools?" "y"; then + install_cmdline_tools fi fi echo "" # ============================================================================ -# Step 3: Set up environment variables +# Step 3: Environment variables # ============================================================================ echo -e "${BLUE}Step 3: Setting up environment variables...${NC}" -# Find Java home if not set if [ -z "$JAVA_HOME" ]; then print_info "JAVA_HOME not set, attempting to detect..." - + if [ "$OS_NAME" == "macOS" ]; then - # Try to find Java 17 installed via Homebrew or zulu if [ -d "/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home" ]; then JAVA_HOME="/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home" - print_success "Found Zulu JDK 17 at: $JAVA_HOME" elif [ -d "/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home" ]; then JAVA_HOME="/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home" - print_success "Found Temurin JDK 17 at: $JAVA_HOME" elif [ -d "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" ]; then JAVA_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" - print_success "Found OpenJDK 17 at: $JAVA_HOME" elif [ -d "/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" ]; then JAVA_HOME="/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" - print_success "Found OpenJDK 17 at: $JAVA_HOME" elif command_exists java; then - JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null) - if [ -n "$JAVA_HOME" ]; then - print_success "Found Java 17 at: $JAVA_HOME" - fi + JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || true) fi else - # Ubuntu/Linux if [ -d "/usr/lib/jvm/zulu17-ca-jdk-amd64" ]; then JAVA_HOME="/usr/lib/jvm/zulu17-ca-jdk-amd64" - print_success "Found Zulu JDK 17 at: $JAVA_HOME" elif [ -d "/usr/lib/jvm/java-17-openjdk-amd64" ]; then JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64" - print_success "Found OpenJDK 17 at: $JAVA_HOME" elif [ -d "/usr/lib/jvm/temurin-17-jdk-amd64" ]; then JAVA_HOME="/usr/lib/jvm/temurin-17-jdk-amd64" - print_success "Found Temurin JDK 17 at: $JAVA_HOME" elif command_exists java; then - JAVA_HOME=$(dirname $(dirname $(readlink $(readlink $(which java))))) - print_success "Found Java at: $JAVA_HOME" + JAVA_HOME=$(dirname "$(dirname "$(readlink "$(readlink "$(which java)")")")") fi fi - - if [ -z "$JAVA_HOME" ]; then + + if [ -n "$JAVA_HOME" ]; then + print_success "Found Java at: $JAVA_HOME" + else print_warning "Could not auto-detect Java installation" - print_info "Please set JAVA_HOME manually after installing Java" fi else print_success "JAVA_HOME already set to: $JAVA_HOME" fi -# Set up Android SDK path if not already set -if [ -z "$ANDROID_HOME" ] && [ -z "$ANDROID_SDK_ROOT" ]; then - print_info "ANDROID_HOME not set, using default: $ANDROID_SDK_PATH" - export ANDROID_HOME="$ANDROID_SDK_PATH" - export ANDROID_SDK_ROOT="$ANDROID_SDK_PATH" - print_success "ANDROID_HOME set to: $ANDROID_HOME" -elif [ -d "$ANDROID_SDK_PATH" ]; then - print_success "ANDROID_HOME found: $ANDROID_SDK_PATH" -else - print_warning "ANDROID_HOME points to non-existent directory: $ANDROID_SDK_PATH" - print_info "Please set ANDROID_HOME to your Android SDK location" -fi +export ANDROID_HOME="$ANDROID_SDK_PATH" +export ANDROID_SDK_ROOT="$ANDROID_SDK_PATH" +print_success "ANDROID_HOME=$ANDROID_HOME" -# Update PATH with Android tools -echo "" -echo "Updating PATH..." +ensure_cmdline_tools_layout if [ -d "$ANDROID_HOME/cmdline-tools/latest/bin" ]; then export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" fi - if [ -d "$ANDROID_HOME/platform-tools" ]; then export PATH="$ANDROID_HOME/platform-tools:$PATH" fi - -if [ -d "$ANDROID_HOME/tools" ]; then - export PATH="$ANDROID_HOME/tools:$PATH" +if [ -d "$ANDROID_HOME/emulator" ]; then + export PATH="$ANDROID_HOME/emulator:$PATH" fi - -if [ -d "$ANDROID_HOME/tools/bin" ]; then - export PATH="$ANDROID_HOME/tools/bin:$PATH" -fi - if [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME/bin" ]; then export PATH="$JAVA_HOME/bin:$PATH" fi - print_success "PATH updated" + echo "" # ============================================================================ -# Step 4: Check Android SDK Manager +# Step 4: SDK packages (skip any package already on disk) # ============================================================================ -echo -e "${BLUE}Step 4: Checking Android SDK Manager...${NC}" - -if command_exists sdkmanager; then - print_success "sdkmanager found" - - # Show installed packages - print_info "Installed packages:" - sdkmanager --list_installed 2>/dev/null | head -20 || print_warning "Could not list SDK packages" +echo -e "${BLUE}Step 4: Android SDK packages (API $ANDROID_API)...${NC}" + +SM=$(sdkmanager_bin) +if [ -n "$SM" ]; then + print_success "sdkmanager: $SM" + print_info "Accepting licenses if needed (no package download)" + yes | "$SM" --sdk_root="$ANDROID_HOME" --licenses >/dev/null || true + + ensure_sdk_package "platform-tools" + ensure_sdk_package "platforms;android-${ANDROID_API}" + ensure_sdk_package "build-tools;${BUILD_TOOLS_VER}" + ensure_sdk_package "emulator" + ensure_sdk_package "$(sdk_sysimage_pkg)" else - print_warning "sdkmanager not found" - if ask_yes_no "Install Android SDK Command Line Tools?" "y"; then - print_info "Downloading Android SDK Command Line Tools..." - - # Use the stable URL format - if [ "$OS_NAME" == "macOS" ]; then - SDK_ZIP="commandlinetools-mac-11076708_latest.zip" - SDK_URL="https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip" - else - SDK_ZIP="commandlinetools-linux-11076708_latest.zip" - SDK_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" - fi - - TEMP_DIR=$(mktemp -d) - SDK_FILE="$TEMP_DIR/$SDK_ZIP" - - print_info "Downloading from: $SDK_URL" - - if has_curl; then - if curl -L "$SDK_URL" -o "$SDK_FILE" --fail; then - mkdir -p "$ANDROID_SDK_PATH/cmdline-tools" - unzip -q "$SDK_FILE" -d "$TEMP_DIR" - if [ -d "$TEMP_DIR/cmdline-tools" ]; then - # Copy contents instead of mv to avoid inter-device issues - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR/cmdline-tools"/* "$ANDROID_SDK_PATH/cmdline-tools/" - else - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR"/* "$ANDROID_SDK_PATH/cmdline-tools/" - fi - rm -rf "$TEMP_DIR" - print_success "Android SDK Command Line Tools installed" - else - echo -e "${RED}Failed to download Android SDK${NC}" - wait_for_manual_install "sdkmanager" "https://developer.android.com/studio#command-tools" - fi - elif has_wget; then - if wget "$SDK_URL" -O "$SDK_FILE" --show-progress; then - mkdir -p "$ANDROID_SDK_PATH/cmdline-tools" - unzip -q "$SDK_FILE" -d "$TEMP_DIR" - if [ -d "$TEMP_DIR/cmdline-tools" ]; then - # Copy contents instead of mv to avoid inter-device issues - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR/cmdline-tools"/* "$ANDROID_SDK_PATH/cmdline-tools/" - else - rm -rf "$ANDROID_SDK_PATH/cmdline-tools"/* - cp -r "$TEMP_DIR"/* "$ANDROID_SDK_PATH/cmdline-tools/" - fi - rm -rf "$TEMP_DIR" - print_success "Android SDK Command Line Tools installed" - else - echo -e "${RED}Failed to download Android SDK${NC}" - wait_for_manual_install "sdkmanager" "https://developer.android.com/studio#command-tools" - fi - else - echo -e "${RED}curl or wget required to download SDK${NC}" - wait_for_manual_install "sdkmanager" "https://developer.android.com/studio#command-tools" - fi - fi + print_warning "sdkmanager not found — skip package install" fi echo "" # ============================================================================ -# Step 5: Check ADB +# Step 5: Local emulator AVD # ============================================================================ -echo -e "${BLUE}Step 5: Checking ADB...${NC}" +echo -e "${BLUE}Step 5: Local emulator (AVD $AVD_NAME)...${NC}" -if command_exists adb; then - ADB_VERSION=$(adb version 2>&1) - print_success "ADB found: $(echo $ADB_VERSION | head -1)" +if [ -f "$HOME/.android/avd/${AVD_NAME}.ini" ]; then + print_success "AVD $AVD_NAME already exists — skip create" else - print_warning "ADB not found in PATH" - if ask_yes_no "Install Android Platform Tools (includes adb)?" "y"; then - print_info "Downloading Android Platform Tools..." - - if [ "$OS_NAME" == "macOS" ]; then - PLATFORM_TOOLS_ZIP="platform-tools-latest-darwin.zip" - PLATFORM_URL="https://dl.google.com/android/repository/platform-tools-latest-darwin.zip" - else - PLATFORM_TOOLS_ZIP="platform-tools-latest-linux.zip" - PLATFORM_URL="https://dl.google.com/android/repository/platform-tools-latest-linux.zip" - fi - - TEMP_DIR=$(mktemp -d) - PLATFORM_FILE="$TEMP_DIR/$PLATFORM_TOOLS_ZIP" - - print_info "Downloading from: $PLATFORM_URL" - - if has_curl; then - if curl -L "$PLATFORM_URL" -o "$PLATFORM_FILE" --fail; then - unzip -q "$PLATFORM_FILE" -d "$TEMP_DIR" - - # Copy adb to /usr/local/bin - if [ -f "$TEMP_DIR/platform-tools/adb" ]; then - if [ "$(id -u)" -eq 0 ]; then - cp "$TEMP_DIR/platform-tools/adb" /usr/local/bin/ - else - echo "Copying adb to /usr/local/bin with sudo..." - sudo cp "$TEMP_DIR/platform-tools/adb" /usr/local/bin/ - fi - fi - - # Copy fastboot if it exists - if [ -f "$TEMP_DIR/platform-tools/fastboot" ]; then - if [ "$(id -u)" -eq 0 ]; then - cp "$TEMP_DIR/platform-tools/fastboot" /usr/local/bin/ - else - sudo cp "$TEMP_DIR/platform-tools/fastboot" /usr/local/bin/ 2>/dev/null || true - fi - elif [ -f "$TEMP_DIR/tools/fastboot" ]; then - if [ "$(id -u)" -eq 0 ]; then - cp "$TEMP_DIR/tools/fastboot" /usr/local/bin/ - else - sudo cp "$TEMP_DIR/tools/fastboot" /usr/local/bin/ 2>/dev/null || true - fi - fi - - rm -rf "$TEMP_DIR" - print_success "Android Platform Tools installed" - else - echo -e "${RED}Failed to download Android Platform Tools${NC}" - wait_for_manual_install "adb" "https://developer.android.com/tools#platform-tools" - fi - elif has_wget; then - if wget "$PLATFORM_URL" -O "$PLATFORM_FILE" --show-progress; then - unzip -q "$PLATFORM_FILE" -d "$TEMP_DIR" - - if [ -f "$TEMP_DIR/platform-tools/adb" ]; then - if [ "$(id -u)" -eq 0 ]; then - cp "$TEMP_DIR/platform-tools/adb" /usr/local/bin/ - else - echo "Copying adb to /usr/local/bin with sudo..." - sudo cp "$TEMP_DIR/platform-tools/adb" /usr/local/bin/ - fi - fi - - if [ -f "$TEMP_DIR/platform-tools/fastboot" ]; then - if [ "$(id -u)" -eq 0 ]; then - cp "$TEMP_DIR/platform-tools/fastboot" /usr/local/bin/ - else - sudo cp "$TEMP_DIR/platform-tools/fastboot" /usr/local/bin/ 2>/dev/null || true - fi - elif [ -f "$TEMP_DIR/tools/fastboot" ]; then - if [ "$(id -u)" -eq 0 ]; then - cp "$TEMP_DIR/tools/fastboot" /usr/local/bin/ - else - sudo cp "$TEMP_DIR/tools/fastboot" /usr/local/bin/ 2>/dev/null || true - fi - fi - - rm -rf "$TEMP_DIR" - print_success "Android Platform Tools installed" - else - echo -e "${RED}Failed to download Android Platform Tools${NC}" - wait_for_manual_install "adb" "https://developer.android.com/tools#platform-tools" - fi - else - echo -e "${RED}curl or wget required to download Platform Tools${NC}" - wait_for_manual_install "adb" "https://developer.android.com/tools#platform-tools" - fi + AM=$(avdmanager_bin) + if [ -z "$AM" ]; then + print_warning "avdmanager not found; cannot create AVD" + elif ask_yes_no "Create local emulator $AVD_NAME?" "y"; then + print_info "Creating AVD $AVD_NAME ($(sdk_sysimage_pkg))" + echo no | "$AM" create avd \ + --name "$AVD_NAME" \ + --package "$(sdk_sysimage_pkg)" \ + --device "$AVD_DEVICE" \ + --force + print_success "AVD $AVD_NAME created" fi fi +if command_exists adb || [ -x "$ANDROID_HOME/platform-tools/adb" ]; then + print_success "adb available (SDK platform-tools — no extra zip)" +else + print_warning "adb not found after platform-tools install" +fi + echo "" # ============================================================================ -# Step 6: Check Gradle +# Step 6: Gradle wrapper # ============================================================================ echo -e "${BLUE}Step 6: Checking Gradle...${NC}" -GRADLE_WRAPPER_OK=false - -# Check for gradlew script in project root -GRADLEW_SCRIPT="gradlew" -GRADLEW_JAR="gradle/wrapper/gradle-wrapper.jar" - -if [ -f "$GRADLEW_SCRIPT" ]; then - print_success "Gradle wrapper script found ($GRADLEW_SCRIPT)" - - if [ -f "$GRADLEW_JAR" ]; then - print_success "Gradle wrapper JAR found ($GRADLEW_JAR)" - - # Test the wrapper - if bash "$GRADLEW_SCRIPT" --version &> /dev/null; then - print_success "Gradle wrapper is working" - GRADLE_WRAPPER_OK=true - else - print_warning "Gradle wrapper test failed" - print_info "This may be due to missing Java or Android SDK" - fi +if [ -f "gradlew" ]; then + print_success "Gradle wrapper script found (gradlew)" + if [ -f "gradle/wrapper/gradle-wrapper.jar" ]; then + print_success "Gradle wrapper JAR found" else - print_warning "Gradle wrapper JAR not found ($GRADLEW_JAR)" - print_info "Gradle wrapper files are required but missing" - print_info "The project should include gradlew and gradle/wrapper/gradle-wrapper.jar" - print_info "You may need to initialize the Gradle wrapper:" - print_info " gradle wrapper --gradle-version 8.9" + print_warning "gradle/wrapper/gradle-wrapper.jar missing" fi else - print_warning "Gradle wrapper script not found ($GRADLEW_SCRIPT)" - print_info "Gradle wrapper files are required but missing" - print_info "You may need to initialize the Gradle wrapper:" - print_info " gradle wrapper --gradle-version 8.9" + print_warning "gradlew not found in the current directory" + print_info "Run this script from the repository root" fi echo "" # ============================================================================ -# Step 7: Create local.properties (if not exists) +# Step 7: local.properties # ============================================================================ echo -e "${BLUE}Step 7: Checking local.properties...${NC}" if [ ! -f "local.properties" ]; then - print_info "Creating local.properties..." - { echo "# Android SDK path - auto-generated by setup.sh" echo "sdk.dir=$ANDROID_HOME" } > local.properties - print_success "local.properties created" else print_success "local.properties already exists" - - # Check if sdk.dir is set correctly CURRENT_SDK=$(grep -E "^sdk\.dir=" local.properties 2>/dev/null | cut -d'=' -f2) if [ "$CURRENT_SDK" != "$ANDROID_HOME" ]; then print_warning "Current sdk.dir ($CURRENT_SDK) differs from ANDROID_HOME ($ANDROID_HOME)" if ask_yes_no "Update local.properties with correct SDK path?" "y"; then sed -i.bak "s|^sdk\.dir=.*|sdk.dir=$ANDROID_HOME|" local.properties - rm local.properties.bak + rm -f local.properties.bak print_success "local.properties updated" fi else @@ -756,7 +571,7 @@ fi echo "" # ============================================================================ -# Step 8: Display environment summary +# Summary # ============================================================================ echo -e "${BLUE}========================================${NC}" echo -e "${BLUE} Environment Summary${NC}" @@ -768,37 +583,21 @@ else echo -e "JAVA_HOME=${YELLOW}NOT SET (install Java 17 and set JAVA_HOME)${NC}" fi echo -e "ANDROID_HOME=$ANDROID_HOME" +echo -e "AVD=$AVD_NAME" echo "" - -# Final summary -echo -e "${BLUE}========================================${NC}" -echo -e "${BLUE} Setup Complete!${NC}" -echo -e "${BLUE}========================================${NC}" -echo "" -echo -e "To build the project, run:" -echo -e " ${GREEN}./gradlew assembleDebug${NC}" +echo -e "To build: ${GREEN}./gradlew assembleDebug${NC}" +echo -e "To deploy: ${GREEN}./scripts/deploy.sh debug${NC}" +echo -e " (starts $AVD_NAME if no device is connected)" echo "" -echo -e "To deploy to a connected device:" -echo -e " ${GREEN}./scripts/deploy.sh${NC}" +echo -e "Re-running this script skips every zip and SDK package already on disk." +echo -e "Cached archives live in ${GREEN}$CACHE_DIR${NC}." echo "" -echo -e "Note: This script has set environment variables for this session." -echo -e "To make these changes permanent, add the following to your shell profile (~/.bashrc, ~/.zshrc, etc.):" +echo -e "To make environment variables permanent, add:" echo "" - if [ -n "$JAVA_HOME" ]; then - echo "### Document Manager Dev Environment ###" echo "export JAVA_HOME=$JAVA_HOME" - echo "export ANDROID_HOME=$ANDROID_HOME" - echo "export ANDROID_SDK_ROOT=\$ANDROID_HOME" - echo 'export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"' - echo "### End Document Manager Setup ###" -else - echo "### Document Manager Dev Environment ###" - echo "# NOTE: Java was not detected. Please set JAVA_HOME to your JDK 17 installation." - echo "# See instructions above for your OS." - echo "export ANDROID_HOME=$ANDROID_HOME" - echo "export ANDROID_SDK_ROOT=\$ANDROID_HOME" - echo 'export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"' - echo "### End Document Manager Setup ###" fi +echo "export ANDROID_HOME=$ANDROID_HOME" +echo "export ANDROID_SDK_ROOT=\$ANDROID_HOME" +echo 'export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"' echo ""