diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f54e9c7b82ab..402b93ef4d18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,3 +200,95 @@ jobs: if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' run: bun run typecheck working-directory: apps/tauri + + android-lint: + name: Android Lint + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check if files have changed + uses: dorny/paths-filter@v3 + continue-on-error: true + id: filter + with: + filters: | + changes: + - 'apps/mobile/android/**' + - 'apps/mobile/modules/**/android/**' + - '.github/workflows/ci.yml' + + - name: Setup Java + if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Bun + if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.4" + + - name: Install dependencies + if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + run: bun install + + - name: Run detekt + if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + run: ./gradlew :sd-mobile-core:detekt --no-daemon + working-directory: apps/mobile/android + + - name: Run ktlint + if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + run: ./gradlew :sd-mobile-core:ktlintCheck --no-daemon + working-directory: apps/mobile/android + + mobile-rust-tests: + name: Mobile Rust Tests + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Maximize build space + uses: easimon/maximize-build-space@master + with: + swap-size-mb: 3072 + root-reserve-mb: 6144 + remove-dotnet: "true" + remove-codeql: "true" + remove-haskell: "true" + remove-docker-images: "true" + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check if files have changed + uses: dorny/paths-filter@v3 + continue-on-error: true + id: filter + with: + filters: | + changes: + - 'apps/mobile/modules/sd-mobile-core/core/**' + - 'core/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/ci.yml' + + - name: Setup System and Rust + if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + uses: ./.github/actions/setup-system + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run mobile core tests + if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' + run: cargo test --manifest-path apps/mobile/modules/sd-mobile-core/core/Cargo.toml --locked diff --git a/Cargo.lock b/Cargo.lock index 3421380b53f5..830d01fbba6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,23 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log 0.4.29", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -232,7 +249,7 @@ checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "image", - "log 0.4.28", + "log 0.4.29", "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", @@ -477,7 +494,7 @@ checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ "base64 0.22.1", "http 1.3.1", - "log 0.4.28", + "log 0.4.29", "url", ] @@ -495,7 +512,7 @@ checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" dependencies = [ "anyhow", "arrayvec", - "log 0.4.28", + "log 0.4.29", "nom 7.1.3", "num-rational", "v_frame", @@ -726,7 +743,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -744,8 +761,8 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.12.1", - "log 0.4.28", + "itertools 0.13.0", + "log 0.4.29", "prettyplease", "proc-macro2", "quote", @@ -1474,7 +1491,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" dependencies = [ - "log 0.4.28", + "log 0.4.29", "web-sys", ] @@ -1647,7 +1664,7 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli 0.26.2", - "log 0.4.28", + "log 0.4.29", "regalloc2", "smallvec", "target-lexicon", @@ -1678,7 +1695,7 @@ dependencies = [ "fxhash", "hashbrown 0.12.3", "indexmap 1.9.3", - "log 0.4.28", + "log 0.4.29", "smallvec", ] @@ -1695,7 +1712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d70abacb8cfef3dc8ff7e8836e9c1d70f7967dfdac824a4cd5e30223415aca6" dependencies = [ "cranelift-codegen", - "log 0.4.28", + "log 0.4.29", "smallvec", "target-lexicon", ] @@ -2323,7 +2340,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -2639,6 +2656,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log 0.4.29", + "regex", +] + [[package]] name = "equator" version = "0.4.2" @@ -2977,7 +3004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716" dependencies = [ "fontconfig-parser", - "log 0.4.28", + "log 0.4.29", "memmap2 0.9.8", "slotmap", "tinyvec", @@ -3349,7 +3376,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "log 0.4.28", + "log 0.4.29", "rustversion", "windows 0.61.3", ] @@ -3622,7 +3649,7 @@ checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" dependencies = [ "aho-corasick", "bstr", - "log 0.4.28", + "log 0.4.29", "regex-automata", "regex-syntax", "serde", @@ -3995,7 +4022,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ - "log 0.4.28", + "log 0.4.29", "mac", "markup5ever", "match_token", @@ -4236,9 +4263,9 @@ dependencies = [ "core-foundation-sys", "iana-time-zone-haiku", "js-sys", - "log 0.4.28", + "log 0.4.29", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4395,7 +4422,7 @@ dependencies = [ "futures", "if-addrs", "ipnet", - "log 0.4.28", + "log 0.4.29", "netlink-packet-core 0.7.0", "netlink-packet-route 0.17.1", "netlink-proto 0.11.5", @@ -4419,7 +4446,7 @@ dependencies = [ "http-body-util", "hyper 1.7.0", "hyper-util", - "log 0.4.28", + "log 0.4.29", "rand 0.9.2", "tokio", "url", @@ -5006,7 +5033,7 @@ dependencies = [ "cfg-if", "combine", "jni-sys", - "log 0.4.28", + "log 0.4.29", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", @@ -5140,7 +5167,7 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ - "log 0.4.28", + "log 0.4.29", "zeroize", ] @@ -5228,7 +5255,7 @@ dependencies = [ "gtk", "gtk-sys", "libappindicator-sys", - "log 0.4.28", + "log 0.4.29", ] [[package]] @@ -5392,14 +5419,14 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" dependencies = [ - "log 0.4.28", + "log 0.4.29", ] [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "log-analyzer" @@ -5498,7 +5525,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" dependencies = [ - "log 0.4.28", + "log 0.4.29", "phf 0.11.3", "phf_codegen 0.11.3", "string_cache", @@ -5636,7 +5663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "log 0.4.28", + "log 0.4.29", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -5648,7 +5675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "log 0.4.28", + "log 0.4.29", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -5804,7 +5831,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", - "log 0.4.28", + "log 0.4.29", "openssl", "openssl-probe 0.1.6", "openssl-sys", @@ -5822,7 +5849,7 @@ checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.9.4", "jni-sys", - "log 0.4.28", + "log 0.4.29", "ndk-sys", "num_enum", "raw-window-handle", @@ -5852,7 +5879,7 @@ checksum = "93062a0dce6da2517ea35f301dfc88184ce18d3601ec786a727a87bf535deca9" dependencies = [ "byteorder", "libc", - "log 0.4.28", + "log 0.4.29", "neli-proc-macros", ] @@ -5928,7 +5955,7 @@ checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" dependencies = [ "bitflags 2.9.4", "libc", - "log 0.4.28", + "log 0.4.29", "netlink-packet-core 0.8.1", ] @@ -5952,7 +5979,7 @@ checksum = "72452e012c2f8d612410d89eea01e2d9b56205274abb35d53f60200b2ec41d60" dependencies = [ "bytes", "futures", - "log 0.4.28", + "log 0.4.29", "netlink-packet-core 0.7.0", "netlink-sys", "thiserror 2.0.16", @@ -5966,7 +5993,7 @@ checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" dependencies = [ "bytes", "futures", - "log 0.4.28", + "log 0.4.29", "netlink-packet-core 0.8.1", "netlink-sys", "thiserror 2.0.16", @@ -5981,7 +6008,7 @@ dependencies = [ "bytes", "futures", "libc", - "log 0.4.28", + "log 0.4.29", "tokio", ] @@ -6102,7 +6129,7 @@ dependencies = [ "inotify 0.9.6", "kqueue", "libc", - "log 0.4.28", + "log 0.4.29", "mio 0.8.11", "walkdir", "windows-sys 0.48.0", @@ -6119,7 +6146,7 @@ dependencies = [ "inotify 0.11.0", "kqueue", "libc", - "log 0.4.28", + "log 0.4.29", "mio 1.0.4", "notify-types", "walkdir", @@ -6679,7 +6706,7 @@ dependencies = [ "getrandom 0.2.16", "http 1.3.1", "http-body 1.0.1", - "log 0.4.28", + "log 0.4.29", "md-5", "moka", "percent-encoding", @@ -6794,7 +6821,7 @@ version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" dependencies = [ - "log 0.4.28", + "log 0.4.29", "plist", "serde", "windows-sys 0.52.0", @@ -6939,10 +6966,10 @@ dependencies = [ "console_error_panic_hook", "console_log", "image", - "itertools 0.12.1", + "itertools 0.13.0", "js-sys", - "libloading 0.7.4", - "log 0.3.9", + "libloading 0.8.9", + "log 0.4.29", "maybe-owned", "once_cell", "utf16string", @@ -7209,7 +7236,7 @@ dependencies = [ "futures-buffered", "futures-lite", "getrandom 0.3.3", - "log 0.4.28", + "log 0.4.29", "lru 0.16.3", "ntimestamp", "reqwest 0.12.23", @@ -7929,7 +7956,7 @@ dependencies = [ "itertools 0.12.1", "libc", "libfuzzer-sys", - "log 0.4.28", + "log 0.4.29", "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", @@ -8064,7 +8091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300d4fbfb40c1c66a78ba3ddd41c1110247cf52f97b87d0f2fc9209bd49b030c" dependencies = [ "fxhash", - "log 0.4.28", + "log 0.4.29", "slice-group-by", "smallvec", ] @@ -8136,7 +8163,7 @@ dependencies = [ "home", "http 1.3.1", "jsonwebtoken", - "log 0.4.28", + "log 0.4.29", "percent-encoding", "quick-xml 0.37.5", "rand 0.8.5", @@ -8168,7 +8195,7 @@ dependencies = [ "hyper-tls 0.5.0", "ipnet", "js-sys", - "log 0.4.28", + "log 0.4.29", "mime", "native-tls", "once_cell", @@ -8213,7 +8240,7 @@ dependencies = [ "hyper-tls 0.6.0", "hyper-util", "js-sys", - "log 0.4.28", + "log 0.4.29", "mime", "native-tls", "percent-encoding", @@ -8254,7 +8281,7 @@ checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" dependencies = [ "gif", "image-webp 0.1.3", - "log 0.4.28", + "log 0.4.29", "pico-args", "rgb", "svgtypes", @@ -8276,7 +8303,7 @@ dependencies = [ "gobject-sys", "gtk-sys", "js-sys", - "log 0.4.28", + "log 0.4.29", "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", @@ -8412,7 +8439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a552eb82d19f38c3beed3f786bd23aa434ceb9ac43ab44419ca6d67a7e186c0" dependencies = [ "futures", - "log 0.4.28", + "log 0.4.29", "netlink-packet-core 0.7.0", "netlink-packet-route 0.17.1", "netlink-packet-utils", @@ -8549,7 +8576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "aws-lc-rs", - "log 0.4.28", + "log 0.4.29", "once_cell", "ring 0.17.14", "rustls-pki-types", @@ -8598,7 +8625,7 @@ dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", "jni", - "log 0.4.28", + "log 0.4.29", "once_cell", "rustls", "rustls-native-certs", @@ -8643,7 +8670,7 @@ dependencies = [ "bitflags 2.9.4", "bytemuck", "core_maths", - "log 0.4.28", + "log 0.4.29", "smallvec", "ttf-parser", "unicode-bidi-mirroring", @@ -9064,9 +9091,11 @@ dependencies = [ name = "sd-mobile-core" version = "0.1.0" dependencies = [ + "android_logger", "anyhow", "jni", "libc", + "log 0.4.29", "once_cell", "openssl-sys", "sd-core", @@ -9169,7 +9198,7 @@ dependencies = [ "bigdecimal", "chrono", "futures-util", - "log 0.4.28", + "log 0.4.29", "ouroboros", "pgvector", "rust_decimal", @@ -9369,7 +9398,7 @@ dependencies = [ "cssparser", "derive_more 0.99.20", "fxhash", - "log 0.4.28", + "log 0.4.29", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", @@ -9843,7 +9872,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" dependencies = [ - "log 0.4.28", + "log 0.4.29", ] [[package]] @@ -9925,7 +9954,7 @@ dependencies = [ "core-graphics", "foreign-types 0.5.0", "js-sys", - "log 0.4.28", + "log 0.4.29", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", @@ -10150,7 +10179,7 @@ dependencies = [ "hashbrown 0.15.5", "hashlink 0.10.0", "indexmap 2.11.4", - "log 0.4.28", + "log 0.4.29", "memchr", "once_cell", "percent-encoding", @@ -10234,7 +10263,7 @@ dependencies = [ "hkdf", "hmac", "itoa", - "log 0.4.28", + "log 0.4.29", "md-5", "memchr", "once_cell", @@ -10278,7 +10307,7 @@ dependencies = [ "hmac", "home", "itoa", - "log 0.4.28", + "log 0.4.29", "md-5", "memchr", "num-bigint", @@ -10313,7 +10342,7 @@ dependencies = [ "futures-intrusive", "futures-util", "libsqlite3-sys", - "log 0.4.28", + "log 0.4.29", "percent-encoding", "serde", "serde_urlencoded", @@ -10665,7 +10694,7 @@ dependencies = [ "jni", "lazy_static", "libc", - "log 0.4.28", + "log 0.4.29", "ndk", "ndk-context", "ndk-sys", @@ -10753,7 +10782,7 @@ dependencies = [ "http-range", "jni", "libc", - "log 0.4.28", + "log 0.4.29", "mime", "muda", "objc2 0.6.3", @@ -10872,7 +10901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" dependencies = [ "arboard", - "log 0.4.28", + "log 0.4.29", "serde", "serde_json", "tauri", @@ -10886,7 +10915,7 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" dependencies = [ - "log 0.4.28", + "log 0.4.29", "raw-window-handle", "rfd", "serde", @@ -10927,7 +10956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" dependencies = [ "gethostname", - "log 0.4.28", + "log 0.4.29", "os_info", "serde", "serde_json", @@ -10945,7 +10974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c374b6db45f2a8a304f0273a15080d98c70cde86178855fc24653ba657a1144c" dependencies = [ "encoding_rs", - "log 0.4.28", + "log 0.4.29", "open", "os_pipe", "regex", @@ -10993,7 +11022,7 @@ dependencies = [ "gtk", "http 1.3.1", "jni", - "log 0.4.28", + "log 0.4.29", "objc2 0.6.3", "objc2-app-kit", "objc2-foundation 0.3.2", @@ -11028,7 +11057,7 @@ dependencies = [ "infer", "json-patch", "kuchikiki", - "log 0.4.28", + "log 0.4.29", "memchr", "phf 0.11.3", "proc-macro2", @@ -11199,7 +11228,7 @@ dependencies = [ "arrayvec", "bytemuck", "cfg-if", - "log 0.4.28", + "log 0.4.29", "png 0.17.16", "tiny-skia-path", ] @@ -11542,7 +11571,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "log 0.4.28", + "log 0.4.29", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -11587,7 +11616,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "log 0.4.28", + "log 0.4.29", "once_cell", "tracing-core", ] @@ -11926,7 +11955,7 @@ dependencies = [ "fontdb", "imagesize", "kurbo", - "log 0.4.28", + "log 0.4.29", "pico-args", "roxmltree", "rustybuzz", @@ -12140,7 +12169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", - "log 0.4.28", + "log 0.4.29", "proc-macro2", "quote", "syn 2.0.106", @@ -12463,7 +12492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", - "log 0.4.28", + "log 0.4.29", "pkg-config", ] @@ -13487,7 +13516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" dependencies = [ "libc", - "log 0.4.28", + "log 0.4.29", "os_pipe", "rustix 0.38.44", "tempfile", @@ -13507,7 +13536,7 @@ checksum = "3d3de777dce4cbcdc661d5d18e78ce4b46a37adc2bb7c0078a556c7f07bcce2f" dependencies = [ "chrono", "futures", - "log 0.4.28", + "log 0.4.29", "serde", "thiserror 2.0.16", "windows 0.61.3", @@ -13574,7 +13603,7 @@ dependencies = [ "async_io_stream", "futures", "js-sys", - "log 0.4.28", + "log 0.4.29", "pharos", "rustc_version", "send_wrapper", diff --git a/apps/mobile/android/SIGNING.md b/apps/mobile/android/SIGNING.md new file mode 100644 index 000000000000..08bbd046bd41 --- /dev/null +++ b/apps/mobile/android/SIGNING.md @@ -0,0 +1,91 @@ +# Android Release Signing Configuration + +This document explains how to configure release signing for the Spacedrive Android app. + +## Overview + +Release builds require a signing key to be installed on user devices. Debug builds use a shared debug keystore, but release builds should use a secure, production keystore. + +## Environment Variables + +The build system looks for the following environment variables: + +| Variable | Description | +|----------|-------------| +| `SPACEDRIVE_KEYSTORE_PATH` | Absolute path to your release keystore file (`.jks` or `.keystore`) | +| `SPACEDRIVE_KEYSTORE_PASSWORD` | Password for the keystore | +| `SPACEDRIVE_KEY_ALIAS` | Alias of the signing key within the keystore | +| `SPACEDRIVE_KEY_PASSWORD` | Password for the signing key (often same as keystore password) | + +## Setup Instructions + +### 1. Generate a Keystore (if you don't have one) + +```bash +keytool -genkey -v -keystore spacedrive-release.keystore \ + -alias spacedrive \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 +``` + +Follow the prompts to set passwords and enter organization details. + +### 2. Store the Keystore Securely + +- Keep the keystore file in a secure location (NOT in the repository) +- Back up the keystore - losing it means you cannot update your app +- Consider using a secrets manager for CI/CD + +### 3. Set Environment Variables + +For local development, add to your shell profile (`.bashrc`, `.zshrc`, etc.): + +```bash +export SPACEDRIVE_KEYSTORE_PATH="/path/to/spacedrive-release.keystore" +export SPACEDRIVE_KEYSTORE_PASSWORD="your-keystore-password" +export SPACEDRIVE_KEY_ALIAS="spacedrive" +export SPACEDRIVE_KEY_PASSWORD="your-key-password" +``` + +### 4. Build Release APK + +```bash +cd apps/mobile/android +./gradlew assembleRelease +``` + +The signed APK will be at: `app/build/outputs/apk/release/app-release.apk` + +## CI/CD Configuration + +For GitHub Actions, add secrets: +- `ANDROID_KEYSTORE_BASE64` - Base64-encoded keystore file +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` + +Example workflow step: +```yaml +- name: Decode keystore + run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore + +- name: Build release APK + env: + SPACEDRIVE_KEYSTORE_PATH: ${{ github.workspace }}/release.keystore + SPACEDRIVE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + SPACEDRIVE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + SPACEDRIVE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: ./gradlew assembleRelease +``` + +## Fallback Behavior + +If the environment variables are not set or the keystore file doesn't exist, the build falls back to the debug keystore. This allows developers to build release variants without production keys for testing purposes. + +## Security Notes + +- Never commit keystores or passwords to version control +- Use different keystores for development and production +- Rotate keys if they may have been compromised +- The Play Store requires consistent signing for app updates diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 9632655c461d..ef8e7d601d23 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -81,6 +81,43 @@ def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBu */ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' +/** + * Calculate dynamic versionCode based on: + * 1. CI build number (GITHUB_RUN_NUMBER or CI_BUILD_NUMBER env var) + * 2. Git commit count as fallback + * 3. Default value of 1 if neither available + * + * This ensures versionCode always increases for Play Store uploads. + */ +def getVersionCode = { + // Priority 1: Use CI build number if available + def ciBuildNumber = System.getenv("GITHUB_RUN_NUMBER") ?: System.getenv("CI_BUILD_NUMBER") + if (ciBuildNumber != null && ciBuildNumber.isInteger()) { + return ciBuildNumber.toInteger() + } + + // Priority 2: Count git commits + try { + def gitCommitCount = 'git rev-list --count HEAD'.execute([], rootDir).text.trim() + if (gitCommitCount.isInteger()) { + return gitCommitCount.toInteger() + } + } catch (Exception e) { + logger.warn("Failed to get git commit count: ${e.message}") + } + + // Fallback: return 1 + return 1 +} + +/** + * Get version name from environment or default. + * Use SPACEDRIVE_VERSION env var in CI to set semantic version. + */ +def getVersionName = { + return System.getenv("SPACEDRIVE_VERSION") ?: "1.0.0" +} + android { ndkVersion rootProject.ext.ndkVersion @@ -92,8 +129,8 @@ android { applicationId 'com.spacedrive.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0.0" + versionCode getVersionCode() + versionName getVersionName() buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } @@ -104,15 +141,36 @@ android { keyAlias 'androiddebugkey' keyPassword 'android' } + // Release signing config - uses environment variables for security + // Required env vars for release builds: + // SPACEDRIVE_KEYSTORE_PATH - Path to the release keystore file + // SPACEDRIVE_KEYSTORE_PASSWORD - Password for the keystore + // SPACEDRIVE_KEY_ALIAS - Alias of the signing key + // SPACEDRIVE_KEY_PASSWORD - Password for the signing key + // See: apps/mobile/android/SIGNING.md for setup instructions + release { + def keystorePath = System.getenv("SPACEDRIVE_KEYSTORE_PATH") + if (keystorePath != null && file(keystorePath).exists()) { + storeFile file(keystorePath) + storePassword System.getenv("SPACEDRIVE_KEYSTORE_PASSWORD") ?: "" + keyAlias System.getenv("SPACEDRIVE_KEY_ALIAS") ?: "" + keyPassword System.getenv("SPACEDRIVE_KEY_PASSWORD") ?: "" + } else { + // Fall back to debug keystore if release signing not configured + // This allows development builds without release keys + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } } buildTypes { debug { signingConfig signingConfigs.debug } release { - // Caution! In production, you need to generate your own keystore file. - // see https://reactnative.dev/docs/signed-apk-android. - signingConfig signingConfigs.debug + signingConfig signingConfigs.release def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' shrinkResources enableShrinkResources.toBoolean() minifyEnabled enableMinifyInReleaseBuilds diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index bb2d8597e8bb..02231d79568d 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,22 @@ - + - - + + + + + + + + + + + + @@ -13,7 +24,7 @@ - + diff --git a/apps/mobile/android/app/src/main/res/xml/backup_rules.xml b/apps/mobile/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 000000000000..16c494307f0e --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml b/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000000..18ca60c06e37 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle index 0554dd156c12..f2149bb383d2 100644 --- a/apps/mobile/android/build.gradle +++ b/apps/mobile/android/build.gradle @@ -12,6 +12,11 @@ buildscript { } } +plugins { + id 'io.gitlab.arturbosch.detekt' version '1.23.7' apply false + id 'org.jlleitschuh.gradle.ktlint' version '12.1.2' apply false +} + allprojects { repositories { google() diff --git a/apps/mobile/android/config/detekt.yml b/apps/mobile/android/config/detekt.yml new file mode 100644 index 000000000000..4fb2b2c2335a --- /dev/null +++ b/apps/mobile/android/config/detekt.yml @@ -0,0 +1,233 @@ +# Detekt configuration for Spacedrive Android +# See: https://detekt.dev/docs/rules/style + +build: + maxIssues: 0 + excludeCorrectable: false + weights: + complexity: 2 + style: 1 + comments: 1 + +config: + validation: true + warningsAsErrors: false + +console-reports: + active: true + +output-reports: + active: true + exclude: + - 'TxtOutputReport' + +complexity: + active: true + CyclomaticComplexMethod: + active: true + threshold: 15 + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + NestedBlockDepth: + active: true + threshold: 4 + TooManyFunctions: + active: true + thresholdInFiles: 25 + thresholdInClasses: 15 + thresholdInInterfaces: 10 + thresholdInObjects: 15 + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyFunctionBlock: + active: true + ignoreOverridden: true + +exceptions: + active: true + TooGenericExceptionCaught: + active: true + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'IllegalMonitorStateException' + - 'NullPointerException' + - 'IndexOutOfBoundsException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Throwable' + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + FunctionNaming: + active: true + functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' + excludeClassPattern: '$^' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + +performance: + active: true + SpreadOperator: + active: true + +potential-bugs: + active: true + CastToNullableType: + active: true + DoubleMutabilityForCollection: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + InvalidRange: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + MapGetWithNotNullAssertionOperator: + active: true + NullableToStringCall: + active: true + UnconditionalJumpStatementInLoop: + active: true + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + CollapsibleIfStatements: + active: true + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + EqualsNullCall: + active: true + ExplicitItLambdaParameter: + active: true + ForbiddenComment: + active: false + MagicNumber: + active: false + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: true + MayBeConst: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false # Project uses tabs for indentation + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 4 + excludeGuardClauses: true + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: true + ThrowsCount: + active: true + max: 3 + TrailingWhitespace: + active: true + UnderscoresInNumericLiterals: + active: true + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: true + UnnecessaryApply: + active: true + UnnecessaryFilter: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: true + UntilInsteadOfRangeTo: + active: true + UnusedImports: + active: true + UnusedPrivateMember: + active: true + allowedNames: '(_|ignored|expected|serialVersionUID)' + UseArrayLiteralsInAnnotations: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: true + allowVars: false + UseEmptyCounterpart: + active: true + UseIfEmptyOrIfBlank: + active: true + UseIsNullOrEmpty: + active: true + UseOrEmpty: + active: true + UseRequire: + active: true + VarCouldBeVal: + active: true + WildcardImport: + active: true + excludeImports: + - 'java.util.*' + - 'kotlinx.android.synthetic.*' diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 5b100d9d4285..3a56c431a4dd 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1,10 +1,10 @@ PODS: - - EXConstants (18.0.11): + - EXConstants (18.0.13): - ExpoModulesCore - EXJSONUtils (0.15.0) - EXManifests (1.0.10): - ExpoModulesCore - - Expo (54.0.27): + - Expo (54.0.32): - ExpoModulesCore - hermes-engine - RCTRequired @@ -200,7 +200,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ExpoAsset (12.0.11): + - ExpoAsset (12.0.12): - ExpoModulesCore - ExpoBlur (15.0.8): - ExpoModulesCore @@ -210,13 +210,13 @@ PODS: - ZXingObjC/PDF417 - ExpoDocumentPicker (14.0.8): - ExpoModulesCore - - ExpoFileSystem (19.0.20): + - ExpoFileSystem (19.0.21): - ExpoModulesCore - - ExpoFont (14.0.10): + - ExpoFont (14.0.11): - ExpoModulesCore - ExpoHaptics (15.0.8): - ExpoModulesCore - - ExpoHead (6.0.17): + - ExpoHead (6.0.22): - ExpoModulesCore - RNScreens - ExpoImage (3.0.11): @@ -228,9 +228,9 @@ PODS: - SDWebImageWebPCoder (~> 0.14.6) - ExpoKeepAwake (15.0.8): - ExpoModulesCore - - ExpoLinking (8.0.10): + - ExpoLinking (8.0.11): - ExpoModulesCore - - ExpoModulesCore (3.0.28): + - ExpoModulesCore (3.0.29): - hermes-engine - RCTRequired - RCTTypeSafety @@ -253,7 +253,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ExpoSplashScreen (31.0.12): + - ExpoSplashScreen (31.0.13): - ExpoModulesCore - EXUpdatesInterface (2.0.0): - ExpoModulesCore @@ -1648,7 +1648,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-slider (5.1.1): + - react-native-slider (5.1.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1660,7 +1660,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-slider/common (= 5.1.1) + - react-native-slider/common (= 5.1.2) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -1671,7 +1671,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-slider/common (5.1.1): + - react-native-slider/common (5.1.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2275,7 +2275,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNWorklets (0.7.1): + - RNWorklets (0.7.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2297,9 +2297,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNWorklets/worklets (= 0.7.1) + - RNWorklets/worklets (= 0.7.2) - Yoga - - RNWorklets/worklets (0.7.1): + - RNWorklets/worklets (0.7.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2321,9 +2321,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNWorklets/worklets/apple (= 0.7.1) + - RNWorklets/worklets/apple (= 0.7.2) - Yoga - - RNWorklets/worklets/apple (0.7.1): + - RNWorklets/worklets/apple (0.7.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2367,107 +2367,107 @@ PODS: - ZXingObjC/Core DEPENDENCIES: - - "EXConstants (from `../../../node_modules/.bun/expo-constants@18.0.11+668c6eeaed077a0c/node_modules/expo-constants/ios`)" - - "EXJSONUtils (from `../../../node_modules/.bun/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)" - - "EXManifests (from `../../../node_modules/.bun/expo-manifests@1.0.10+668c6eeaed077a0c/node_modules/expo-manifests/ios`)" - - "Expo (from `../../../node_modules/.bun/expo@54.0.27+668c6eeaed077a0c/node_modules/expo`)" - - "expo-dev-client (from `../../../node_modules/.bun/expo-dev-client@6.0.20+668c6eeaed077a0c/node_modules/expo-dev-client/ios`)" - - "expo-dev-launcher (from `../../../node_modules/.bun/expo-dev-launcher@6.0.20+668c6eeaed077a0c/node_modules/expo-dev-launcher`)" - - "expo-dev-menu (from `../../../node_modules/.bun/expo-dev-menu@7.0.18+668c6eeaed077a0c/node_modules/expo-dev-menu`)" - - "expo-dev-menu-interface (from `../../../node_modules/.bun/expo-dev-menu-interface@2.0.0+668c6eeaed077a0c/node_modules/expo-dev-menu-interface/ios`)" - - "ExpoAsset (from `../../../node_modules/.bun/expo-asset@12.0.11+668c6eeaed077a0c/node_modules/expo-asset/ios`)" - - "ExpoBlur (from `../../../node_modules/.bun/expo-blur@15.0.8+668c6eeaed077a0c/node_modules/expo-blur/ios`)" - - "ExpoCamera (from `../../../node_modules/.bun/expo-camera@17.0.10+668c6eeaed077a0c/node_modules/expo-camera/ios`)" - - "ExpoDocumentPicker (from `../../../node_modules/.bun/expo-document-picker@14.0.8+668c6eeaed077a0c/node_modules/expo-document-picker/ios`)" - - "ExpoFileSystem (from `../../../node_modules/.bun/expo-file-system@19.0.20+668c6eeaed077a0c/node_modules/expo-file-system/ios`)" - - "ExpoFont (from `../../../node_modules/.bun/expo-font@14.0.10+c262bee79918334c/node_modules/expo-font/ios`)" - - "ExpoHaptics (from `../../../node_modules/.bun/expo-haptics@15.0.8+668c6eeaed077a0c/node_modules/expo-haptics/ios`)" - - "ExpoHead (from `../../../node_modules/.bun/expo-router@6.0.17+a55fb14e5eb2d958/node_modules/expo-router/ios`)" - - "ExpoImage (from `../../../node_modules/.bun/expo-image@3.0.11+668c6eeaed077a0c/node_modules/expo-image/ios`)" - - "ExpoKeepAwake (from `../../../node_modules/.bun/expo-keep-awake@15.0.8+ddb0696906414ead/node_modules/expo-keep-awake/ios`)" - - "ExpoLinking (from `../../../node_modules/.bun/expo-linking@8.0.10+668c6eeaed077a0c/node_modules/expo-linking/ios`)" - - "ExpoModulesCore (from `../../../node_modules/.bun/expo-modules-core@3.0.28+87dd5a4c738f4c73/node_modules/expo-modules-core`)" - - "ExpoSplashScreen (from `../../../node_modules/.bun/expo-splash-screen@31.0.12+668c6eeaed077a0c/node_modules/expo-splash-screen/ios`)" - - "EXUpdatesInterface (from `../../../node_modules/.bun/expo-updates-interface@2.0.0+668c6eeaed077a0c/node_modules/expo-updates-interface/ios`)" - - "FBLazyVector (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/FBLazyVector`)" - - "hermes-engine (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)" - - "LiquidGlass (from `../../../node_modules/.bun/@callstack+liquid-glass@0.7.0+87dd5a4c738f4c73/node_modules/@callstack/liquid-glass`)" - - "RCTDeprecation (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)" - - "RCTRequired (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Required`)" - - "RCTTypeSafety (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/TypeSafety`)" - - "React (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/`)" - - "React-callinvoker (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/callinvoker`)" - - "React-Core (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/`)" - - "React-Core-prebuilt (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React-Core-prebuilt.podspec`)" - - "React-Core/RCTWebSocket (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/`)" - - "React-CoreModules (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React/CoreModules`)" - - "React-cxxreact (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/cxxreact`)" - - "React-debug (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/debug`)" - - "React-defaultsnativemodule (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/defaults`)" - - "React-domnativemodule (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/dom`)" - - "React-Fabric (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon`)" - - "React-FabricComponents (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon`)" - - "React-FabricImage (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon`)" - - "React-featureflags (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/featureflags`)" - - "React-featureflagsnativemodule (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)" - - "React-graphics (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/graphics`)" - - "React-hermes (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/hermes`)" - - "React-idlecallbacksnativemodule (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)" - - "React-ImageManager (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)" - - "React-jserrorhandler (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jserrorhandler`)" - - "React-jsi (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsi`)" - - "React-jsiexecutor (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsiexecutor`)" - - "React-jsinspector (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern`)" - - "React-jsinspectorcdp (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)" - - "React-jsinspectornetwork (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern/network`)" - - "React-jsinspectortracing (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)" - - "React-jsitooling (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsitooling`)" - - "React-jsitracing (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/hermes/executor/`)" - - "React-logger (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/logger`)" - - "React-Mapbuffer (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon`)" - - "React-microtasksnativemodule (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)" - - "react-native-safe-area-context (from `../../../node_modules/.bun/react-native-safe-area-context@5.6.2+87dd5a4c738f4c73/node_modules/react-native-safe-area-context`)" - - "react-native-slider (from `../../../node_modules/.bun/@react-native-community+slider@5.1.1/node_modules/@react-native-community/slider`)" - - "React-NativeModulesApple (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)" - - "React-oscompat (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/oscompat`)" - - "React-perflogger (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/reactperflogger`)" - - "React-performancetimeline (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/performance/timeline`)" - - "React-RCTActionSheet (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/ActionSheetIOS`)" - - "React-RCTAnimation (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/NativeAnimation`)" - - "React-RCTAppDelegate (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/AppDelegate`)" - - "React-RCTBlob (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Blob`)" - - "React-RCTFabric (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React`)" - - "React-RCTFBReactNativeSpec (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React`)" - - "React-RCTImage (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Image`)" - - "React-RCTLinking (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/LinkingIOS`)" - - "React-RCTNetwork (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Network`)" - - "React-RCTRuntime (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React/Runtime`)" - - "React-RCTSettings (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Settings`)" - - "React-RCTText (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Text`)" - - "React-RCTVibration (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Vibration`)" - - "React-rendererconsistency (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/consistency`)" - - "React-renderercss (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/css`)" - - "React-rendererdebug (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/debug`)" - - "React-RuntimeApple (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/runtime/platform/ios`)" - - "React-RuntimeCore (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/runtime`)" - - "React-runtimeexecutor (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/runtimeexecutor`)" - - "React-RuntimeHermes (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/runtime`)" - - "React-runtimescheduler (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)" - - "React-timing (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/timing`)" - - "React-utils (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/utils`)" + - EXConstants (from `../../../node_modules/expo-constants/ios`) + - EXJSONUtils (from `../../../node_modules/expo-json-utils/ios`) + - EXManifests (from `../../../node_modules/expo-manifests/ios`) + - Expo (from `../../../node_modules/expo`) + - expo-dev-client (from `../../../node_modules/expo-dev-client/ios`) + - expo-dev-launcher (from `../../../node_modules/expo-dev-launcher`) + - expo-dev-menu (from `../../../node_modules/expo-dev-menu`) + - expo-dev-menu-interface (from `../../../node_modules/expo-dev-menu-interface/ios`) + - ExpoAsset (from `../../../node_modules/expo-asset/ios`) + - ExpoBlur (from `../../../node_modules/expo-blur/ios`) + - ExpoCamera (from `../../../node_modules/expo-camera/ios`) + - ExpoDocumentPicker (from `../../../node_modules/expo-document-picker/ios`) + - ExpoFileSystem (from `../../../node_modules/expo-file-system/ios`) + - ExpoFont (from `../../../node_modules/expo-font/ios`) + - ExpoHaptics (from `../../../node_modules/expo-haptics/ios`) + - ExpoHead (from `../../../node_modules/expo-router/ios`) + - ExpoImage (from `../../../node_modules/expo-image/ios`) + - ExpoKeepAwake (from `../../../node_modules/expo-keep-awake/ios`) + - ExpoLinking (from `../../../node_modules/expo-linking/ios`) + - ExpoModulesCore (from `../../../node_modules/expo-modules-core`) + - ExpoSplashScreen (from `../../../node_modules/expo-splash-screen/ios`) + - EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`) + - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) + - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - "LiquidGlass (from `../../../node_modules/@callstack/liquid-glass`)" + - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) + - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../../../node_modules/react-native/`) + - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../../../node_modules/react-native/`) + - React-Core-prebuilt (from `../../../node_modules/react-native/React-Core-prebuilt.podspec`) + - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) + - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../../../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) + - "react-native-slider (from `../../../node_modules/@react-native-community/slider`)" + - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-oscompat (from `../../../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../../../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`) + - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../../../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../../../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`) - ReactAppDependencyProvider (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`) - - "ReactCommon/turbomodule/core (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon`)" - - "ReactNativeDependencies (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)" - - "RNCAsyncStorage (from `../../../node_modules/.bun/@react-native-async-storage+async-storage@2.2.0+87dd5a4c738f4c73/node_modules/@react-native-async-storage/async-storage`)" - - "RNCClipboard (from `../../../node_modules/.bun/@react-native-clipboard+clipboard@1.16.3+87dd5a4c738f4c73/node_modules/@react-native-clipboard/clipboard`)" - - "RNGestureHandler (from `../../../node_modules/.bun/react-native-gesture-handler@2.28.0+87dd5a4c738f4c73/node_modules/react-native-gesture-handler`)" - - "RNReanimated (from `../../../node_modules/.bun/react-native-reanimated@4.1.6+d983531a34c8e10a/node_modules/react-native-reanimated`)" - - "RNScreens (from `../../../node_modules/.bun/react-native-screens@4.16.0+87dd5a4c738f4c73/node_modules/react-native-screens`)" - - "RNSVG (from `../../../node_modules/.bun/react-native-svg@15.12.1+87dd5a4c738f4c73/node_modules/react-native-svg`)" - - "RNWorklets (from `../../../node_modules/.bun/react-native-worklets@0.7.1+87dd5a4c738f4c73/node_modules/react-native-worklets`)" + - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) + - ReactNativeDependencies (from `../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)" + - "RNCClipboard (from `../../../node_modules/@react-native-clipboard/clipboard`)" + - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`) + - RNReanimated (from `../../../node_modules/react-native-reanimated`) + - RNScreens (from `../../../node_modules/react-native-screens`) + - RNSVG (from `../../../node_modules/react-native-svg`) + - RNWorklets (from `../../../node_modules/react-native-worklets`) - SDMobileCore (from `../modules/sd-mobile-core/ios`) - - "Yoga (from `../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/yoga`)" + - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: trunk: @@ -2482,229 +2482,229 @@ SPEC REPOS: EXTERNAL SOURCES: EXConstants: - :path: "../../../node_modules/.bun/expo-constants@18.0.11+668c6eeaed077a0c/node_modules/expo-constants/ios" + :path: "../../../node_modules/expo-constants/ios" EXJSONUtils: - :path: "../../../node_modules/.bun/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios" + :path: "../../../node_modules/expo-json-utils/ios" EXManifests: - :path: "../../../node_modules/.bun/expo-manifests@1.0.10+668c6eeaed077a0c/node_modules/expo-manifests/ios" + :path: "../../../node_modules/expo-manifests/ios" Expo: - :path: "../../../node_modules/.bun/expo@54.0.27+668c6eeaed077a0c/node_modules/expo" + :path: "../../../node_modules/expo" expo-dev-client: - :path: "../../../node_modules/.bun/expo-dev-client@6.0.20+668c6eeaed077a0c/node_modules/expo-dev-client/ios" + :path: "../../../node_modules/expo-dev-client/ios" expo-dev-launcher: - :path: "../../../node_modules/.bun/expo-dev-launcher@6.0.20+668c6eeaed077a0c/node_modules/expo-dev-launcher" + :path: "../../../node_modules/expo-dev-launcher" expo-dev-menu: - :path: "../../../node_modules/.bun/expo-dev-menu@7.0.18+668c6eeaed077a0c/node_modules/expo-dev-menu" + :path: "../../../node_modules/expo-dev-menu" expo-dev-menu-interface: - :path: "../../../node_modules/.bun/expo-dev-menu-interface@2.0.0+668c6eeaed077a0c/node_modules/expo-dev-menu-interface/ios" + :path: "../../../node_modules/expo-dev-menu-interface/ios" ExpoAsset: - :path: "../../../node_modules/.bun/expo-asset@12.0.11+668c6eeaed077a0c/node_modules/expo-asset/ios" + :path: "../../../node_modules/expo-asset/ios" ExpoBlur: - :path: "../../../node_modules/.bun/expo-blur@15.0.8+668c6eeaed077a0c/node_modules/expo-blur/ios" + :path: "../../../node_modules/expo-blur/ios" ExpoCamera: - :path: "../../../node_modules/.bun/expo-camera@17.0.10+668c6eeaed077a0c/node_modules/expo-camera/ios" + :path: "../../../node_modules/expo-camera/ios" ExpoDocumentPicker: - :path: "../../../node_modules/.bun/expo-document-picker@14.0.8+668c6eeaed077a0c/node_modules/expo-document-picker/ios" + :path: "../../../node_modules/expo-document-picker/ios" ExpoFileSystem: - :path: "../../../node_modules/.bun/expo-file-system@19.0.20+668c6eeaed077a0c/node_modules/expo-file-system/ios" + :path: "../../../node_modules/expo-file-system/ios" ExpoFont: - :path: "../../../node_modules/.bun/expo-font@14.0.10+c262bee79918334c/node_modules/expo-font/ios" + :path: "../../../node_modules/expo-font/ios" ExpoHaptics: - :path: "../../../node_modules/.bun/expo-haptics@15.0.8+668c6eeaed077a0c/node_modules/expo-haptics/ios" + :path: "../../../node_modules/expo-haptics/ios" ExpoHead: - :path: "../../../node_modules/.bun/expo-router@6.0.17+a55fb14e5eb2d958/node_modules/expo-router/ios" + :path: "../../../node_modules/expo-router/ios" ExpoImage: - :path: "../../../node_modules/.bun/expo-image@3.0.11+668c6eeaed077a0c/node_modules/expo-image/ios" + :path: "../../../node_modules/expo-image/ios" ExpoKeepAwake: - :path: "../../../node_modules/.bun/expo-keep-awake@15.0.8+ddb0696906414ead/node_modules/expo-keep-awake/ios" + :path: "../../../node_modules/expo-keep-awake/ios" ExpoLinking: - :path: "../../../node_modules/.bun/expo-linking@8.0.10+668c6eeaed077a0c/node_modules/expo-linking/ios" + :path: "../../../node_modules/expo-linking/ios" ExpoModulesCore: - :path: "../../../node_modules/.bun/expo-modules-core@3.0.28+87dd5a4c738f4c73/node_modules/expo-modules-core" + :path: "../../../node_modules/expo-modules-core" ExpoSplashScreen: - :path: "../../../node_modules/.bun/expo-splash-screen@31.0.12+668c6eeaed077a0c/node_modules/expo-splash-screen/ios" + :path: "../../../node_modules/expo-splash-screen/ios" EXUpdatesInterface: - :path: "../../../node_modules/.bun/expo-updates-interface@2.0.0+668c6eeaed077a0c/node_modules/expo-updates-interface/ios" + :path: "../../../node_modules/expo-updates-interface/ios" FBLazyVector: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/FBLazyVector" + :path: "../../../node_modules/react-native/Libraries/FBLazyVector" hermes-engine: - :podspec: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 LiquidGlass: - :path: "../../../node_modules/.bun/@callstack+liquid-glass@0.7.0+87dd5a4c738f4c73/node_modules/@callstack/liquid-glass" + :path: "../../../node_modules/@callstack/liquid-glass" RCTDeprecation: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Required" + :path: "../../../node_modules/react-native/Libraries/Required" RCTTypeSafety: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/TypeSafety" + :path: "../../../node_modules/react-native/Libraries/TypeSafety" React: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/" + :path: "../../../node_modules/react-native/" React-callinvoker: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/callinvoker" + :path: "../../../node_modules/react-native/ReactCommon/callinvoker" React-Core: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/" + :path: "../../../node_modules/react-native/" React-Core-prebuilt: - :podspec: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React-Core-prebuilt.podspec" + :podspec: "../../../node_modules/react-native/React-Core-prebuilt.podspec" React-CoreModules: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React/CoreModules" + :path: "../../../node_modules/react-native/React/CoreModules" React-cxxreact: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/cxxreact" + :path: "../../../node_modules/react-native/ReactCommon/cxxreact" React-debug: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/debug" + :path: "../../../node_modules/react-native/ReactCommon/react/debug" React-defaultsnativemodule: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/defaults" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults" React-domnativemodule: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/dom" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom" React-Fabric: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-FabricComponents: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-FabricImage: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-featureflags: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/featureflags" + :path: "../../../node_modules/react-native/ReactCommon/react/featureflags" React-featureflagsnativemodule: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" React-graphics: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/graphics" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics" React-hermes: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/hermes" + :path: "../../../node_modules/react-native/ReactCommon/hermes" React-idlecallbacksnativemodule: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" React-ImageManager: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" React-jserrorhandler: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jserrorhandler" + :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler" React-jsi: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsi" + :path: "../../../node_modules/react-native/ReactCommon/jsi" React-jsiexecutor: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsiexecutor" + :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor" React-jsinspector: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern" React-jsinspectorcdp: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" React-jsinspectornetwork: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern/network" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/network" React-jsinspectortracing: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" React-jsitooling: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/jsitooling" + :path: "../../../node_modules/react-native/ReactCommon/jsitooling" React-jsitracing: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/hermes/executor/" + :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/" React-logger: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/logger" + :path: "../../../node_modules/react-native/ReactCommon/logger" React-Mapbuffer: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-microtasksnativemodule: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-safe-area-context: - :path: "../../../node_modules/.bun/react-native-safe-area-context@5.6.2+87dd5a4c738f4c73/node_modules/react-native-safe-area-context" + :path: "../../../node_modules/react-native-safe-area-context" react-native-slider: - :path: "../../../node_modules/.bun/@react-native-community+slider@5.1.1/node_modules/@react-native-community/slider" + :path: "../../../node_modules/@react-native-community/slider" React-NativeModulesApple: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/oscompat" + :path: "../../../node_modules/react-native/ReactCommon/oscompat" React-perflogger: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/reactperflogger" + :path: "../../../node_modules/react-native/ReactCommon/reactperflogger" React-performancetimeline: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/performance/timeline" + :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline" React-RCTActionSheet: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/ActionSheetIOS" + :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" React-RCTAnimation: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/NativeAnimation" + :path: "../../../node_modules/react-native/Libraries/NativeAnimation" React-RCTAppDelegate: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/AppDelegate" + :path: "../../../node_modules/react-native/Libraries/AppDelegate" React-RCTBlob: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Blob" + :path: "../../../node_modules/react-native/Libraries/Blob" React-RCTFabric: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React" + :path: "../../../node_modules/react-native/React" React-RCTFBReactNativeSpec: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React" + :path: "../../../node_modules/react-native/React" React-RCTImage: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Image" + :path: "../../../node_modules/react-native/Libraries/Image" React-RCTLinking: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/LinkingIOS" + :path: "../../../node_modules/react-native/Libraries/LinkingIOS" React-RCTNetwork: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Network" + :path: "../../../node_modules/react-native/Libraries/Network" React-RCTRuntime: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/React/Runtime" + :path: "../../../node_modules/react-native/React/Runtime" React-RCTSettings: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Settings" + :path: "../../../node_modules/react-native/Libraries/Settings" React-RCTText: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Text" + :path: "../../../node_modules/react-native/Libraries/Text" React-RCTVibration: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/Libraries/Vibration" + :path: "../../../node_modules/react-native/Libraries/Vibration" React-rendererconsistency: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/consistency" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency" React-renderercss: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/css" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/css" React-rendererdebug: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/debug" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug" React-RuntimeApple: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/runtime/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios" React-RuntimeCore: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/runtime" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" React-runtimeexecutor: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/runtimeexecutor" + :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor" React-RuntimeHermes: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/runtime" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" React-runtimescheduler: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" React-timing: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/timing" + :path: "../../../node_modules/react-native/ReactCommon/react/timing" React-utils: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/react/utils" + :path: "../../../node_modules/react-native/ReactCommon/react/utils" ReactAppDependencyProvider: :path: build/generated/ios ReactCodegen: :path: build/generated/ios ReactCommon: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" ReactNativeDependencies: - :podspec: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" RNCAsyncStorage: - :path: "../../../node_modules/.bun/@react-native-async-storage+async-storage@2.2.0+87dd5a4c738f4c73/node_modules/@react-native-async-storage/async-storage" + :path: "../../../node_modules/@react-native-async-storage/async-storage" RNCClipboard: - :path: "../../../node_modules/.bun/@react-native-clipboard+clipboard@1.16.3+87dd5a4c738f4c73/node_modules/@react-native-clipboard/clipboard" + :path: "../../../node_modules/@react-native-clipboard/clipboard" RNGestureHandler: - :path: "../../../node_modules/.bun/react-native-gesture-handler@2.28.0+87dd5a4c738f4c73/node_modules/react-native-gesture-handler" + :path: "../../../node_modules/react-native-gesture-handler" RNReanimated: - :path: "../../../node_modules/.bun/react-native-reanimated@4.1.6+d983531a34c8e10a/node_modules/react-native-reanimated" + :path: "../../../node_modules/react-native-reanimated" RNScreens: - :path: "../../../node_modules/.bun/react-native-screens@4.16.0+87dd5a4c738f4c73/node_modules/react-native-screens" + :path: "../../../node_modules/react-native-screens" RNSVG: - :path: "../../../node_modules/.bun/react-native-svg@15.12.1+87dd5a4c738f4c73/node_modules/react-native-svg" + :path: "../../../node_modules/react-native-svg" RNWorklets: - :path: "../../../node_modules/.bun/react-native-worklets@0.7.1+87dd5a4c738f4c73/node_modules/react-native-worklets" + :path: "../../../node_modules/react-native-worklets" SDMobileCore: :path: "../modules/sd-mobile-core/ios" Yoga: - :path: "../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native/ReactCommon/yoga" + :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EXConstants: c378c1b344ff1ecfbad27b90f0e48d1d0ead8cbb + EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b - Expo: 3d19389232751e415391827839a629100a3c6a64 + Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710 expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3 expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533 - ExpoAsset: 23a958e97d3d340919fe6774db35d563241e6c03 + ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18 ExpoBlur: b90747a3f22a8b6ceffd9cb0dc41a4184efdc656 ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0 - ExpoFileSystem: 3592defb5faa3c5866a2900eae87ceec8cc0489f - ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509 + ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063 + ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47 ExpoHaptics: d3a6375d8dcc3a1083d003bc2298ff654fafb536 - ExpoHead: 5611b33d6b983922d0233367ee6ab65633364dfd + ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780 ExpoImage: 686f972bff29525733aa13357f6691dc90aa03d8 ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 - ExpoLinking: f4c4a351523da72a6bfa7e1f4ca92aee1043a3ca - ExpoModulesCore: ded694f230d03c59d919efe243911622fa169834 - ExpoSplashScreen: 76af87337650d06926aa7d0157fe98b4fddca336 + ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37 + ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583 + ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 @@ -2746,7 +2746,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 - react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1 + react-native-slider: 8b9a218d1a3e526146a170cb6133be9cda23e70e React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4 @@ -2775,17 +2775,17 @@ SPEC CHECKSUMS: React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801 React-utils: 6e2035b53d087927768649a11a26c4e092448e34 ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 - ReactCodegen: 9c2af94dc4ee5c1b98fb465418036615be3793b3 + ReactCodegen: c1acc47016bcb83d3c70e6094d13a79ac7ebe270 ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 RNCClipboard: 88d7eeb555d1183915f0885bdbc5c97eb6f7f3ba RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3 - RNReanimated: 83246804817326398f1506dd916bf6fe47fa6242 + RNReanimated: 9aa370001444fd7e301a58993067c31cefef4457 RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 - RNWorklets: bdca513296f69bf7fe8418208da31447c65b23ed - SDMobileCore: 1f342704b37de152ac5664ce73fe71ea881f20c2 + RNWorklets: c334b2bdc640e80287d440585bb6a6d553a033a1 + SDMobileCore: 0627166e301484902c71b9da5b4430396854d848 SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj index 8f7fba262082..a77e5bb61daa 100644 --- a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj @@ -462,7 +462,7 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ENABLE_EXPLICIT_MODULES = NO; @@ -517,7 +517,7 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.bun/react-native@0.81.5+87dd5a4c738f4c73/node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ENABLE_EXPLICIT_MODULES = NO; USE_HERMES = true; diff --git a/apps/mobile/modules/sd-mobile-core/android/build-scripts/build.sh b/apps/mobile/modules/sd-mobile-core/android/build-scripts/build.sh index 85d8cf8ead5b..0fb724fb30d2 100755 --- a/apps/mobile/modules/sd-mobile-core/android/build-scripts/build.sh +++ b/apps/mobile/modules/sd-mobile-core/android/build-scripts/build.sh @@ -5,19 +5,60 @@ cd "$(dirname "$0")/../../core" echo "Building Spacedrive Mobile Core for Android..." +# Auto-detect Android NDK if not set +if [ -z "$ANDROID_NDK_ROOT" ]; then + # Try common locations + if [ -d "$HOME/Library/Android/sdk/ndk" ]; then + # macOS Android Studio location - find the latest NDK version + ANDROID_NDK_ROOT=$(ls -d "$HOME/Library/Android/sdk/ndk"/* 2>/dev/null | sort -V | tail -1) + elif [ -d "$ANDROID_HOME/ndk" ]; then + ANDROID_NDK_ROOT=$(ls -d "$ANDROID_HOME/ndk"/* 2>/dev/null | sort -V | tail -1) + elif [ -d "/usr/local/lib/android/sdk/ndk" ]; then + # Linux CI location + ANDROID_NDK_ROOT=$(ls -d "/usr/local/lib/android/sdk/ndk"/* 2>/dev/null | sort -V | tail -1) + fi + + if [ -n "$ANDROID_NDK_ROOT" ]; then + echo "Auto-detected ANDROID_NDK_ROOT: $ANDROID_NDK_ROOT" + export ANDROID_NDK_ROOT + else + echo "Error: Could not find Android NDK. Please set ANDROID_NDK_ROOT environment variable." + exit 1 + fi +fi + +# Also set ANDROID_NDK for compatibility +export ANDROID_NDK="$ANDROID_NDK_ROOT" + OUTPUT_DIR="../android/src/main/jniLibs" mkdir -p "$OUTPUT_DIR" +# Use mobile-dev profile for faster builds (no LTO, parallel codegen) +# See Cargo.toml [profile.mobile-dev] for settings + # Build for arm64-v8a (most modern Android devices) echo "Building for arm64-v8a..." -cargo ndk --platform 24 -t arm64-v8a -o "$OUTPUT_DIR" build --release + +# Clear any environment variables that might conflict with cargo-ndk's cross-compilation setup +unset CMAKE_TOOLCHAIN_FILE 2>/dev/null || true +unset CMAKE_TOOLCHAIN_FILE_aarch64_linux_android 2>/dev/null || true +unset CFLAGS 2>/dev/null || true +unset CXXFLAGS 2>/dev/null || true +unset CFLAGS_aarch64_linux_android 2>/dev/null || true + +# Don't use NDK toolchain file - let cargo-ndk handle cross-compilation via CC/CXX +# The toolchain file conflicts with cargo-ndk's --target flags +export ANDROID_ABI=arm64-v8a +export ANDROID_PLATFORM=android-24 + +cargo ndk --platform 24 -t arm64-v8a -o "$OUTPUT_DIR" build --profile mobile-dev # Optional: Build for armeabi-v7a (older 32-bit devices) # echo "Building for armeabi-v7a..." -# cargo ndk --platform 24 -t armeabi-v7a -o "$OUTPUT_DIR" build --release +# cargo ndk --platform 24 -t armeabi-v7a -o "$OUTPUT_DIR" build --profile mobile-dev # Optional: Build for x86_64 (emulators) # echo "Building for x86_64..." -# cargo ndk --platform 24 -t x86_64 -o "$OUTPUT_DIR" build --release +# cargo ndk --platform 24 -t x86_64 -o "$OUTPUT_DIR" build --profile mobile-dev echo "Android Rust build complete!" diff --git a/apps/mobile/modules/sd-mobile-core/android/build.gradle b/apps/mobile/modules/sd-mobile-core/android/build.gradle index 65af9f92c7f8..1745b110ae07 100644 --- a/apps/mobile/modules/sd-mobile-core/android/build.gradle +++ b/apps/mobile/modules/sd-mobile-core/android/build.gradle @@ -1,5 +1,26 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'io.gitlab.arturbosch.detekt' +apply plugin: 'org.jlleitschuh.gradle.ktlint' + +detekt { + config.setFrom("${rootProject.projectDir}/config/detekt.yml") + buildUponDefaultConfig = true + allRules = false + autoCorrect = false + parallel = true +} + +ktlint { + android = true + outputToConsole = true + outputColorName = "RED" + ignoreFailures = false + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } +} android { compileSdkVersion 35 @@ -39,4 +60,5 @@ tasks.named('preBuild').configure { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25" + implementation "androidx.documentfile:documentfile:1.0.1" } diff --git a/apps/mobile/modules/sd-mobile-core/android/src/main/java/com/spacedrive/core/SDMobileCoreModule.kt b/apps/mobile/modules/sd-mobile-core/android/src/main/java/com/spacedrive/core/SDMobileCoreModule.kt index 2f4d339fd453..c06f7c46ac01 100644 --- a/apps/mobile/modules/sd-mobile-core/android/src/main/java/com/spacedrive/core/SDMobileCoreModule.kt +++ b/apps/mobile/modules/sd-mobile-core/android/src/main/java/com/spacedrive/core/SDMobileCoreModule.kt @@ -1,133 +1,666 @@ package com.spacedrive.core +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.storage.StorageManager +import android.provider.DocumentsContract +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile +import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition -import expo.modules.kotlin.Promise +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +// Options class for folder picker (required for AsyncFunction pattern with activity results) +class FolderPickerOptions : Record { + @Field + val dummy: String? = null +} +// Function count exceeds threshold due to small, focused helper functions +// extracted from complex SAF path resolution logic - this is preferred over +// having fewer but more complex functions. +@Suppress("TooManyFunctions") class SDMobileCoreModule : Module() { - private var listeners = 0 - private var logListeners = 0 - private var registeredWithRust = false - private var logRegisteredWithRust = false - - init { - try { - System.loadLibrary("sd_mobile_core") - } catch (e: UnsatisfiedLinkError) { - android.util.Log.e("SDMobileCore", "Failed to load native library: ${e.message}") - } - } - - override fun definition() = ModuleDefinition { - Name("SDMobileCore") - - Events("SDCoreEvent", "SDCoreLog") - - OnStartObserving("SDCoreEvent") { - android.util.Log.i("SDMobileCore", "📡 OnStartObserving SDCoreEvent triggered") - - if (!registeredWithRust) { - try { - android.util.Log.i("SDMobileCore", "🚀 Registering event listener...") - registerCoreEventListener() - registeredWithRust = true - android.util.Log.i("SDMobileCore", "✅ Event listener registered with Rust") - } catch (e: Exception) { - android.util.Log.e("SDMobileCore", "Failed to register event listener: ${e.message}") - } - } - - listeners++ - android.util.Log.i("SDMobileCore", "📊 SDCoreEvent listeners: $listeners") - } - - OnStopObserving("SDCoreEvent") { - listeners-- - android.util.Log.i("SDMobileCore", "📉 SDCoreEvent listeners: $listeners") - } - - OnStartObserving("SDCoreLog") { - android.util.Log.i("SDMobileCore", "📡 OnStartObserving SDCoreLog triggered") - - if (!logRegisteredWithRust) { - try { - android.util.Log.i("SDMobileCore", "🚀 Registering log listener...") - registerCoreLogListener() - logRegisteredWithRust = true - android.util.Log.i("SDMobileCore", "✅ Log listener registered with Rust") - } catch (e: Exception) { - android.util.Log.e("SDMobileCore", "Failed to register log listener: ${e.message}") - } - } - - logListeners++ - android.util.Log.i("SDMobileCore", "📊 SDCoreLog listeners: $logListeners") - } - - OnStopObserving("SDCoreLog") { - logListeners-- - android.util.Log.i("SDMobileCore", "📉 SDCoreLog listeners: $logListeners") - } - - Function("initialize") { dataDir: String?, deviceName: String? -> - val dir = dataDir ?: appContext.persistentFilesDirectory?.absolutePath - ?: throw Exception("No data directory available") - - try { - initializeCore(dir, deviceName) - } catch (e: Exception) { - android.util.Log.e("SDMobileCore", "Failed to initialize core: ${e.message}") - -1 - } - } - - AsyncFunction("sendMessage") { query: String, promise: Promise -> - try { - handleCoreMsg(query, SDCorePromise(promise)) - } catch (e: Exception) { - promise.reject("CORE_ERROR", e.message ?: "Unknown error", e) - } - } - - Function("shutdown") { - try { - shutdownCore() - } catch (e: Exception) { - android.util.Log.e("SDMobileCore", "Failed to shutdown core: ${e.message}") - } - } - } - - fun getDataDirectory(): String { - return appContext.persistentFilesDirectory?.absolutePath ?: "" - } - - fun sendCoreEvent(body: String) { - if (listeners > 0) { - this@SDMobileCoreModule.sendEvent("SDCoreEvent", mapOf("body" to body)) - } - } - - fun sendCoreLog(body: String) { - if (logListeners > 0) { - this@SDMobileCoreModule.sendEvent("SDCoreLog", mapOf("body" to body)) - } - } - - // Native methods - will throw UnsatisfiedLinkError if library not loaded - private external fun registerCoreEventListener() - private external fun registerCoreLogListener() - private external fun initializeCore(dataDir: String, deviceName: String?): Int - private external fun handleCoreMsg(query: String, promise: SDCorePromise) - private external fun shutdownCore() + // Thread-safe listener counters + private val listeners = AtomicInteger(0) + private val logListeners = AtomicInteger(0) + private val registeredWithRust = AtomicBoolean(false) + private val logRegisteredWithRust = AtomicBoolean(false) + + // Thread-safe promise storage for concurrent folder picker calls + // Maps request code to pending promise + private val pendingFolderPickerPromises = ConcurrentHashMap() + private val requestCodeCounter = AtomicInteger(FOLDER_PICKER_REQUEST_CODE_BASE) + + companion object { + private const val FOLDER_PICKER_REQUEST_CODE_BASE = 9999 + private const val TAG = "SDMobileCore" + + // Cached debug state - set during initialization + @Volatile + private var isDebugBuild: Boolean = true // Default to debug for safety + + /** + * Initialize the debug state based on application flags. + * Should be called once during module initialization. + */ + fun initDebugState(context: Context?) { + context?.applicationInfo?.let { appInfo -> + isDebugBuild = appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + } + } + + /** + * Log a debug message only in debug builds. + * In release builds, this is a no-op. + */ + fun debugLog(message: String) { + if (isDebugBuild) { + Log.d(TAG, message) + } + } + + /** + * Sanitize a path for logging to avoid exposing user directory structure. + * In debug builds, returns the full path for easier debugging. + * In release builds, returns only the last path component. + */ + fun sanitizePath(path: String?): String { + if (path == null) return "" + return if (isDebugBuild) { + path + } else { + // Only show last path component in release + val lastSeparator = path.lastIndexOf('/') + if (lastSeparator >= 0 && lastSeparator < path.length - 1) { + ".../${path.substring(lastSeparator + 1)}" + } else { + "..." + } + } + } + + /** + * Check if the app has appropriate storage permissions for the current Android version. + * - Android 11+ (API 30+): Checks MANAGE_EXTERNAL_STORAGE + * - Android 10 (API 29): Checks READ_EXTERNAL_STORAGE + * - Android 9 and below: Checks READ_EXTERNAL_STORAGE + * + * @return true if the app has sufficient storage permissions + */ + fun hasStoragePermission(context: Context): Boolean { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + // Android 11+ - check for MANAGE_EXTERNAL_STORAGE + Environment.isExternalStorageManager() + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + // Android 10 - scoped storage, but can still check basic permission + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED + } + else -> { + // Android 9 and below + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED + } + } + } + + /** + * Log a warning if storage permission is not granted. + * This helps developers understand why file operations might fail. + */ + fun warnIfNoStoragePermission( + context: Context?, + operation: String, + ) { + if (context == null) return + if (!hasStoragePermission(context)) { + Log.w( + TAG, + "Storage permission not granted for operation: $operation. " + + "File access may fail or be limited to app-specific directories.", + ) + } + } + + /** + * Validate and resolve a path, checking for path traversal attacks. + * + * Security checks performed: + * 1. Reject null or empty paths + * 2. Split path into components and check each one + * 3. Reject paths with ".." components (parent traversal) + * 4. Reject paths starting with "/" (absolute paths) when relative expected + * 5. Resolve canonical path and verify it's under expected base + * + * @param basePath The allowed base directory + * @param relativePath The relative path to validate + * @return The validated canonical path, or null if validation fails + */ + fun validateAndResolvePath( + basePath: String, + relativePath: String, + ): String? { + // Reject empty paths + if (relativePath.isBlank()) { + return basePath + } + + // Split and validate each path component + val components = relativePath.split("/").filter { it.isNotEmpty() } + for (component in components) { + // Reject parent directory traversal + if (component == "..") { + Log.w(TAG, "Path traversal attempt detected: contains '..'") + return null + } + // Reject hidden directories/files starting with . (optional, stricter) + // component.startsWith(".") could be added here if needed + } + + // Construct the full path + val fullPath = + if (relativePath.isNotEmpty()) { + "$basePath/$relativePath" + } else { + basePath + } + + // Resolve to canonical path and verify it's still under base + return try { + val baseFile = java.io.File(basePath).canonicalFile + val targetFile = java.io.File(fullPath).canonicalFile + + // Check that the canonical path is under the base path + // This catches symlink-based escape attempts + if (!targetFile.absolutePath.startsWith(baseFile.absolutePath)) { + Log.w(TAG, "Path escape attempt: resolved path is outside base directory") + null + } else { + targetFile.absolutePath + } + } catch (e: Exception) { + Log.w(TAG, "Path validation failed: ${e.message}") + null + } + } + } + + init { + try { + System.loadLibrary("sd_mobile_core") + } catch (e: UnsatisfiedLinkError) { + Log.e(TAG, "Failed to load native library: ${e.message}") + } + } + + // Expo module API requires all event handlers and functions to be declared + // within the definition block, making this inherently long. + @Suppress("LongMethod", "CyclomaticComplexMethod") + override fun definition() = + ModuleDefinition { + Name("SDMobileCore") + + // Initialize debug state based on app's debuggable flag + initDebugState(appContext.reactContext) + + Events("SDCoreEvent", "SDCoreLog") + + OnStartObserving("SDCoreEvent") { + Log.i(TAG, "OnStartObserving SDCoreEvent triggered") + + if (registeredWithRust.compareAndSet(false, true)) { + try { + Log.i(TAG, "Registering event listener...") + registerCoreEventListener() + Log.i(TAG, "Event listener registered with Rust") + } catch (e: Exception) { + registeredWithRust.set(false) // Reset on failure + Log.e(TAG, "Failed to register event listener: ${e.message}") + } + } + + val count = listeners.incrementAndGet() + Log.i(TAG, "SDCoreEvent listeners: $count") + } + + OnStopObserving("SDCoreEvent") { + val count = listeners.decrementAndGet() + Log.i(TAG, "SDCoreEvent listeners: $count") + } + + OnStartObserving("SDCoreLog") { + Log.i(TAG, "OnStartObserving SDCoreLog triggered") + + if (logRegisteredWithRust.compareAndSet(false, true)) { + try { + Log.i(TAG, "Registering log listener...") + registerCoreLogListener() + Log.i(TAG, "Log listener registered with Rust") + } catch (e: Exception) { + logRegisteredWithRust.set(false) // Reset on failure + Log.e(TAG, "Failed to register log listener: ${e.message}") + } + } + + val count = logListeners.incrementAndGet() + Log.i(TAG, "SDCoreLog listeners: $count") + } + + OnStopObserving("SDCoreLog") { + val count = logListeners.decrementAndGet() + Log.i(TAG, "SDCoreLog listeners: $count") + } + + Function("initialize") { dataDir: String?, deviceName: String? -> + val dir = + dataDir ?: appContext.persistentFilesDirectory?.absolutePath + ?: throw Exception("No data directory available") + + try { + initializeCore(dir, deviceName) + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize core: ${e.message}") + -1 + } + } + + AsyncFunction("sendMessage") { query: String, promise: Promise -> + try { + handleCoreMsg(query, SDCorePromise(promise)) + } catch (e: Exception) { + promise.reject("CORE_ERROR", e.message ?: "Unknown error", e) + } + } + + Function("shutdown") { + try { + shutdownCore() + } catch (e: Exception) { + Log.e(TAG, "Failed to shutdown core: ${e.message}") + } + } + + // Simple test function + Function("testFunction") { + Log.i(TAG, "testFunction called!") + "test_result" + } + + // Open Android folder picker using Storage Access Framework + // TODO: Migrate to ActivityResultContracts when Expo modules support it + // See: https://github.com/expo/expo/issues/TBD + @Suppress("DEPRECATION") + AsyncFunction("pickFolder") { options: FolderPickerOptions, promise: Promise -> + val activity = appContext.currentActivity + if (activity == null) { + promise.reject(CodedException("NO_ACTIVITY", "No activity available", null)) + return@AsyncFunction + } + + try { + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + } + + // Generate unique request code for concurrent calls + val requestCode = requestCodeCounter.incrementAndGet() + pendingFolderPickerPromises[requestCode] = promise + + @Suppress("DEPRECATION") + activity.startActivityForResult(intent, requestCode) + } catch (e: Exception) { + Log.e(TAG, "Failed to open folder picker: ${e.message}") + promise.reject(CodedException("PICKER_ERROR", e.message ?: "Failed to open folder picker", e)) + } + } + + // Get the real filesystem path from a content URI (if possible) + Function("getPathFromUri") { uriString: String -> + try { + val uri = Uri.parse(uriString) + getPathFromContentUri(uri) + } catch (e: Exception) { + Log.e(TAG, "Failed to get path from URI: ${e.message}") + null + } + } + + // Check if the app has full storage access permission (Android 11+) + Function("hasStoragePermission") { + val context = appContext.reactContext ?: return@Function false + hasStoragePermission(context) + } + + // Check if full storage permission is required (Android 11+) + Function("requiresStoragePermission") { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + } + + // Open the system settings page to grant "All Files Access" permission + Function("openStoragePermissionSettings") { + val context = appContext.reactContext ?: return@Function false + try { + val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION + } else { + android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS + } + val intent = Intent(action, Uri.parse("package:${context.packageName}")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to open storage permission settings: ${e.message}") + false + } + } + + OnActivityResult { _, payload -> + // Look up promise by request code from concurrent-safe map + val promise = pendingFolderPickerPromises.remove(payload.requestCode) + + if (promise == null) { + // Not our request code, ignore + return@OnActivityResult + } + + if (payload.resultCode != Activity.RESULT_OK) { + promise.reject(CodedException("CANCELLED", "Folder picker was cancelled", null)) + return@OnActivityResult + } + + val uri = payload.data?.data + if (uri == null) { + promise.reject(CodedException("NO_URI", "No folder URI returned", null)) + return@OnActivityResult + } + + // Take persistent permissions + try { + val takeFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + appContext.reactContext?.contentResolver?.takePersistableUriPermission(uri, takeFlags) + } catch (e: SecurityException) { + Log.w(TAG, "Failed to take persistent permission (SecurityException): ${e.message}") + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to take persistent permission (invalid URI): ${e.message}") + } + + // Check storage permissions before path resolution + warnIfNoStoragePermission(appContext.reactContext, "folder picker path resolution") + + // Try to get the real path + val realPath = getPathFromContentUri(uri) + val folderName = + appContext.reactContext?.let { context -> + DocumentFile.fromTreeUri(context, uri)?.name + } ?: "Unknown" + + val result = + mapOf( + "uri" to uri.toString(), + "path" to realPath, + "name" to folderName, + ) + + promise.resolve(result) + } + } + + fun getDataDirectory(): String { + return appContext.persistentFilesDirectory?.absolutePath ?: "" + } + + fun sendCoreEvent(body: String) { + if (listeners.get() > 0) { + this@SDMobileCoreModule.sendEvent("SDCoreEvent", mapOf("body" to body)) + } + } + + fun sendCoreLog(body: String) { + if (logListeners.get() > 0) { + this@SDMobileCoreModule.sendEvent("SDCoreLog", mapOf("body" to body)) + } + } + + /** + * Attempts to convert a content:// URI to a real filesystem path. + * This works for primary external storage on most devices. + */ + private fun getPathFromContentUri(uri: Uri): String? { + // Handle document tree URIs (from ACTION_OPEN_DOCUMENT_TREE) + if (DocumentsContract.isTreeUri(uri)) { + val docId = DocumentsContract.getTreeDocumentId(uri) + return getPathFromDocId(docId) + } + + // Handle regular document URIs + if (DocumentsContract.isDocumentUri(appContext.reactContext, uri)) { + val docId = DocumentsContract.getDocumentId(uri) + return getPathFromDocId(docId) + } + + return null + } + + /** + * Parse and validate a document ID, returning the storage ID and relative path. + * Returns null if the document ID is invalid or contains path traversal attempts. + */ + private fun parseDocumentId(docId: String): Pair? { + if (docId.isBlank()) { + Log.w(TAG, "Empty document ID provided") + return null + } + + // Document ID format: "primary:path/to/folder" or "storageId:path/to/folder" + val split = docId.split(":", limit = 2) + if (split.size < 2) { + Log.w(TAG, "Invalid document ID format: $docId") + return null + } + + val storageId = split[0] + val relativePath = split[1] + + if (!isRelativePathSafe(relativePath)) { + return null + } + + return Pair(storageId, relativePath) + } + + /** + * Check if a relative path is safe (no traversal attempts, not absolute). + */ + private fun isRelativePathSafe(relativePath: String): Boolean { + if (relativePath.startsWith("/")) { + Log.w(TAG, "Absolute path in document ID rejected: $relativePath") + return false + } + + val pathComponents = relativePath.split("/").filter { it.isNotEmpty() } + for (component in pathComponents) { + if (component == "..") { + Log.w(TAG, "Path traversal attempt detected: $relativePath") + return false + } + if (component == ".") { + Log.w(TAG, "Suspicious path component '.' in: $relativePath") + return false + } + } + return true + } + + /** + * Resolve a path for secondary storage volumes using fallback mount points. + */ + private fun resolveViaFallbackMounts( + storageId: String, + relativePath: String, + ): String? { + val possibleBases = + listOf( + "/storage/$storageId", + "/mnt/media_rw/$storageId", + "/mnt/usb/$storageId", + ) + + return possibleBases + .filter { java.io.File(it).exists() } + .mapNotNull { validateAndResolvePath(it, relativePath) } + .firstOrNull() + } + + private fun getPathFromDocId(docId: String): String? { + val (storageId, relativePath) = parseDocumentId(docId) ?: return null + + return when (storageId) { + "primary" -> resolvePrimaryStorage(relativePath) + "home" -> resolveHomeStorage(relativePath) + else -> resolveSecondaryStorage(storageId, relativePath) + } + } + + @Suppress("DEPRECATION") + private fun resolvePrimaryStorage(relativePath: String): String? { + val basePath = Environment.getExternalStorageDirectory().absolutePath + return validateAndResolvePath(basePath, relativePath) + } + + @Suppress("DEPRECATION") + private fun resolveHomeStorage(relativePath: String): String? { + val documentsDir = + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOCUMENTS, + ) + return validateAndResolvePath(documentsDir.absolutePath, relativePath) + } + + private fun resolveSecondaryStorage( + storageId: String, + relativePath: String, + ): String? { + // Try StorageManager API first on Android N+ + tryResolveViaStorageManager(storageId, relativePath)?.let { return it } + + // Fallback: Try common mount points + val result = resolveViaFallbackMounts(storageId, relativePath) + if (result == null) { + Log.w(TAG, "Could not resolve path for storage ID: $storageId") + } + return result + } + + /** + * Try to resolve a storage volume path using StorageManager API (Android N+). + * This provides more reliable path resolution than hardcoded mount points. + */ + private fun tryResolveViaStorageManager( + storageId: String, + relativePath: String, + ): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null + + val context = appContext.reactContext ?: return null + val storageManager = + context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager + ?: return null + + return try { + @Suppress("DEPRECATION") + storageManager.storageVolumes + .firstOrNull { it.uuid?.equals(storageId, ignoreCase = true) == true } + ?.let { volume -> resolveVolumeToPath(volume, storageId, relativePath) } + } catch (e: Exception) { + Log.w(TAG, "StorageManager resolution failed: ${e.message}") + null + } + } + + /** + * Resolve a matched StorageVolume to a filesystem path. + */ + private fun resolveVolumeToPath( + volume: android.os.storage.StorageVolume, + storageId: String, + relativePath: String, + ): String? { + // On Android R+, we can get the directory directly + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + volume.directory?.absolutePath?.let { basePath -> + return buildPath(basePath, relativePath) + } + } + + // Fallback: construct path from common patterns + val possibleBase = "/storage/$storageId" + if (java.io.File(possibleBase).exists()) { + return buildPath(possibleBase, relativePath) + } + + return null + } + + private fun buildPath( + basePath: String, + relativePath: String, + ): String { + return if (relativePath.isNotEmpty()) "$basePath/$relativePath" else basePath + } + + // Native methods - will throw UnsatisfiedLinkError if library not loaded + private external fun registerCoreEventListener() + + private external fun registerCoreLogListener() + + private external fun initializeCore( + dataDir: String, + deviceName: String?, + ): Int + + private external fun handleCoreMsg( + query: String, + promise: SDCorePromise, + ) + + private external fun shutdownCore() } class SDCorePromise(private val promise: Promise) { - fun resolve(msg: String) { - promise.resolve(msg) - } + fun resolve(msg: String) { + promise.resolve(msg) + } - fun reject(error: String) { - promise.reject("CORE_ERROR", error, null) - } + fun reject(error: String) { + promise.reject("CORE_ERROR", error, null) + } } diff --git a/apps/mobile/modules/sd-mobile-core/core/Cargo.toml b/apps/mobile/modules/sd-mobile-core/core/Cargo.toml index bf524aac5cf5..208c24302ef7 100644 --- a/apps/mobile/modules/sd-mobile-core/core/Cargo.toml +++ b/apps/mobile/modules/sd-mobile-core/core/Cargo.toml @@ -25,3 +25,5 @@ openssl-sys = { version = "0.9", features = ["vendored"] } [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" +log = "0.4.29" +android_logger = "0.15.1" diff --git a/apps/mobile/modules/sd-mobile-core/core/src/lib.rs b/apps/mobile/modules/sd-mobile-core/core/src/lib.rs index ee0581abf0fc..4db56f0b66dd 100644 --- a/apps/mobile/modules/sd-mobile-core/core/src/lib.rs +++ b/apps/mobile/modules/sd-mobile-core/core/src/lib.rs @@ -10,11 +10,64 @@ use std::{ sync::Arc, }; +/// Debug logging macro that only emits output in debug builds. +/// In release builds, these calls are completely eliminated by the compiler. +macro_rules! debug_log { + ($($arg:tt)*) => { + #[cfg(debug_assertions)] + { + #[cfg(target_os = "android")] + log::debug!($($arg)*); + #[cfg(not(target_os = "android"))] + println!($($arg)*); + } + }; +} + +/// Info logging that's always available (both debug and release). +/// Use sparingly in release - only for critical lifecycle events. +macro_rules! info_log { + ($($arg:tt)*) => { + #[cfg(target_os = "android")] + log::info!($($arg)*); + #[cfg(not(target_os = "android"))] + println!($($arg)*); + }; +} + +/// Error logging that's always available. +macro_rules! error_log { + ($($arg:tt)*) => { + #[cfg(target_os = "android")] + log::error!($($arg)*); + #[cfg(not(target_os = "android"))] + eprintln!($($arg)*); + }; +} + +/// Safely creates a CString by stripping any embedded null bytes. +/// This prevents panics when converting strings that may contain null bytes +/// (e.g., from file paths or error messages). +fn safe_cstring(s: impl AsRef) -> CString { + let s = s.as_ref(); + // Replace null bytes with Unicode replacement character, then strip any remaining + let sanitized: String = s.chars().filter(|&c| c != '\0').collect(); + CString::new(sanitized).unwrap_or_else(|_| { + // If somehow still fails, return empty string + CString::new("").expect("Empty string should always be valid CString") + }) +} + use once_cell::sync::OnceCell; use serde::{Deserialize, Serialize}; +use std::time::Duration; use tokio::runtime::Runtime; use uuid::Uuid; +// Timeout configuration for async operations +const DEFAULT_TIMEOUT_SECS: u64 = 30; +const LONG_RUNNING_TIMEOUT_SECS: u64 = 120; + use sd_core::{ infra::daemon::rpc::RpcServer, infra::daemon::types::{DaemonError, DaemonRequest, DaemonResponse}, @@ -62,17 +115,163 @@ struct JsonRpcResponse { struct JsonRpcError { code: i32, message: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +struct JsonRpcErrorData { + /// Specific error type for client-side handling + error_type: String, + /// Additional details about the error + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +/// Map DaemonError variants to JSON-RPC error codes +/// Standard JSON-RPC codes: -32700 to -32600 +/// Application-specific codes: -32000 to -32099 +fn daemon_error_to_jsonrpc(error: &DaemonError) -> (i32, String, JsonRpcErrorData) { + match error { + DaemonError::ConnectionFailed(msg) => ( + -32001, + format!("Connection failed: {}", msg), + JsonRpcErrorData { + error_type: "CONNECTION_FAILED".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::ReadError(msg) => ( + -32002, + format!("Read error: {}", msg), + JsonRpcErrorData { + error_type: "READ_ERROR".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::WriteError(msg) => ( + -32003, + format!("Write error: {}", msg), + JsonRpcErrorData { + error_type: "WRITE_ERROR".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::RequestTooLarge(msg) => ( + -32004, + format!("Request too large: {}", msg), + JsonRpcErrorData { + error_type: "REQUEST_TOO_LARGE".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::InvalidRequest(msg) => ( + -32600, + format!("Invalid request: {}", msg), + JsonRpcErrorData { + error_type: "INVALID_REQUEST".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::SerializationError(msg) => ( + -32005, + format!("Serialization error: {}", msg), + JsonRpcErrorData { + error_type: "SERIALIZATION_ERROR".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::DeserializationError(msg) => ( + -32006, + format!("Deserialization error: {}", msg), + JsonRpcErrorData { + error_type: "DESERIALIZATION_ERROR".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::HandlerNotFound(method) => ( + -32601, + format!("Method not found: {}", method), + JsonRpcErrorData { + error_type: "HANDLER_NOT_FOUND".to_string(), + details: Some(serde_json::json!({ "method": method })), + }, + ), + DaemonError::OperationFailed(msg) => ( + -32007, + format!("Operation failed: {}", msg), + JsonRpcErrorData { + error_type: "OPERATION_FAILED".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::CoreUnavailable(msg) => ( + -32008, + format!("Core unavailable: {}", msg), + JsonRpcErrorData { + error_type: "CORE_UNAVAILABLE".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::ValidationError(msg) => ( + -32009, + format!("Validation error: {}", msg), + JsonRpcErrorData { + error_type: "VALIDATION_ERROR".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::SecurityError(msg) => ( + -32010, + format!("Security error: {}", msg), + JsonRpcErrorData { + error_type: "SECURITY_ERROR".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + DaemonError::InternalError(msg) => ( + -32603, + format!("Internal error: {}", msg), + JsonRpcErrorData { + error_type: "INTERNAL_ERROR".to_string(), + details: Some(serde_json::json!({ "reason": msg })), + }, + ), + } } /// Initialize the embedded core with full Spacedrive functionality /// /// # Safety -/// `data_dir` must be a valid null-terminated C string. `device_name` may be null. +/// - `data_dir` must be a valid, non-null pointer to a null-terminated C string +/// - `device_name` may be null, but if non-null must be a valid pointer to a null-terminated C string #[no_mangle] -pub unsafe extern "C" fn initialize_core( +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn initialize_core( data_dir: *const std::os::raw::c_char, device_name: *const std::os::raw::c_char, ) -> std::os::raw::c_int { + // Initialize Android logging first so we can see output in logcat + #[cfg(target_os = "android")] + { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(if cfg!(debug_assertions) { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }) + .with_tag("sd-mobile-core"), + ); + log::info!("Android logger initialized for sd-mobile-core"); + } + + // SAFETY: Validate data_dir is not null before dereferencing + if data_dir.is_null() { + error_log!("initialize_core: data_dir is null"); + return -2; // Error code for null pointer + } + let data_dir_str = unsafe { CStr::from_ptr(data_dir).to_string_lossy().to_string() }; let device_name_opt = if device_name.is_null() { @@ -86,14 +285,33 @@ pub unsafe extern "C" fn initialize_core( } }; - println!( + // Set up panic hook to log panics + std::panic::set_hook(Box::new(|panic_info| { + let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_info.payload().downcast_ref::() { + s.clone() + } else { + "Unknown panic".to_string() + }; + let location = panic_info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())).unwrap_or_else(|| "unknown location".to_string()); + + // Use Android logging for better logcat visibility + #[cfg(target_os = "android")] + log::error!("RUST PANIC: {} at {}", msg, location); + + #[cfg(not(target_os = "android"))] + eprintln!("RUST PANIC: {} at {}", msg, location); + })); + + info_log!( "Initializing embedded Spacedrive core with data dir: {}, device name: {:?}", data_dir_str, device_name_opt ); // Check if already initialized (singleton pattern) if RUNTIME.get().is_some() && CORE.get().is_some() { - println!("Embedded core already initialized, skipping"); + debug_log!("Embedded core already initialized, skipping"); return 0; } @@ -113,7 +331,7 @@ pub unsafe extern "C" fn initialize_core( let rt = match Runtime::new() { Ok(rt) => rt, Err(e) => { - println!("Failed to create Tokio runtime: {}", e); + error_log!("Failed to create Tokio runtime: {}", e); return -1; } }; @@ -121,7 +339,7 @@ pub unsafe extern "C" fn initialize_core( // Ensure data directory exists let data_path = PathBuf::from(data_dir_str.clone()); if let Err(e) = std::fs::create_dir_all(&data_path) { - println!("Failed to create data directory: {}", e); + error_log!("Failed to create data directory: {}", e); return -1; } @@ -132,24 +350,29 @@ pub unsafe extern "C" fn initialize_core( let mut core = match core { Ok(core) => core, Err(e) => { - println!("Failed to initialize core: {}", e); + error_log!("Failed to initialize core: {}", e); return -1; } }; - // Initialize networking with protocol registration + // Try to initialize networking - may fail on mobile due to platform restrictions + // iOS: Limited background networking capabilities + // Android: SELinux may deny access to /sys/class/net for interface enumeration let networking_result = rt.block_on(async { - println!("Initializing networking with protocol registration..."); + debug_log!("Initializing networking with protocol registration..."); core.init_networking().await }); match networking_result { Ok(()) => { - println!("Networking initialized with protocol registration"); + info_log!("Networking initialized with protocol registration"); } Err(e) => { - println!("Failed to initialize networking: {}", e); - println!("Continuing without networking (pairing will not work)"); + error_log!("Failed to initialize networking: {}", e); + info_log!("Continuing without networking (pairing will not work)"); + // Log more details on Android + #[cfg(target_os = "android")] + log::warn!("Android networking init failed: {}. Device sync will not be available.", e); } } @@ -172,43 +395,66 @@ pub unsafe extern "C" fn initialize_core( /// Shutdown the embedded core #[no_mangle] pub extern "C" fn shutdown_core() { - println!("Shutting down embedded core..."); - println!("Core shut down"); + info_log!("Shutting down embedded core..."); + info_log!("Core shut down"); } /// Handle JSON-RPC message from the embedded core /// /// # Safety -/// - `query` must be a valid, non-null, null-terminated C string. -/// - `callback` must be a valid function pointer that is safe to call from any thread. -/// - `callback_data` must remain valid until `callback` is invoked (invocation is asynchronous). +/// - `query` must be a valid, non-null pointer to a null-terminated C string +/// - `callback` must be a valid function pointer +/// - `callback_data` is passed through to the callback and may be null #[no_mangle] -pub unsafe extern "C" fn handle_core_msg( +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn handle_core_msg( query: *const std::os::raw::c_char, callback: extern "C" fn(*mut std::os::raw::c_void, *const std::os::raw::c_char), callback_data: *mut std::os::raw::c_void, ) { + // SAFETY: Validate query pointer before dereferencing + if query.is_null() { + let error_json = r#"{"jsonrpc":"2.0","id":"","error":{"code":-32600,"message":"Query pointer is null"}}"#; + let error_cstring = safe_cstring(error_json); + callback(callback_data, error_cstring.as_ptr()); + return; + } + let query_str = unsafe { CStr::from_ptr(query).to_string_lossy().to_string() }; - println!("[RPC REQUEST]: {}", query_str); + debug_log!("[RPC REQUEST]: {}", query_str); // Get global state let runtime = match RUNTIME.get() { Some(rt) => rt, None => { - let error_json = r#"{"jsonrpc":"2.0","id":"","error":{"code":-32603,"message":"Core not initialized"}}"#; - let error_cstring = CString::new(error_json).unwrap(); + let error_json = r#"{"jsonrpc":"2.0","id":"","error":{"code":-32603,"message":"Runtime not initialized"}}"#; + let error_cstring = safe_cstring(error_json); callback(callback_data, error_cstring.as_ptr()); return; } }; - let core = CORE.get().unwrap(); + let core = match CORE.get() { + Some(core) => core, + None => { + let error_json = r#"{"jsonrpc":"2.0","id":"","error":{"code":-32603,"message":"Core not initialized"}}"#; + let error_cstring = safe_cstring(error_json); + callback(callback_data, error_cstring.as_ptr()); + return; + } + }; // Convert callback pointers to usize for Send safety let callback_fn_ptr: usize = callback as usize; let callback_data_int: usize = callback_data as usize; + // SAFETY: Validate callback pointer is non-zero before transmute + if callback_fn_ptr == 0 { + error_log!("handle_core_msg: callback function pointer is null"); + return; + } + // Spawn async task to handle the request runtime.spawn(async move { let response = handle_json_rpc_request(query_str, core).await; @@ -216,9 +462,10 @@ pub unsafe extern "C" fn handle_core_msg( r#"{"jsonrpc":"2.0","id":"","error":{"code":-32603,"message":"Response serialization failed"}}"#.to_string() ); - println!("[RPC RESPONSE]: {}", response_json); + debug_log!("[RPC RESPONSE]: {}", response_json); - let response_cstring = CString::new(response_json).unwrap(); + let response_cstring = safe_cstring(response_json); + // SAFETY: callback_fn_ptr was validated as non-zero before spawning let callback: extern "C" fn(*mut std::os::raw::c_void, *const std::os::raw::c_char) = unsafe { std::mem::transmute(callback_fn_ptr) }; let callback_data_ptr: *mut std::os::raw::c_void = @@ -234,21 +481,33 @@ pub extern "C" fn spawn_core_event_listener( callback: extern "C" fn(*mut std::os::raw::c_void, *const std::os::raw::c_char), callback_data: *mut std::os::raw::c_void, ) { - println!("Starting core event listener..."); + debug_log!("Starting core event listener..."); let core = match CORE.get() { Some(core) => core, None => { - println!("Core not initialized, cannot start event listener"); + error_log!("Core not initialized, cannot start event listener"); return; } }; - let runtime = RUNTIME.get().unwrap(); + let runtime = match RUNTIME.get() { + Some(rt) => rt, + None => { + error_log!("Runtime not initialized, cannot start event listener"); + return; + } + }; let callback_fn_ptr: usize = callback as usize; let callback_data_int: usize = callback_data as usize; + // SAFETY: Validate callback pointer is non-zero before transmute + if callback_fn_ptr == 0 { + error_log!("spawn_core_event_listener: callback function pointer is null"); + return; + } + let mut event_subscriber = core.events.subscribe(); runtime.spawn(async move { @@ -256,14 +515,15 @@ pub extern "C" fn spawn_core_event_listener( let event_json = match serde_json::to_string(&event) { Ok(json) => json, Err(e) => { - println!("Failed to serialize event: {}", e); + error_log!("Failed to serialize event: {}", e); continue; } }; - println!("Broadcasting event: {}", event_json); + debug_log!("Broadcasting event: {}", event_json); - let event_cstring = CString::new(event_json).unwrap(); + let event_cstring = safe_cstring(event_json); + // SAFETY: callback_fn_ptr was validated as non-zero before spawning let callback: extern "C" fn(*mut std::os::raw::c_void, *const std::os::raw::c_char) = unsafe { std::mem::transmute(callback_fn_ptr) }; let callback_data_ptr: *mut std::os::raw::c_void = @@ -279,55 +539,93 @@ pub extern "C" fn spawn_core_log_listener( callback: extern "C" fn(*mut std::os::raw::c_void, *const std::os::raw::c_char), callback_data: *mut std::os::raw::c_void, ) { - println!("[FFI] spawn_core_log_listener called"); + debug_log!("[FFI] spawn_core_log_listener called"); let core = match CORE.get() { Some(core) => core, None => { - println!("❌ [FFI] Core not initialized, cannot start log listener"); + error_log!("[FFI] Core not initialized, cannot start log listener"); return; } }; - println!("[FFI] Core found, subscribing to LogBus..."); - let runtime = RUNTIME.get().unwrap(); + debug_log!("[FFI] Core found, subscribing to LogBus..."); + let runtime = match RUNTIME.get() { + Some(rt) => rt, + None => { + error_log!("[FFI] Runtime not initialized, cannot start log listener"); + return; + } + }; let callback_fn_ptr: usize = callback as usize; let callback_data_int: usize = callback_data as usize; + // SAFETY: Validate callback pointer is non-zero before transmute + if callback_fn_ptr == 0 { + error_log!("[FFI] spawn_core_log_listener: callback function pointer is null"); + return; + } + let mut log_subscriber = core.logs.subscribe(); - println!( + debug_log!( "[FFI] Log subscriber created, current subscriber count: {}", core.logs.subscriber_count() ); runtime.spawn(async move { - println!("[FFI] Log listener task spawned, waiting for logs..."); + debug_log!("[FFI] Log listener task spawned, waiting for logs..."); while let Ok(log) = log_subscriber.recv().await { let log_json = match serde_json::to_string(&log) { Ok(json) => json, Err(e) => { - println!("❌ [FFI] Failed to serialize log: {}", e); + error_log!("[FFI] Failed to serialize log: {}", e); continue; } }; - println!("[FFI] Broadcasting log: {}", log_json); + debug_log!("[FFI] Broadcasting log: {}", log_json); - let log_cstring = CString::new(log_json).unwrap(); + let log_cstring = safe_cstring(log_json); + // SAFETY: callback_fn_ptr was validated as non-zero before spawning let callback: extern "C" fn(*mut std::os::raw::c_void, *const std::os::raw::c_char) = unsafe { std::mem::transmute(callback_fn_ptr) }; let callback_data_ptr: *mut std::os::raw::c_void = callback_data_int as *mut std::os::raw::c_void; callback(callback_data_ptr, log_cstring.as_ptr()); } - println!("❌ [FFI] Log listener task ended"); + debug_log!("[FFI] Log listener task ended"); }); } // Helper functions // (send_response function removed - inlined into handle_core_msg) +/// List of methods that are known to take longer and require extended timeout +const LONG_RUNNING_METHODS: &[&str] = &[ + "action:locations.add", + "action:locations.rescan", + "action:libraries.create", + "action:jobs.run", + "action:sync.full_sync", +]; + +/// Check if a method is long-running and requires extended timeout +fn is_long_running_method(method: &str) -> bool { + LONG_RUNNING_METHODS + .iter() + .any(|&prefix| method.starts_with(prefix)) +} + +/// Get appropriate timeout duration for a method +fn get_timeout_for_method(method: &str) -> Duration { + if is_long_running_method(method) { + Duration::from_secs(LONG_RUNNING_TIMEOUT_SECS) + } else { + Duration::from_secs(DEFAULT_TIMEOUT_SECS) + } +} + async fn handle_json_rpc_request(request_json: String, core: &Arc) -> serde_json::Value { // Try parsing as batch first, then as single request let result: serde_json::Value = match serde_json::from_str::>(&request_json) @@ -338,14 +636,32 @@ async fn handle_json_rpc_request(request_json: String, core: &Arc) -> serd for req in batch { responses.push(process_single_request(req, core).await); } - serde_json::to_value(responses).unwrap() + serde_json::to_value(responses).unwrap_or_else(|e| { + serde_json::json!({ + "jsonrpc": "2.0", + "id": "", + "error": { + "code": -32603, + "message": format!("Failed to serialize batch response: {}", e) + } + }) + }) } Err(_) => { // Try as single request match serde_json::from_str::(&request_json) { Ok(req) => { let response = process_single_request(req, core).await; - serde_json::to_value(response).unwrap() + serde_json::to_value(response).unwrap_or_else(|e| { + serde_json::json!({ + "jsonrpc": "2.0", + "id": "", + "error": { + "code": -32603, + "message": format!("Failed to serialize response: {}", e) + } + }) + }) } Err(e) => { serde_json::json!({ @@ -368,6 +684,46 @@ async fn process_single_request( jsonrpc_request: JsonRpcRequest, core: &Arc, ) -> JsonRpcResponse { + // Validate library_id if provided - ensure it's open before processing + if let Some(ref lib_id_str) = jsonrpc_request.params.library_id { + match Uuid::parse_str(lib_id_str) { + Ok(uuid) => { + // Check if library is open using the libraries manager + let library = core.libraries.get_library(uuid).await; + if library.is_none() { + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: jsonrpc_request.id, + result: None, + error: Some(JsonRpcError { + code: -32004, + message: format!("Library not found or not open: {}", lib_id_str), + data: Some(JsonRpcErrorData { + error_type: "LIBRARY_NOT_FOUND".to_string(), + details: Some(serde_json::json!({ "library_id": lib_id_str })), + }), + }), + }; + } + } + Err(e) => { + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: jsonrpc_request.id, + result: None, + error: Some(JsonRpcError { + code: -32602, + message: format!("Invalid library ID format: {}", e), + data: Some(JsonRpcErrorData { + error_type: "INVALID_LIBRARY_ID".to_string(), + details: Some(serde_json::json!({ "library_id": lib_id_str, "reason": e.to_string() })), + }), + }), + }; + } + } + } + let (daemon_request, request_id) = match convert_jsonrpc_to_daemon_request(&jsonrpc_request) { Ok(converted) => converted, Err(e) => { @@ -377,13 +733,48 @@ async fn process_single_request( result: None, error: Some(JsonRpcError { code: -32601, - message: e, + message: e.clone(), + data: Some(JsonRpcErrorData { + error_type: "INVALID_METHOD".to_string(), + details: Some(serde_json::json!({ "reason": e })), + }), }), }; } }; - let daemon_response = process_daemon_request(daemon_request, core).await; + // Determine timeout based on method type + let timeout_duration = get_timeout_for_method(&jsonrpc_request.method); + + // Process with timeout + let daemon_response = + match tokio::time::timeout(timeout_duration, process_daemon_request(daemon_request, core)) + .await + { + Ok(response) => response, + Err(_elapsed) => { + let timeout_secs = timeout_duration.as_secs(); + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: None, + error: Some(JsonRpcError { + code: -32000, + message: format!( + "Request timeout after {}s: {}", + timeout_secs, jsonrpc_request.method + ), + data: Some(JsonRpcErrorData { + error_type: "TIMEOUT".to_string(), + details: Some(serde_json::json!({ + "method": jsonrpc_request.method, + "timeout_secs": timeout_secs + })), + }), + }), + }; + } + }; convert_daemon_response_to_jsonrpc(daemon_response, request_id) } @@ -399,7 +790,12 @@ fn convert_jsonrpc_to_daemon_request( let daemon_request = if jsonrpc.method.starts_with("query:") { let payload = if jsonrpc.params.input.is_object() - && jsonrpc.params.input.as_object().unwrap().is_empty() + && jsonrpc + .params + .input + .as_object() + .map(|o| o.is_empty()) + .unwrap_or(false) { serde_json::Value::Null } else { @@ -459,15 +855,19 @@ fn convert_daemon_response_to_jsonrpc( result: Some(json), error: None, }, - DaemonResponse::Error(daemon_error) => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: request_id, - result: None, - error: Some(JsonRpcError { - code: -32603, - message: daemon_error.to_string(), - }), - }, + DaemonResponse::Error(daemon_error) => { + let (code, message, data) = daemon_error_to_jsonrpc(&daemon_error); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: None, + error: Some(JsonRpcError { + code, + message, + data: Some(data), + }), + } + } _ => JsonRpcResponse { jsonrpc: "2.0".to_string(), id: request_id, @@ -475,11 +875,123 @@ fn convert_daemon_response_to_jsonrpc( error: Some(JsonRpcError { code: -32603, message: "Unsupported response type".to_string(), + data: Some(JsonRpcErrorData { + error_type: "UNSUPPORTED_RESPONSE".to_string(), + details: None, + }), }), }, } } +// Unit tests for FFI layer +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_cstring_strips_nulls() { + // Test that embedded null bytes are stripped + let input = "hello\0world\0test"; + let result = safe_cstring(input); + assert_eq!(result.to_str().unwrap(), "helloworldtest"); + } + + #[test] + fn test_safe_cstring_empty_string() { + // Test empty string handling + let result = safe_cstring(""); + assert_eq!(result.to_str().unwrap(), ""); + } + + #[test] + fn test_safe_cstring_normal_string() { + // Test normal string without nulls + let input = "normal string without nulls"; + let result = safe_cstring(input); + assert_eq!(result.to_str().unwrap(), input); + } + + #[test] + fn test_safe_cstring_unicode() { + // Test unicode handling + let input = "hello\u{1F600}world"; // Contains emoji + let result = safe_cstring(input); + assert_eq!(result.to_str().unwrap(), input); + } + + #[test] + fn test_safe_cstring_only_nulls() { + // Test string with only null bytes + let input = "\0\0\0"; + let result = safe_cstring(input); + assert_eq!(result.to_str().unwrap(), ""); + } + + #[test] + fn test_daemon_error_connection_failed() { + let error = DaemonError::ConnectionFailed("test connection".to_string()); + let (code, message, data) = daemon_error_to_jsonrpc(&error); + assert_eq!(code, -32001); + assert!(message.contains("Connection failed")); + assert_eq!(data.error_type, "CONNECTION_FAILED"); + } + + #[test] + fn test_daemon_error_handler_not_found() { + let error = DaemonError::HandlerNotFound("unknownMethod".to_string()); + let (code, message, data) = daemon_error_to_jsonrpc(&error); + assert_eq!(code, -32601); // Standard JSON-RPC method not found code + assert!(message.contains("Method not found")); + assert_eq!(data.error_type, "HANDLER_NOT_FOUND"); + } + + #[test] + fn test_daemon_error_invalid_request() { + let error = DaemonError::InvalidRequest("bad format".to_string()); + let (code, message, data) = daemon_error_to_jsonrpc(&error); + assert_eq!(code, -32600); // Standard JSON-RPC invalid request code + assert!(message.contains("Invalid request")); + assert_eq!(data.error_type, "INVALID_REQUEST"); + } + + #[test] + fn test_daemon_error_internal_error() { + let error = DaemonError::InternalError("internal issue".to_string()); + let (code, message, data) = daemon_error_to_jsonrpc(&error); + assert_eq!(code, -32603); // Standard JSON-RPC internal error code + assert!(message.contains("Internal error")); + assert_eq!(data.error_type, "INTERNAL_ERROR"); + } + + #[test] + fn test_daemon_error_security_error() { + let error = DaemonError::SecurityError("unauthorized".to_string()); + let (code, message, data) = daemon_error_to_jsonrpc(&error); + assert_eq!(code, -32010); + assert!(message.contains("Security error")); + assert_eq!(data.error_type, "SECURITY_ERROR"); + } + + #[test] + fn test_daemon_error_validation_error() { + let error = DaemonError::ValidationError("invalid input".to_string()); + let (code, message, data) = daemon_error_to_jsonrpc(&error); + assert_eq!(code, -32009); + assert!(message.contains("Validation error")); + assert_eq!(data.error_type, "VALIDATION_ERROR"); + } + + #[test] + fn test_daemon_error_core_unavailable() { + let error = DaemonError::CoreUnavailable("shutting down".to_string()); + let (code, message, data) = daemon_error_to_jsonrpc(&error); + assert_eq!(code, -32008); + assert!(message.contains("Core unavailable")); + assert_eq!(data.error_type, "CORE_UNAVAILABLE"); + } +} + // Android JNI bindings #[cfg(target_os = "android")] mod android { @@ -496,6 +1008,25 @@ mod android { static EVENT_MODULE_REF: OnceCell = OnceCell::new(); static LOG_MODULE_REF: OnceCell = OnceCell::new(); + /// Helper function to safely reject a promise with an error message. + /// Returns Ok(()) if the rejection succeeded, Err with the failure reason otherwise. + fn reject_promise(env: &mut JNIEnv, promise: &GlobalRef, error: &str) { + let result = (|| -> Result<(), String> { + let error_jstring = env.new_string(error).map_err(|e| format!("Failed to create error string: {}", e))?; + env.call_method( + promise.as_obj(), + "reject", + "(Ljava/lang/String;)V", + &[JValue::Object(&error_jstring)], + ).map_err(|e| format!("Failed to call reject method: {}", e))?; + Ok(()) + })(); + + if let Err(e) = result { + log::error!("Failed to reject promise: {}", e); + } + } + // Only for Android x86_64 - provides missing symbol #[cfg(all(target_os = "android", target_arch = "x86_64"))] #[no_mangle] @@ -525,8 +1056,8 @@ mod android { ) }; - let data_dir_cstr = CString::new(data_dir_str).unwrap(); - let device_name_cstr = device_name_str.map(|s: String| CString::new(s).unwrap()); + let data_dir_cstr = safe_cstring(data_dir_str); + let device_name_cstr = device_name_str.map(|s: String| safe_cstring(s)); let result = super::initialize_core( data_dir_cstr.as_ptr(), @@ -554,11 +1085,19 @@ mod android { query: JString, promise: JObject, ) { + // CRITICAL: Capture JavaVM before spawning async task + // The async callback will run on a Tokio worker thread that needs JVM access + if JAVA_VM.get().is_none() { + if let Ok(jvm) = env.get_java_vm() { + let _ = JAVA_VM.set(Arc::new(jvm)); + } + } + let query_str: String = env .get_string(&query) .expect("Failed to get query string") .into(); - let query_cstr = CString::new(query_str).unwrap(); + let query_cstr = safe_cstring(query_str); let promise_ref = env.new_global_ref(promise).unwrap(); @@ -566,20 +1105,60 @@ mod android { data: *mut std::os::raw::c_void, result: *const std::os::raw::c_char, ) { - let promise_ref = unsafe { Box::from_raw(data as *mut GlobalRef) }; - let result_str = unsafe { CStr::from_ptr(result).to_string_lossy().to_string() }; + // Wrap entire callback in catch_unwind to prevent panics from crossing FFI boundary + let callback_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: Validate data pointer before Box::from_raw + if data.is_null() { + log::error!("android_callback: data pointer is null"); + return; + } + // SAFETY: Validate result pointer before CStr::from_ptr + if result.is_null() { + log::error!("android_callback: result pointer is null"); + return; + } - let jvm = JAVA_VM.get().expect("JavaVM not initialized"); - let mut env = jvm.attach_current_thread().unwrap(); + let promise_ref = unsafe { Box::from_raw(data as *mut GlobalRef) }; + let result_str = unsafe { CStr::from_ptr(result).to_string_lossy().to_string() }; + + let jvm = match JAVA_VM.get() { + Some(jvm) => jvm, + None => { + log::error!("android_callback: JavaVM not initialized"); + return; + } + }; + + let mut env = match jvm.attach_current_thread() { + Ok(env) => env, + Err(e) => { + log::error!("android_callback: Failed to attach thread: {}", e); + return; + } + }; + + let result_jstring = match env.new_string(&result_str) { + Ok(s) => s, + Err(e) => { + log::error!("android_callback: Failed to create result string: {}", e); + reject_promise(&mut env, &promise_ref, &format!("JNI error: {}", e)); + return; + } + }; + + if let Err(e) = env.call_method( + promise_ref.as_obj(), + "resolve", + "(Ljava/lang/String;)V", + &[JValue::Object(&result_jstring)], + ) { + log::error!("android_callback: Failed to resolve promise: {}", e); + } + })); - let result_jstring = env.new_string(&result_str).unwrap(); - env.call_method( - promise_ref.as_obj(), - "resolve", - "(Ljava/lang/String;)V", - &[JValue::Object(&result_jstring)], - ) - .unwrap(); + if let Err(e) = callback_result { + log::error!("android_callback: Panic caught: {:?}", e); + } } let promise_ptr = Box::into_raw(Box::new(promise_ref)) as *mut std::os::raw::c_void; @@ -602,23 +1181,55 @@ mod android { _data: *mut std::os::raw::c_void, event: *const std::os::raw::c_char, ) { - let event_str = unsafe { CStr::from_ptr(event).to_string_lossy().to_string() }; - - let jvm = JAVA_VM.get().expect("JavaVM not initialized"); - let mut env = jvm.attach_current_thread().unwrap(); - - let module_ref = EVENT_MODULE_REF - .get() - .expect("Event module not initialized"); - let event_jstring = env.new_string(&event_str).unwrap(); + // Wrap entire callback in catch_unwind to prevent panics from crossing FFI boundary + let callback_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let event_str = unsafe { CStr::from_ptr(event).to_string_lossy().to_string() }; + + let jvm = match JAVA_VM.get() { + Some(jvm) => jvm, + None => { + log::error!("android_event_callback: JavaVM not initialized"); + return; + } + }; + + let mut env = match jvm.attach_current_thread() { + Ok(env) => env, + Err(e) => { + log::error!("android_event_callback: Failed to attach thread: {}", e); + return; + } + }; + + let module_ref = match EVENT_MODULE_REF.get() { + Some(r) => r, + None => { + log::error!("android_event_callback: Event module not initialized"); + return; + } + }; + + let event_jstring = match env.new_string(&event_str) { + Ok(s) => s, + Err(e) => { + log::error!("android_event_callback: Failed to create event string: {}", e); + return; + } + }; + + if let Err(e) = env.call_method( + module_ref.as_obj(), + "sendCoreEvent", + "(Ljava/lang/String;)V", + &[JValue::Object(&event_jstring)], + ) { + log::error!("android_event_callback: Failed to send event: {}", e); + } + })); - env.call_method( - module_ref.as_obj(), - "sendCoreEvent", - "(Ljava/lang/String;)V", - &[JValue::Object(&event_jstring)], - ) - .unwrap(); + if let Err(e) = callback_result { + log::error!("android_event_callback: Panic caught: {:?}", e); + } } super::spawn_core_event_listener(android_event_callback, std::ptr::null_mut()); @@ -639,21 +1250,53 @@ mod android { _data: *mut std::os::raw::c_void, log: *const std::os::raw::c_char, ) { - let log_str = unsafe { CStr::from_ptr(log).to_string_lossy().to_string() }; - - let jvm = JAVA_VM.get().expect("JavaVM not initialized"); - let mut env = jvm.attach_current_thread().unwrap(); - - let module_ref = LOG_MODULE_REF.get().expect("Log module not initialized"); - let log_jstring = env.new_string(&log_str).unwrap(); - - env.call_method( - module_ref.as_obj(), - "sendCoreLog", - "(Ljava/lang/String;)V", - &[JValue::Object(&log_jstring)], - ) - .unwrap(); + // Wrap entire callback in catch_unwind to prevent panics from crossing FFI boundary + let callback_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let log_str = unsafe { CStr::from_ptr(log).to_string_lossy().to_string() }; + + let jvm = match JAVA_VM.get() { + Some(jvm) => jvm, + None => { + // Can't log this since we're in the log callback - just return + return; + } + }; + + let mut env = match jvm.attach_current_thread() { + Ok(env) => env, + Err(_) => { + // Can't log this since we're in the log callback - just return + return; + } + }; + + let module_ref = match LOG_MODULE_REF.get() { + Some(r) => r, + None => { + // Log module not initialized - just return + return; + } + }; + + let log_jstring = match env.new_string(&log_str) { + Ok(s) => s, + Err(_) => { + // Failed to create string - just return + return; + } + }; + + // Ignore errors in log callback to avoid infinite recursion + let _ = env.call_method( + module_ref.as_obj(), + "sendCoreLog", + "(Ljava/lang/String;)V", + &[JValue::Object(&log_jstring)], + ); + })); + + // Silently ignore panics in log callback to avoid cascading failures + let _ = callback_result; } super::spawn_core_log_listener(android_log_callback, std::ptr::null_mut()); diff --git a/apps/mobile/modules/sd-mobile-core/src/index.ts b/apps/mobile/modules/sd-mobile-core/src/index.ts index eb7f7c4bdd21..e55ee63a6fcd 100644 --- a/apps/mobile/modules/sd-mobile-core/src/index.ts +++ b/apps/mobile/modules/sd-mobile-core/src/index.ts @@ -24,12 +24,26 @@ type SDMobileCoreEvents = { SDCoreLog: (log: CoreLog) => void; }; +export interface FolderPickerResult { + uri: string; + path: string | null; + name: string; +} + export interface CoreModule { initialize(dataDir?: string, deviceName?: string): Promise; sendMessage(query: string): Promise; shutdown(): void; addListener(callback: (event: CoreEvent) => void): () => void; addLogListener(callback: (log: CoreLog) => void): () => void; + pickFolder(): Promise; + getPathFromUri(uri: string): string | null; + /** Check if the app has full storage access permission (Android 11+) */ + hasStoragePermission(): boolean; + /** Check if full storage permission is required for this Android version */ + requiresStoragePermission(): boolean; + /** Open system settings to grant "All Files Access" permission */ + openStoragePermissionSettings(): boolean; } interface SDMobileCoreNativeModule extends NativeModule { @@ -41,6 +55,11 @@ interface SDMobileCoreNativeModule extends NativeModule { shutdown(): void; addListener(callback: (event: CoreEvent) => void): () => void; addLogListener(callback: (log: CoreLog) => void): () => void; + pickFolder(options: Record): Promise; + getPathFromUri(uri: string): string | null; + hasStoragePermission(): boolean; + requiresStoragePermission(): boolean; + openStoragePermissionSettings(): boolean; } const SDMobileCoreModule = @@ -70,4 +89,19 @@ export const SDMobileCore: CoreModule = { const subscription = emitter.addListener("SDCoreLog", callback); return () => subscription.remove(); }, + pickFolder: async () => { + return SDMobileCoreModule.pickFolder({}); + }, + getPathFromUri: (uri: string) => { + return SDMobileCoreModule.getPathFromUri(uri); + }, + hasStoragePermission: () => { + return SDMobileCoreModule.hasStoragePermission(); + }, + requiresStoragePermission: () => { + return SDMobileCoreModule.requiresStoragePermission(); + }, + openStoragePermissionSettings: () => { + return SDMobileCoreModule.openStoragePermissionSettings(); + }, }; diff --git a/apps/mobile/src/app/(drawer)/(tabs)/_layout.tsx b/apps/mobile/src/app/(drawer)/(tabs)/_layout.tsx index 09a6b5db9b9d..8f2c5adcd8d7 100644 --- a/apps/mobile/src/app/(drawer)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(drawer)/(tabs)/_layout.tsx @@ -1,43 +1,206 @@ -import { Platform } from 'react-native'; +import { Platform, Text } from 'react-native'; +import { Tabs } from 'expo-router'; import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Animated, { + useAnimatedStyle, + withTiming, + Easing, +} from 'react-native-reanimated'; -export default function TabLayout() { +// Brand colors matching the app theme +const colors = { + tabBarBackground: 'hsl(235, 15%, 13%)', + tabBarBorder: 'hsl(235, 15%, 23%)', + active: 'hsl(208, 100%, 57%)', + inactive: 'hsl(235, 10%, 55%)', + // M3 active indicator uses primary color at low opacity + activeIndicator: 'hsla(208, 100%, 57%, 0.15)', +}; + +// Animation config for smooth M3-style transitions +const timingConfig = { + duration: 200, + easing: Easing.out(Easing.cubic), +}; + +// M3 Active Indicator wrapper for tab icons with animations +function TabIcon({ + name, + focusedName, + focused, + color, +}: { + name: keyof typeof Ionicons.glyphMap; + focusedName: keyof typeof Ionicons.glyphMap; + focused: boolean; + color: string; +}) { + const animatedContainerStyle = useAnimatedStyle(() => ({ + backgroundColor: withTiming( + focused ? colors.activeIndicator : 'transparent', + timingConfig + ), + marginBottom: withTiming(focused ? 6 : 2, timingConfig), + transform: [ + { scale: withTiming(focused ? 1 : 0.95, timingConfig) }, + ], + })); + + return ( + + + + ); +} + +// Tab label with animated spacing +function TabLabel({ + label, + focused, + color, +}: { + label: string; + focused: boolean; + color: string; +}) { + const animatedStyle = useAnimatedStyle(() => ({ + marginTop: withTiming(focused ? 2 : 0, timingConfig), + opacity: withTiming(focused ? 1 : 0.8, timingConfig), + })); + + return ( + + {label} + + ); +} + +function IOSTabs() { return ( - {Platform.OS === 'ios' ? ( - - ) : ( - - )} + - {Platform.OS === 'ios' ? ( - - ) : ( - - )} + - {Platform.OS === 'ios' ? ( - - ) : ( - - )} + ); } + +function AndroidTabs() { + const insets = useSafeAreaInsets(); + + return ( + + ( + + ), + tabBarLabel: ({ color, focused }) => ( + + ), + }} + /> + ( + + ), + tabBarLabel: ({ color, focused }) => ( + + ), + }} + /> + ( + + ), + tabBarLabel: ({ color, focused }) => ( + + ), + }} + /> + + ); +} + +export default function TabLayout() { + return Platform.OS === 'ios' ? : ; +} diff --git a/apps/mobile/src/app/(drawer)/(tabs)/network.tsx b/apps/mobile/src/app/(drawer)/(tabs)/network.tsx deleted file mode 100644 index cd0ec2e705ee..000000000000 --- a/apps/mobile/src/app/(drawer)/(tabs)/network.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { View, Text } from 'react-native'; - -export default function NetworkScreen() { - return ( - - Network - Coming soon - - ); -} diff --git a/apps/mobile/src/app/(drawer)/_layout.tsx b/apps/mobile/src/app/(drawer)/_layout.tsx index 195382967583..f0a4ea9b9cf1 100644 --- a/apps/mobile/src/app/(drawer)/_layout.tsx +++ b/apps/mobile/src/app/(drawer)/_layout.tsx @@ -4,6 +4,13 @@ export default function DrawerLayout() { return ( + ); } diff --git a/apps/mobile/src/client/SpacedriveClient.ts b/apps/mobile/src/client/SpacedriveClient.ts index 474a76cfe336..b8b70dadfac4 100644 --- a/apps/mobile/src/client/SpacedriveClient.ts +++ b/apps/mobile/src/client/SpacedriveClient.ts @@ -1,5 +1,9 @@ import { SDMobileCore } from "sd-mobile-core"; -import { ReactNativeTransport } from "./transport"; +import { + ReactNativeTransport, + type HealthCheckResult, + type HealthStatus, +} from "./transport"; import { WIRE_METHODS } from "@sd/ts-client"; import type { Event } from "@sd/ts-client/generated/types"; import { SubscriptionManager } from "./subscriptionManager"; @@ -224,10 +228,54 @@ export class SpacedriveClient extends SimpleEventEmitter { return this.subscriptionManager.getStats(); } + /** + * Start connection health monitoring. + * Health checks run periodically and emit 'connection-health' events. + * @param intervalMs Interval between checks (default: 30 seconds) + */ + startHealthMonitoring(intervalMs?: number): void { + this.transport.startHealthCheck(intervalMs); + } + + /** + * Stop connection health monitoring. + */ + stopHealthMonitoring(): void { + this.transport.stopHealthCheck(); + } + + /** + * Get the current connection health status. + */ + getHealthStatus(): HealthStatus { + return this.transport.getHealthStatus(); + } + + /** + * Add a listener for connection health changes. + * @returns Cleanup function to remove the listener + */ + onHealthChange(listener: (result: HealthCheckResult) => void): () => void { + const cleanup = this.transport.onHealthChange((result) => { + listener(result); + // Also emit as event for compatibility + this.emit("connection-health", result); + }); + return cleanup; + } + + /** + * Perform a single health check and return the result. + */ + async checkHealth(): Promise { + return this.transport.performHealthCheck(); + } + /** * Shutdown the core and clean up resources. */ destroy() { + this.stopHealthMonitoring(); this.subscriptionManager.destroy(); this.transport.destroy(); SDMobileCore.shutdown(); diff --git a/apps/mobile/src/client/hooks/useClient.tsx b/apps/mobile/src/client/hooks/useClient.tsx index 6c315ad4e005..1c6e4448876a 100644 --- a/apps/mobile/src/client/hooks/useClient.tsx +++ b/apps/mobile/src/client/hooks/useClient.tsx @@ -171,7 +171,8 @@ export function SpacedriveProvider({ return () => { mounted = false; - if (unsubscribeLogs) unsubscribeLogs(); + // unsubscribeLogs is commented out above, so skip the cleanup + // if (unsubscribeLogs) unsubscribeLogs(); initPromise.then((unsubscribe) => { if (unsubscribe) unsubscribe(); }); @@ -199,9 +200,11 @@ export function SpacedriveProvider({ ); } + // Cast to any because mobile's SpacedriveClient has a different interface + // than ts-client's SpacedriveClient but is compatible at runtime return ( - + {children} diff --git a/apps/mobile/src/client/hooks/useQuery.ts b/apps/mobile/src/client/hooks/useQuery.ts index 01c16daa2540..b40d6d60725f 100644 --- a/apps/mobile/src/client/hooks/useQuery.ts +++ b/apps/mobile/src/client/hooks/useQuery.ts @@ -5,6 +5,12 @@ import { UseMutationOptions, } from "@tanstack/react-query"; import { useSpacedriveClient } from "./useClient"; +import type { SpacedriveClient } from "../SpacedriveClient"; + +// Cast hook result to mobile's client type +function useMobileClient(): SpacedriveClient { + return useSpacedriveClient() as unknown as SpacedriveClient; +} /** * Hook for executing core-level queries (no library context). @@ -14,7 +20,7 @@ export function useCoreQuery( input: unknown = {}, options?: Omit, "queryKey" | "queryFn">, ) { - const client = useSpacedriveClient(); + const client = useMobileClient(); return useQuery({ queryKey: ["core", method, input], @@ -32,7 +38,7 @@ export function useLibraryQuery( input: unknown = {}, options?: Omit, "queryKey" | "queryFn">, ) { - const client = useSpacedriveClient(); + const client = useMobileClient(); const libraryId = client.getCurrentLibraryId(); return useQuery({ @@ -50,7 +56,7 @@ export function useCoreAction( method: string, options?: UseMutationOptions, ) { - const client = useSpacedriveClient(); + const client = useMobileClient(); return useMutation({ mutationFn: (input: TInput) => @@ -66,7 +72,7 @@ export function useLibraryAction( method: string, options?: UseMutationOptions, ) { - const client = useSpacedriveClient(); + const client = useMobileClient(); return useMutation({ mutationFn: (input: TInput) => diff --git a/apps/mobile/src/client/index.ts b/apps/mobile/src/client/index.ts index a100b29b2eb0..7024c17da18a 100644 --- a/apps/mobile/src/client/index.ts +++ b/apps/mobile/src/client/index.ts @@ -11,6 +11,13 @@ export { useLibraryAction, } from "./hooks/useQuery"; +// Helper to get properly typed mobile client +import type { SpacedriveClient as MobileClient } from "./SpacedriveClient"; +import { useSpacedriveClient as _useClient } from "./hooks/useClient"; +export function useMobileClient(): MobileClient { + return _useClient() as unknown as MobileClient; +} + // Re-export shared hooks from ts-client export { useNormalizedQuery } from "@sd/ts-client/src/hooks/useNormalizedQuery"; export { useSearchFiles } from "@sd/ts-client"; diff --git a/apps/mobile/src/client/subscriptionManager.ts b/apps/mobile/src/client/subscriptionManager.ts index 2dfd1a274515..152f497b1f02 100644 --- a/apps/mobile/src/client/subscriptionManager.ts +++ b/apps/mobile/src/client/subscriptionManager.ts @@ -28,12 +28,16 @@ interface SubscriptionEntry { listeners: Set<(event: Event) => void>; refCount: number; filter: EventFilter; + /** Flag to prevent double cleanup */ + cleaned: boolean; } export class SubscriptionManager { private subscriptions = new Map(); private pendingSubscriptions = new Map>(); private transport: ReactNativeTransport; + /** Flag to prevent cleanup races during destruction */ + private isDestroying = false; constructor(transport: ReactNativeTransport) { this.transport = transport; @@ -114,7 +118,8 @@ export class SubscriptionManager { ): Promise { const unsubscribe = await this.transport.subscribe((event) => { const currentEntry = this.subscriptions.get(key); - if (currentEntry && this.matchesFilter(event, filter)) { + // Check cleaned flag to prevent processing during cleanup + if (currentEntry && !currentEntry.cleaned && this.matchesFilter(event, filter)) { currentEntry.listeners.forEach((listener) => listener(event)); } }); @@ -124,6 +129,7 @@ export class SubscriptionManager { listeners: new Set(), refCount: 0, filter, + cleaned: false, }; this.subscriptions.set(key, entry); @@ -134,16 +140,35 @@ export class SubscriptionManager { key: string, callback: (event: Event) => void, ): () => void { + let hasRun = false; + return () => { + // Guard against double cleanup + if (hasRun) return; + hasRun = true; + + // Don't cleanup during destruction - destroy() handles it + if (this.isDestroying) return; + const currentEntry = this.subscriptions.get(key); - if (!currentEntry) return; + if (!currentEntry || currentEntry.cleaned) return; currentEntry.listeners.delete(callback); currentEntry.refCount--; if (currentEntry.refCount === 0) { - currentEntry.unsubscribe(); - this.subscriptions.delete(key); + // Mark as cleaned first to prevent race conditions + currentEntry.cleaned = true; + + // Defer unsubscribe to next tick to allow pending operations to complete + setTimeout(() => { + // Double check the entry is still the one we expect + const entry = this.subscriptions.get(key); + if (entry === currentEntry) { + entry.unsubscribe(); + this.subscriptions.delete(key); + } + }, 0); } }; } @@ -168,7 +193,27 @@ export class SubscriptionManager { * Force cleanup all subscriptions (for testing/cleanup) */ destroy() { - this.subscriptions.forEach((entry) => entry.unsubscribe()); + // Set flag to prevent individual cleanup handlers from running + this.isDestroying = true; + + // Mark all entries as cleaned first + this.subscriptions.forEach((entry) => { + entry.cleaned = true; + }); + + // Then unsubscribe all + this.subscriptions.forEach((entry) => { + try { + entry.unsubscribe(); + } catch (e) { + console.warn("[SubscriptionManager] Error during unsubscribe:", e); + } + }); + this.subscriptions.clear(); + this.pendingSubscriptions.clear(); + + // Reset flag in case manager is reused + this.isDestroying = false; } } diff --git a/apps/mobile/src/client/transport.ts b/apps/mobile/src/client/transport.ts index 666eb76b43b4..915240436573 100644 --- a/apps/mobile/src/client/transport.ts +++ b/apps/mobile/src/client/transport.ts @@ -25,20 +25,145 @@ export interface JsonRpcRequest { }; } +export interface JsonRpcErrorData { + error_type: string; + details?: Record; +} + export interface JsonRpcResponse { jsonrpc: "2.0"; id: string; result?: unknown; - error?: { code: number; message: string }; + error?: { code: number; message: string; data?: JsonRpcErrorData }; +} + +/** + * Custom error class for Spacedrive errors with additional context. + */ +export class SpacedriveError extends Error { + public readonly code: number; + public readonly errorType: string; + public readonly details?: Record; + + constructor( + message: string, + code: number, + errorType: string, + details?: Record, + ) { + super(message); + this.name = "SpacedriveError"; + this.code = code; + this.errorType = errorType; + this.details = details; + } + + /** + * Check if this is a specific error type. + */ + isType(errorType: string): boolean { + return this.errorType === errorType; + } } type PendingRequest = { resolve: (result: unknown) => void; reject: (error: Error) => void; + timeoutId?: ReturnType; }; let requestCounter = 0; +// Timeout configuration +const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds for normal requests +const LONG_RUNNING_TIMEOUT_MS = 120000; // 2 minutes for long-running operations + +// Methods that are known to take longer +const LONG_RUNNING_METHODS = [ + "action:locations.add", + "action:locations.rescan", + "action:libraries.create", + "action:jobs.run", +]; + +// Retry configuration +export interface RetryConfig { + maxRetries: number; + baseDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; +} + +const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + baseDelayMs: 1000, + maxDelayMs: 10000, + backoffMultiplier: 2, +}; + +// Errors that should not be retried +const NON_RETRYABLE_ERROR_TYPES = [ + "INVALID_REQUEST", // Client error - request is malformed + "INVALID_METHOD", // Client error - method doesn't exist + "INVALID_LIBRARY_ID", // Client error - bad library ID format + "LIBRARY_NOT_FOUND", // Client error - library doesn't exist + "SECURITY_ERROR", // Security violation - shouldn't retry + "VALIDATION_ERROR", // Client error - invalid input +]; + +/** + * Check if an error should be retried. + */ +function isRetryableError(error: Error): boolean { + if (error instanceof SpacedriveError) { + return !NON_RETRYABLE_ERROR_TYPES.includes(error.errorType); + } + // Retry network errors and unknown errors + return true; +} + +/** + * Calculate delay for exponential backoff with jitter. + */ +function calculateRetryDelay(attempt: number, config: RetryConfig): number { + const exponentialDelay = + config.baseDelayMs * Math.pow(config.backoffMultiplier, attempt); + const boundedDelay = Math.min(exponentialDelay, config.maxDelayMs); + // Add jitter (10-20% randomization) + const jitter = boundedDelay * (0.1 + Math.random() * 0.1); + return boundedDelay + jitter; +} + +/** + * Sleep for the specified duration. + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if a method is a long-running operation. + */ +function isLongRunningMethod(method: string): boolean { + return LONG_RUNNING_METHODS.some((m) => method.startsWith(m)); +} + +// Health check configuration +const HEALTH_CHECK_INTERVAL_MS = 30000; // 30 seconds +const HEALTH_CHECK_TIMEOUT_MS = 5000; // 5 seconds + +// Batch processing configuration +const BATCH_TIMEOUT_BASE_MS = 30000; // Base timeout for batch +const BATCH_TIMEOUT_PER_REQUEST_MS = 5000; // Additional timeout per request in batch + +export type HealthStatus = "healthy" | "unhealthy" | "unknown"; + +export interface HealthCheckResult { + status: HealthStatus; + latencyMs?: number; + error?: string; +} + /** * Transport layer for communicating with the embedded Spacedrive core. * Batches requests for efficiency and handles JSON-RPC protocol. @@ -47,6 +172,9 @@ export class ReactNativeTransport { private pendingRequests = new Map(); private batch: JsonRpcRequest[] = []; private batchQueued = false; + private healthCheckInterval: ReturnType | null = null; + private currentHealthStatus: HealthStatus = "unknown"; + private healthListeners = new Set<(result: HealthCheckResult) => void>(); constructor() { // No event listener needed - responses come through sendMessage promise @@ -60,7 +188,14 @@ export class ReactNativeTransport { if (response.error) { console.error("[Transport] ❌ Response error:", response.error); - pending.reject(new Error(response.error.message)); + const errorData = response.error.data; + const error = new SpacedriveError( + response.error.message, + response.error.code, + errorData?.error_type ?? "UNKNOWN_ERROR", + errorData?.details, + ); + pending.reject(error); } else { pending.resolve(response.result); } @@ -76,15 +211,43 @@ export class ReactNativeTransport { setTimeout(async () => { const currentBatch = [...this.batch]; this.batch = []; - this.batchQueued = false; - if (currentBatch.length === 0) return; + // Reset batchQueued after taking the batch, not before processing + // This prevents new requests from being lost if processing fails + + if (currentBatch.length === 0) { + this.batchQueued = false; + return; + } + + // Calculate batch timeout based on number of requests + const batchTimeout = + BATCH_TIMEOUT_BASE_MS + currentBatch.length * BATCH_TIMEOUT_PER_REQUEST_MS; try { const query = JSON.stringify( currentBatch.length === 1 ? currentBatch[0] : currentBatch, ); - const resultStr = await SDMobileCore.sendMessage(query); + + // Create timeout promise for batch-level timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new SpacedriveError( + `Batch request timeout after ${batchTimeout}ms (${currentBatch.length} requests)`, + -32000, + "BATCH_TIMEOUT", + { requestCount: currentBatch.length, timeout: batchTimeout }, + ), + ); + }, batchTimeout); + }); + + // Race between actual request and timeout + const resultStr = await Promise.race([ + SDMobileCore.sendMessage(query), + timeoutPromise, + ]); const result = JSON.parse(resultStr); if (Array.isArray(result)) { @@ -93,31 +256,124 @@ export class ReactNativeTransport { this.processResponse(result); } } catch (e) { - console.error("[Transport] ❌ Batch request failed:", e); + console.error("[Transport] Batch request failed:", e); + + // Determine error type for better error messages + const errorType = + e instanceof SpacedriveError ? e.errorType : "BATCH_FAILED"; + const errorMessage = + e instanceof Error ? e.message : "Batch request failed"; + + // Reject all pending requests in the batch with specific error for (const req of currentBatch) { const pending = this.pendingRequests.get(req.id); if (pending) { - pending.reject(new Error("Batch request failed")); + const error = new SpacedriveError( + `${errorMessage} (method: ${req.method})`, + -32000, + errorType, + { method: req.method }, + ); + pending.reject(error); this.pendingRequests.delete(req.id); } } + } finally { + // Always reset batchQueued in finally to ensure recovery + this.batchQueued = false; } }, 0); } /** * Send a request to the core and return a promise with the result. + * @param method The JSON-RPC method to call + * @param params The parameters for the method + * @param options Optional configuration including custom timeout and retry config */ async request( method: string, params: { input: unknown; library_id?: string }, + options?: { timeout?: number; retry?: Partial | false }, + ): Promise { + const retryConfig = + options?.retry === false + ? null + : { ...DEFAULT_RETRY_CONFIG, ...options?.retry }; + + let lastError: Error | null = null; + const maxAttempts = retryConfig ? retryConfig.maxRetries + 1 : 1; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await this.requestInternal(method, params, options?.timeout); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if we should retry + const isLastAttempt = attempt >= maxAttempts - 1; + const shouldRetry = retryConfig && !isLastAttempt && isRetryableError(lastError); + + if (!shouldRetry) { + throw lastError; + } + + // Calculate and wait for retry delay + const delay = calculateRetryDelay(attempt, retryConfig); + console.warn( + `[Transport] Request failed, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxAttempts}): ${method}`, + ); + await sleep(delay); + } + } + + // Should never reach here, but TypeScript needs this + throw lastError ?? new Error("Request failed"); + } + + /** + * Internal request implementation without retry logic. + */ + private requestInternal( + method: string, + params: { input: unknown; library_id?: string }, + timeout?: number, ): Promise { return new Promise((resolve, reject) => { const id = `${++requestCounter}`; + // Determine timeout based on method type or explicit option + const effectiveTimeout = + timeout ?? + (isLongRunningMethod(method) ? LONG_RUNNING_TIMEOUT_MS : DEFAULT_TIMEOUT_MS); + + // Set up timeout handler + const timeoutId = setTimeout(() => { + const pending = this.pendingRequests.get(id); + if (pending) { + this.pendingRequests.delete(id); + console.error(`[Transport] Request timeout after ${effectiveTimeout}ms: ${method}`); + reject( + new SpacedriveError( + `Request timeout after ${effectiveTimeout}ms: ${method}`, + -32000, + "TIMEOUT", + { method, timeout: effectiveTimeout }, + ), + ); + } + }, effectiveTimeout); + this.pendingRequests.set(id, { - resolve: resolve as (result: unknown) => void, - reject, + resolve: (result: unknown) => { + clearTimeout(timeoutId); + resolve(result as T); + }, + reject: (error: Error) => { + clearTimeout(timeoutId); + reject(error); + }, + timeoutId, }); this.batch.push({ @@ -153,10 +409,115 @@ export class ReactNativeTransport { } /** - * Clean up resources. + * Start periodic health checks. + * @param intervalMs Interval between health checks (default: 30 seconds) + */ + startHealthCheck(intervalMs: number = HEALTH_CHECK_INTERVAL_MS): void { + if (this.healthCheckInterval) { + return; // Already running + } + + // Run initial check + this.performHealthCheck(); + + // Schedule periodic checks + this.healthCheckInterval = setInterval(() => { + this.performHealthCheck(); + }, intervalMs); + } + + /** + * Stop periodic health checks. + */ + stopHealthCheck(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + } + + /** + * Add a listener for health status changes. + * @returns Cleanup function to remove the listener + */ + onHealthChange(listener: (result: HealthCheckResult) => void): () => void { + this.healthListeners.add(listener); + return () => { + this.healthListeners.delete(listener); + }; + } + + /** + * Get the current health status. + */ + getHealthStatus(): HealthStatus { + return this.currentHealthStatus; + } + + /** + * Perform a single health check. + */ + async performHealthCheck(): Promise { + const startTime = Date.now(); + + try { + // Use a simple query that should always succeed + await this.requestInternal( + "query:core.ping", + { input: {} }, + HEALTH_CHECK_TIMEOUT_MS, + ); + + const latencyMs = Date.now() - startTime; + const result: HealthCheckResult = { + status: "healthy", + latencyMs, + }; + + this.updateHealthStatus(result); + return result; + } catch (error) { + const result: HealthCheckResult = { + status: "unhealthy", + error: error instanceof Error ? error.message : String(error), + }; + + this.updateHealthStatus(result); + return result; + } + } + + private updateHealthStatus(result: HealthCheckResult): void { + const previousStatus = this.currentHealthStatus; + this.currentHealthStatus = result.status; + + // Only notify listeners if status changed or if unhealthy (always report errors) + if (previousStatus !== result.status || result.status === "unhealthy") { + this.healthListeners.forEach((listener) => { + try { + listener(result); + } catch (e) { + console.warn("[Transport] Health listener error:", e); + } + }); + } + } + + /** + * Clean up resources including pending timeouts. */ destroy() { + // Stop health checks + this.stopHealthCheck(); + + // Clear all pending timeouts before clearing the map + for (const pending of this.pendingRequests.values()) { + if (pending.timeoutId) { + clearTimeout(pending.timeoutId); + } + } this.pendingRequests.clear(); this.batch = []; + this.healthListeners.clear(); } } diff --git a/apps/mobile/src/components/PageIndicator.tsx b/apps/mobile/src/components/PageIndicator.tsx index ae273b8e10c8..38dbcb33fbe1 100644 --- a/apps/mobile/src/components/PageIndicator.tsx +++ b/apps/mobile/src/components/PageIndicator.tsx @@ -1,7 +1,17 @@ import React from "react"; import { View } from "react-native"; +import Animated, { + useAnimatedStyle, + withTiming, + Easing, +} from "react-native-reanimated"; import sharedColors from "@sd/ui/style/colors"; +const timingConfig = { + duration: 200, + easing: Easing.out(Easing.cubic), +}; + interface PageIndicatorProps { currentIndex: number; totalPages: number; @@ -11,6 +21,34 @@ interface PageIndicatorProps { pageColors?: (string | null)[]; } +function IndicatorDot({ + isActive, + color, + inactiveColor, +}: { + isActive: boolean; + color: string; + inactiveColor: string; +}) { + const animatedStyle = useAnimatedStyle(() => ({ + width: withTiming(isActive ? 24 : 8, timingConfig), + opacity: withTiming(isActive ? 1 : 0.3, timingConfig), + backgroundColor: withTiming(isActive ? color : inactiveColor, timingConfig), + })); + + return ( + + ); +} + export function PageIndicator({ currentIndex, totalPages, @@ -22,18 +60,14 @@ export function PageIndicator({ {Array.from({ length: totalPages }).map((_, index) => { const isActive = currentIndex === index; - const pageColor = pageColors?.[index]; - const backgroundColor = pageColor || (isActive ? activeColor : inactiveColor); + const pageColor = pageColors?.[index] || activeColor; return ( - ); })} diff --git a/apps/mobile/src/components/StoragePermissionBanner.tsx b/apps/mobile/src/components/StoragePermissionBanner.tsx new file mode 100644 index 000000000000..60df3905ff23 --- /dev/null +++ b/apps/mobile/src/components/StoragePermissionBanner.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { View, Text, Pressable, Platform } from "react-native"; +import { useStoragePermission } from "../hooks/useStoragePermission"; + +interface StoragePermissionBannerProps { + /** Whether to show the banner even if permission is granted (for testing) */ + forceShow?: boolean; +} + +/** + * A banner that shows when Android storage permission is required but not granted. + * This banner explains the issue and provides a button to open settings. + */ +export function StoragePermissionBanner({ forceShow = false }: StoragePermissionBannerProps) { + const { isRequired, isGranted, isLoading, openSettings } = useStoragePermission(); + + // Don't show on iOS or while loading + if (Platform.OS !== "android") return null; + if (isLoading) return null; + + // Don't show if permission is granted (unless forceShow is true) + if (isGranted && !forceShow) return null; + + // Don't show if permission isn't required on this Android version + if (!isRequired && !forceShow) return null; + + return ( + + + + ! + + + + Storage Permission Required + + + Spacedrive needs "All Files Access" permission to browse files on your device. + Without it, only folder names will be visible. + + + + Grant Permission + + + + + + ); +} diff --git a/apps/mobile/src/components/primitive/SettingsGroup.tsx b/apps/mobile/src/components/primitive/SettingsGroup.tsx index 25be11f37ec4..6dfec84ef018 100644 --- a/apps/mobile/src/components/primitive/SettingsGroup.tsx +++ b/apps/mobile/src/components/primitive/SettingsGroup.tsx @@ -6,7 +6,7 @@ import { SettingsRowProps } from "./SettingsRow"; interface SettingsGroupProps { header?: string; footer?: string; - children: ReactElement | ReactElement[]; + children: React.ReactNode; className?: string; } @@ -33,7 +33,7 @@ export function SettingsGroup({ {Children.map(children, (child, index) => { if (!React.isValidElement(child)) return child; - return cloneElement(child, { + return cloneElement(child as ReactElement, { isFirst: index === 0, isLast: index === totalChildren - 1, }); diff --git a/apps/mobile/src/hooks/useStoragePermission.ts b/apps/mobile/src/hooks/useStoragePermission.ts new file mode 100644 index 000000000000..2bd40ab26afd --- /dev/null +++ b/apps/mobile/src/hooks/useStoragePermission.ts @@ -0,0 +1,121 @@ +import { useEffect, useState, useCallback } from "react"; +import { Platform, Alert, AppState, AppStateStatus } from "react-native"; +import { SDMobileCore } from "sd-mobile-core"; + +export interface StoragePermissionState { + /** Whether storage permission is required on this device */ + isRequired: boolean; + /** Whether the permission has been granted */ + isGranted: boolean; + /** Whether we're still checking the permission status */ + isLoading: boolean; + /** Open the system settings to grant permission */ + openSettings: () => void; + /** Re-check the permission status (useful after returning from settings) */ + recheckPermission: () => void; +} + +/** + * Hook to check and manage Android storage permission. + * On Android 11+, apps need "All Files Access" permission to read files + * outside of app-specific directories when using direct filesystem paths. + */ +export function useStoragePermission(): StoragePermissionState { + const [isRequired, setIsRequired] = useState(false); + const [isGranted, setIsGranted] = useState(true); + const [isLoading, setIsLoading] = useState(true); + + const checkPermission = useCallback(() => { + if (Platform.OS !== "android") { + setIsRequired(false); + setIsGranted(true); + setIsLoading(false); + return; + } + + try { + const required = SDMobileCore.requiresStoragePermission(); + setIsRequired(required); + + if (required) { + const granted = SDMobileCore.hasStoragePermission(); + setIsGranted(granted); + } else { + setIsGranted(true); + } + } catch (error) { + console.error("Error checking storage permission:", error); + // Assume granted on error to avoid blocking the user + setIsGranted(true); + } + + setIsLoading(false); + }, []); + + const openSettings = useCallback(() => { + if (Platform.OS !== "android") return; + + try { + SDMobileCore.openStoragePermissionSettings(); + } catch (error) { + console.error("Error opening storage settings:", error); + Alert.alert( + "Unable to Open Settings", + "Please go to Settings > Apps > Spacedrive > Permissions and enable 'All Files Access' manually.", + ); + } + }, []); + + // Check permission on mount + useEffect(() => { + checkPermission(); + }, [checkPermission]); + + // Re-check permission when app comes back to foreground (user may have granted it in settings) + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState === "active") { + checkPermission(); + } + }; + + const subscription = AppState.addEventListener("change", handleAppStateChange); + return () => subscription.remove(); + }, [checkPermission]); + + return { + isRequired, + isGranted, + isLoading, + openSettings, + recheckPermission: checkPermission, + }; +} + +/** + * Show an alert prompting the user to grant storage permission. + * Returns a promise that resolves when the user dismisses the alert. + */ +export function showStoragePermissionAlert(openSettings: () => void): Promise { + return new Promise((resolve) => { + Alert.alert( + "Storage Permission Required", + "Spacedrive needs 'All Files Access' permission to browse and index files on your device.\n\n" + + "Without this permission, you'll only see folder names but not the files inside them.", + [ + { + text: "Not Now", + style: "cancel", + onPress: () => resolve(), + }, + { + text: "Open Settings", + onPress: () => { + openSettings(); + resolve(); + }, + }, + ], + ); + }); +} diff --git a/apps/mobile/src/screens/browse/BrowseScreen.tsx b/apps/mobile/src/screens/browse/BrowseScreen.tsx index 6d201dc750ca..1fbbb77d80e4 100644 --- a/apps/mobile/src/screens/browse/BrowseScreen.tsx +++ b/apps/mobile/src/screens/browse/BrowseScreen.tsx @@ -4,6 +4,7 @@ import { Text, ScrollView, Dimensions, + Platform, type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; @@ -62,7 +63,12 @@ function SpaceContent({ }); // Space name scale on overscroll (anchored left) + // Note: transformOrigin doesn't work well on Android + const isIOS = Platform.OS === 'ios'; const spaceNameScale = useAnimatedStyle(() => { + if (!isIOS) { + return {}; + } const scale = interpolate( scrollY.value, [-200, 0], diff --git a/apps/mobile/src/screens/browse/components/DevicesGroup.tsx b/apps/mobile/src/screens/browse/components/DevicesGroup.tsx index 3dd532592877..674ff8d01afa 100644 --- a/apps/mobile/src/screens/browse/components/DevicesGroup.tsx +++ b/apps/mobile/src/screens/browse/components/DevicesGroup.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { View, Image } from "react-native"; +import { View, Image, ImageSourcePropType } from "react-native"; import { useRouter } from "expo-router"; import { useNormalizedQuery } from "../../../client"; import type { Device } from "@sd/ts-client"; @@ -25,7 +25,8 @@ export function DevicesGroup() { return ( {devices.map((device) => { - const deviceIconSrc = getDeviceIcon(device); + // Cast since getDeviceIcon returns imported PNG modules + const deviceIconSrc = getDeviceIcon(device as any) as ImageSourcePropType; return ( { router.push({ - pathname: "/explorer", + pathname: "/device/[deviceId]", params: { - type: "view", - view: "device", - id: device.id, + deviceId: device.id, + name: device.name, }, }); }} diff --git a/apps/mobile/src/screens/browse/components/LocationsGroup.tsx b/apps/mobile/src/screens/browse/components/LocationsGroup.tsx index cfcf74851cbd..ea00a8c68917 100644 --- a/apps/mobile/src/screens/browse/components/LocationsGroup.tsx +++ b/apps/mobile/src/screens/browse/components/LocationsGroup.tsx @@ -4,65 +4,72 @@ import { useRouter } from "expo-router"; import { useNormalizedQuery } from "../../../client"; import { SettingsGroup, SettingsLink } from "../../../components/primitive"; import FolderIcon from "@sd/assets/icons/Folder.png"; -import type { Device } from "@sd/ts-client"; +import type { Location, SdPath } from "@sd/ts-client"; + +// Extract path string and device slug from SdPath +function extractPathInfo(sdPath: SdPath): { path: string; deviceSlug: string } { + if ("Physical" in sdPath) { + return { + path: sdPath.Physical.path, + deviceSlug: sdPath.Physical.device_slug, + }; + } + if ("Cloud" in sdPath) { + return { + path: sdPath.Cloud.path, + deviceSlug: `cloud-${sdPath.Cloud.service}`, + }; + } + return { path: "/", deviceSlug: "local" }; +} export function LocationsGroup() { const router = useRouter(); - const { data: locationsData } = useNormalizedQuery({ + const { data: locationsData } = useNormalizedQuery< + any, + { locations: Location[] } + >({ query: "locations.list", input: null, resourceType: "location", }); - const { data: devices } = useNormalizedQuery({ - query: "devices.list", - input: { - include_offline: true, - include_details: false, - show_paired: true, - }, - resourceType: "device", - }); - const locations = locationsData?.locations ?? []; if (locations.length === 0) { return null; } - // Helper to get device name from device_slug - const getDeviceName = (location: any) => { - const deviceSlug = location.sd_path?.Physical?.device_slug; - if (!deviceSlug) return "Unknown device"; - const device = devices?.find((d) => d.slug === deviceSlug); - return device?.name || "Unknown device"; - }; - return ( - {locations.map((location: any) => ( - - } - label={location.name || "Unnamed"} - description={getDeviceName(location)} - onPress={() => { - router.push({ - pathname: "/explorer", - params: { - type: "path", - path: JSON.stringify(location.sd_path), - }, - }); - }} - /> - ))} + {locations.map((location) => { + const { path, deviceSlug } = extractPathInfo(location.sd_path); + return ( + + } + label={location.name || "Unnamed"} + description={path || "No path"} + onPress={() => { + router.push({ + pathname: "/location/[locationId]", + params: { + locationId: location.id, + name: location.name || "Location", + path: path, + deviceSlug: deviceSlug, + }, + }); + }} + /> + ); + })} ); } diff --git a/apps/mobile/src/screens/browse/components/VolumesGroup.tsx b/apps/mobile/src/screens/browse/components/VolumesGroup.tsx index bc283d750b14..8287179b24b1 100644 --- a/apps/mobile/src/screens/browse/components/VolumesGroup.tsx +++ b/apps/mobile/src/screens/browse/components/VolumesGroup.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Image } from "react-native"; +import { Image, ImageSourcePropType } from "react-native"; import { useRouter } from "expo-router"; import { useNormalizedQuery } from "../../../client"; import type { Volume, Device } from "@sd/ts-client"; @@ -35,9 +35,15 @@ export function VolumesGroup() { return ( {volumes.map((volume) => { - const volumeIconSrc = getVolumeIcon(volume); - const device = devices?.find((d) => d.id === volume.device_id); - + // Cast volume_type for compatibility with getVolumeIcon's expected type + const volumeIconSrc = getVolumeIcon({ + mount_point: volume.mount_point, + volume_type: volume.volume_type as + | "Internal" + | "External" + | "Removable" + | undefined, + }) as ImageSourcePropType; return ( { - if (device) { - const sdPath = { - Physical: { - device_slug: device.slug, - path: volume.mount_point || "/", - }, - }; - router.push({ - pathname: "/explorer", - params: { - type: "path", - path: JSON.stringify(sdPath), - }, - }); - } + router.push({ + pathname: "/volume/[volumeId]", + params: { + volumeId: volume.id, + name: volume.display_name || volume.name, + }, + }); }} /> ); diff --git a/apps/mobile/src/screens/overview/OverviewScreen.tsx b/apps/mobile/src/screens/overview/OverviewScreen.tsx index 35cd6f1178c1..95d5fb62c61d 100644 --- a/apps/mobile/src/screens/overview/OverviewScreen.tsx +++ b/apps/mobile/src/screens/overview/OverviewScreen.tsx @@ -1,5 +1,5 @@ -import React, { useState, useMemo, useEffect } from "react"; -import { View, Text, Pressable, ScrollView, StyleSheet } from "react-native"; +import React, { useState, useMemo, useEffect, useCallback } from "react"; +import { View, Text, Pressable, ScrollView, StyleSheet, Alert, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useNavigation, DrawerActions } from "@react-navigation/native"; import Animated, { @@ -13,14 +13,17 @@ import Animated, { Easing, } from "react-native-reanimated"; import { BlurView } from "expo-blur"; -import { useNormalizedQuery } from "../../client"; -import type { Library } from "@sd/ts-client"; +import * as DocumentPicker from "expo-document-picker"; +import { SDMobileCore } from "sd-mobile-core"; +import { useNormalizedQuery, useMobileClient } from "../../client"; +import type { Library, Device } from "@sd/ts-client"; import { HeroStats, DevicePanel, ActionButtons } from "./components"; import { PairingPanel } from "../../components/PairingPanel"; import { LibrarySwitcherPanel } from "../../components/LibrarySwitcherPanel"; import { GlassButton } from "../../components/GlassButton"; import { GlassSearchBar } from "../../components/GlassSearchBar"; import { JobManagerPanel } from "../../components/JobManagerPanel"; +import { StoragePermissionBanner } from "../../components/StoragePermissionBanner"; import { useRouter } from "expo-router"; import { useSearchStore } from "../explorer/context/SearchContext"; import { CircleNotch, ListBullets } from "phosphor-react-native"; @@ -35,6 +38,7 @@ export function OverviewScreen() { const insets = useSafeAreaInsets(); const navigation = useNavigation(); const router = useRouter(); + const client = useMobileClient(); const scrollY = useSharedValue(0); const expandedOffsetY = useSharedValue(0); const [showPairing, setShowPairing] = useState(false); @@ -44,6 +48,7 @@ export function OverviewScreen() { ); const { enterSearchMode } = useSearchStore(); const { activeJobCount, hasRunningJobs } = useJobs(); + const [isAddingStorage, setIsAddingStorage] = useState(false); // Spinning animation for jobs icon const spinRotation = useSharedValue(0); @@ -90,6 +95,21 @@ export function OverviewScreen() { resourceType: "location", }); + // Fetch devices to get current device slug + const { data: devicesData, error: devicesError } = useNormalizedQuery({ + query: "devices.list", + input: { include_offline: true, include_details: false }, + resourceType: "device", + }); + + // Get the current device + const currentDevice = useMemo(() => { + if (!devicesData) return null; + const devices = Array.isArray(devicesData) ? devicesData : (devicesData as any).devices; + if (!devices) return null; + return devices.find((d: Device) => d.is_current) || null; + }, [devicesData]); + // Find the selected location from the list reactively const selectedLocation = useMemo(() => { if (!selectedLocationId || !locationsData?.locations) return null; @@ -104,6 +124,90 @@ export function OverviewScreen() { navigation.dispatch(DrawerActions.openDrawer()); }; + // Handle adding storage location + const handleAddStorage = useCallback(async () => { + if (!currentDevice) { + const errorMsg = devicesError + ? `Device query failed: ${devicesError}` + : "Device information not loaded yet. Please wait a moment and try again."; + Alert.alert("Error", errorMsg); + console.log("[handleAddStorage] No current device. Error:", devicesError); + return; + } + + if (isAddingStorage) return; + + try { + setIsAddingStorage(true); + + if (Platform.OS === "android") { + // Use native SAF folder picker for Android + console.log("[handleAddStorage] Opening Android folder picker..."); + const result = await SDMobileCore.pickFolder(); + console.log("[handleAddStorage] Folder picker result:", result); + + if (!result.path) { + Alert.alert( + "Cannot Access Folder", + "The selected folder cannot be accessed directly. This may be due to Android storage restrictions.\n\nPlease try selecting a folder from internal storage (not an SD card or cloud storage).", + [{ text: "OK" }] + ); + return; + } + + // Add the location with the real filesystem path + await client.libraryAction("locations.add", { + path: { + Physical: { + device_slug: currentDevice.slug, + path: result.path, + }, + }, + name: result.name, + mode: "Deep", + job_policies: null, + }); + + Alert.alert("Success", `Added "${result.name}" to your library! Indexing will begin shortly.`); + } else { + // iOS - use expo-document-picker + const result = await DocumentPicker.getDocumentAsync({ + type: "*/*", + copyToCacheDirectory: false, + }); + + if (result.canceled || !result.assets || result.assets.length === 0) { + return; + } + + const selectedUri = result.assets[0].uri; + + await client.libraryAction("locations.add", { + path: { + Physical: { + device_slug: currentDevice.slug, + path: selectedUri, + }, + }, + name: null, + mode: "Deep", + job_policies: null, + }); + + Alert.alert("Success", "Storage location added! Indexing will begin shortly."); + } + } catch (err: any) { + console.error("Failed to add storage:", err); + // Handle cancellation gracefully + if (err?.code === "CANCELLED" || err?.message?.includes("cancel")) { + return; + } + Alert.alert("Error", `Failed to add storage: ${err?.message || err}`); + } finally { + setIsAddingStorage(false); + } + }, [client, currentDevice, isAddingStorage, devicesError]); + // Entrance animation on mount useEffect(() => { expandedOffsetY.value = withTiming(HERO_HEIGHT, { @@ -142,7 +246,12 @@ export function OverviewScreen() { }); // Library name scale on overscroll (anchored left) + // Note: transformOrigin doesn't work well on Android, so we skip scaling there + const isIOS = Platform.OS === 'ios'; const libraryNameScale = useAnimatedStyle(() => { + if (!isIOS) { + return {}; + } const scale = interpolate( scrollY.value, [-200, 0], @@ -277,6 +386,47 @@ export function OverviewScreen() { return { opacity }; }); + // Android constants for slide-over layout + const ANDROID_HERO_HEIGHT = 380; + const ANDROID_HEADER_HEIGHT = 70; + + // Android scroll handler - tracks scroll position for parallax + // Must be defined before early returns to maintain consistent hook order + const androidScrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollY.value = event.contentOffset.y; + }, + }); + + // Android hero parallax style - moves slower than scroll for depth effect + const androidHeroParallax = useAnimatedStyle(() => { + const translateY = interpolate( + scrollY.value, + [0, ANDROID_HERO_HEIGHT], + [0, ANDROID_HERO_HEIGHT * 0.3], + Extrapolation.CLAMP + ); + const opacity = interpolate( + scrollY.value, + [0, ANDROID_HERO_HEIGHT * 0.6], + [1, 0], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }], opacity }; + }); + + // Android sticky network header - appears when scrolled past hero + const androidNetworkHeaderStyle = useAnimatedStyle(() => { + // Show when scroll position passes the hero section + const opacity = interpolate( + scrollY.value, + [ANDROID_HERO_HEIGHT - 100, ANDROID_HERO_HEIGHT - 50], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + if (isLoading || !libraryInfo) { return ( + {/* Fixed Header - library name stays at top */} + + + + {libraryInfo.name} + + + {hasRunningJobs ? ( + + + + ) : ( + + )} + {activeJobCount > 0 && ( + + + {activeJobCount > 9 ? "9+" : activeJobCount} + + + )} + + } + /> + ⋯} + /> + + + + {/* Fixed MY NETWORK Header - fades in when scrolled past hero */} + + + MY NETWORK + + + + + {/* Index 0: Hero Section - parallax effect (moves slower) + fades out */} + + {/* Search Bar */} + + + + + {/* Hero Stats - horizontal scroll works naturally here */} + + + + {/* Content Card - overlaps hero, has inline MY NETWORK that scrolls away */} + + {/* Section Header - scrolls away, fixed one fades in to replace it */} + + + MY NETWORK + + + {/* Storage Permission Banner */} + + + + {/* Device Panel */} + + setSelectedLocationId(location?.id || null) + } + /> + + {/* Job Manager Panel */} + + + {/* Action Buttons */} + setShowPairing(true)} + onSetupSync={() => {/* TODO: Open sync setup */}} + onAddStorage={handleAddStorage} + /> + + + + + {/* Pairing Panel */} + setShowPairing(false)} + /> + + {/* Library Switcher Panel */} + setShowLibrarySwitcher(false)} + /> + + ); + } + + // iOS layout with parallax animations return ( {/* Hero Clipping Container - clips hero at page container's top edge */} @@ -539,8 +847,12 @@ export function OverviewScreen() { }} onScroll={scrollHandler} scrollEventThrottle={16} - pointerEvents="box-none" > + {/* Storage Permission Banner (Android only) */} + + + + {/* Device Panel */} @@ -557,7 +869,7 @@ export function OverviewScreen() { setShowPairing(true)} onSetupSync={() => {/* TODO: Open sync setup */}} - onAddStorage={() => {/* TODO: Open location picker */}} + onAddStorage={handleAddStorage} /> diff --git a/apps/mobile/src/screens/overview/components/HeroStats.tsx b/apps/mobile/src/screens/overview/components/HeroStats.tsx index cc0b4889af3c..aba614d40c19 100644 --- a/apps/mobile/src/screens/overview/components/HeroStats.tsx +++ b/apps/mobile/src/screens/overview/components/HeroStats.tsx @@ -201,6 +201,7 @@ export function HeroStats({ onScroll={handleScroll} scrollEventThrottle={16} decelerationRate="fast" + nestedScrollEnabled={true} > {pages.map((pageStats, pageIndex) => ( { Alert.alert( @@ -680,6 +682,31 @@ export function SettingsScreen() { onPress={handleResetData} /> + + {/* Permissions Section (Android only) */} + {Platform.OS === "android" && storagePermission.isRequired && ( + + + } + label="All Files Access" + description={ + storagePermission.isGranted + ? "Permission granted" + : "Tap to grant permission" + } + onPress={storagePermission.openSettings} + /> + + )} {/* Footer */} diff --git a/apps/mobile/src/types/assets.d.ts b/apps/mobile/src/types/assets.d.ts new file mode 100644 index 000000000000..3b7cee62df84 --- /dev/null +++ b/apps/mobile/src/types/assets.d.ts @@ -0,0 +1,16 @@ +// Type declarations for @sd/assets imports + +declare module "@sd/assets/icons/*.png" { + const value: number; + export default value; +} + +declare module "@sd/assets/images/*.png" { + const value: number; + export default value; +} + +declare module "@sd/assets/images/*.jpg" { + const value: number; + export default value; +} diff --git a/apps/mobile/src/types/expo-router.d.ts b/apps/mobile/src/types/expo-router.d.ts new file mode 100644 index 000000000000..54ab20e2e52d --- /dev/null +++ b/apps/mobile/src/types/expo-router.d.ts @@ -0,0 +1,26 @@ +// Type augmentation for expo-router unstable native tabs +// The `name` prop exists at runtime but is missing from types + +declare module "expo-router/unstable-native-tabs" { + import type { ComponentProps } from "react"; + + export interface IconProps { + sf?: string; + name?: string; + } + + export const Icon: React.FC; + export const Label: React.FC<{ children: React.ReactNode }>; + + export interface NativeTabsProps { + backgroundColor?: string | null; + disableTransparentOnScrollEdge?: boolean; + iconColor?: string; + labelStyle?: object; + children?: React.ReactNode; + } + + export const NativeTabs: React.FC & { + Trigger: React.FC<{ name: string; children: React.ReactNode }>; + }; +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 46b391c2fd37..f38eb990887b 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -26,6 +26,7 @@ "expo-env.d.ts" ], "exclude": [ - "node_modules" + "node_modules", + "modules/sd-mobile-core/core/target" ] } diff --git a/bun.lockb b/bun.lockb index 01f4edc65ab9..1e68196689a4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/core/src/domain/device.rs b/core/src/domain/device.rs index e31657022eb2..fa587a069a9b 100644 --- a/core/src/domain/device.rs +++ b/core/src/domain/device.rs @@ -572,81 +572,105 @@ pub struct SystemInfoConfig { /// Detect comprehensive system information using sysinfo fn detect_system_info() -> SystemInfo { - use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System}; - - let mut sys = System::new_with_specifics( - RefreshKind::new() - .with_cpu(CpuRefreshKind::everything()) - .with_memory(MemoryRefreshKind::everything()), - ); - - // Refresh to get accurate data - sys.refresh_cpu_all(); - sys.refresh_memory(); - - // CPU information - let cpu_model = sys - .cpus() - .first() - .map(|cpu| cpu.brand().to_string()) - .filter(|s| !s.is_empty()); - - let cpu_architecture = Some(std::env::consts::ARCH.to_string()); - - let cpu_cores_physical = sys.physical_core_count().map(|c| c as u32); - - let cpu_cores_logical = Some(sys.cpus().len() as u32); - - let cpu_frequency_mhz = sys - .cpus() - .first() - .map(|cpu| cpu.frequency() as i64) - .filter(|&freq| freq > 0); - - // Memory information - let memory_total_bytes = { - let total = sys.total_memory(); - if total > 0 { - Some(total as i64) - } else { - None - } - }; - - let swap_total_bytes = { - let total = sys.total_swap(); - if total > 0 { - Some(total as i64) - } else { - None - } - }; - - // Form factor detection - let form_factor = detect_form_factor(); - - // Manufacturer detection - let manufacturer = detect_manufacturer(); - - // Phase 2: GPU and storage detection - let gpu_models = detect_gpu_models(); - let boot_disk_type = detect_boot_disk_type(); - let boot_disk_capacity_bytes = detect_boot_disk_capacity(); - - SystemInfo { - cpu_model, - cpu_architecture, - cpu_cores_physical, - cpu_cores_logical, - cpu_frequency_mhz, - memory_total_bytes, - swap_total_bytes, - form_factor, - manufacturer, - gpu_models, - boot_disk_type, - boot_disk_capacity_bytes, + // Skip sysinfo on mobile platforms - it was causing crashes on Android + // (likely due to SELinux denying access to /proc files) and is unreliable on iOS. + // TODO: Implement with native APIs (android.os.Build, UIDevice) for richer device info. + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return SystemInfo { + cpu_model: None, + cpu_architecture: Some(std::env::consts::ARCH.to_string()), + cpu_cores_physical: None, + cpu_cores_logical: None, + cpu_frequency_mhz: None, + memory_total_bytes: None, + swap_total_bytes: None, + form_factor: None, + manufacturer: None, + gpu_models: None, + boot_disk_type: None, + boot_disk_capacity_bytes: None, + }; } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System}; + + let mut sys = System::new_with_specifics( + RefreshKind::new() + .with_cpu(CpuRefreshKind::everything()) + .with_memory(MemoryRefreshKind::everything()), + ); + + // Refresh to get accurate data + sys.refresh_cpu_all(); + sys.refresh_memory(); + + // CPU information + let cpu_model = sys + .cpus() + .first() + .map(|cpu| cpu.brand().to_string()) + .filter(|s| !s.is_empty()); + + let cpu_architecture = Some(std::env::consts::ARCH.to_string()); + + let cpu_cores_physical = sys.physical_core_count().map(|c| c as u32); + + let cpu_cores_logical = Some(sys.cpus().len() as u32); + + let cpu_frequency_mhz = sys + .cpus() + .first() + .map(|cpu| cpu.frequency() as i64) + .filter(|&freq| freq > 0); + + // Memory information + let memory_total_bytes = { + let total = sys.total_memory(); + if total > 0 { + Some(total as i64) + } else { + None + } + }; + + let swap_total_bytes = { + let total = sys.total_swap(); + if total > 0 { + Some(total as i64) + } else { + None + } + }; + + // Form factor detection + let form_factor = detect_form_factor(); + + // Manufacturer detection + let manufacturer = detect_manufacturer(); + + // Phase 2: GPU and storage detection + let gpu_models = detect_gpu_models(); + let boot_disk_type = detect_boot_disk_type(); + let boot_disk_capacity_bytes = detect_boot_disk_capacity(); + + SystemInfo { + cpu_model, + cpu_architecture, + cpu_cores_physical, + cpu_cores_logical, + cpu_frequency_mhz, + memory_total_bytes, + swap_total_bytes, + form_factor, + manufacturer, + gpu_models, + boot_disk_type, + boot_disk_capacity_bytes, + } + } // end #[cfg(not(any(target_os = "android", target_os = "ios")))] } /// Public function to detect system info for DeviceConfig diff --git a/core/src/library/manager.rs b/core/src/library/manager.rs index dc699a7ddaa9..2bd7b8cb1005 100644 --- a/core/src/library/manager.rs +++ b/core/src/library/manager.rs @@ -528,6 +528,7 @@ impl LibraryManager { // Create library instance let library = Arc::new(Library { + id: config.id, // Store ID separately for lock-free access path: path.to_path_buf(), config: Arc::new(RwLock::new(config.clone())), core_context: context.clone(), diff --git a/core/src/library/mod.rs b/core/src/library/mod.rs index e34ad06d6b9e..e5c8188c0ec0 100644 --- a/core/src/library/mod.rs +++ b/core/src/library/mod.rs @@ -35,6 +35,9 @@ use uuid::Uuid; /// Represents an open Spacedrive library pub struct Library { + /// Library ID (immutable, stored separately for lock-free access) + id: Uuid, + /// Root directory of the library (the .sdlibrary folder) path: PathBuf, @@ -74,13 +77,9 @@ pub struct Library { } impl Library { - /// Get the library ID + /// Get the library ID (lock-free, stored separately for reliability) pub fn id(&self) -> Uuid { - // Config is immutable for ID, so we can use try_read - self.config.try_read().map(|c| c.id).unwrap_or_else(|_| { - // This should never happen in practice - panic!("Failed to read library config for ID") - }) + self.id } /// Get the library name diff --git a/core/src/ops/locations/add/action.rs b/core/src/ops/locations/add/action.rs index f77b09df11dc..bf84cc54cc84 100644 --- a/core/src/ops/locations/add/action.rs +++ b/core/src/ops/locations/add/action.rs @@ -17,9 +17,52 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tracing::warn; use uuid::Uuid; +/// Safely canonicalize a path, with fallback for paths that can't be fully resolved. +/// This handles cases where the path exists but can't be canonicalized (e.g., on some +/// Android file systems or when intermediate directories have restrictive permissions). +fn safe_canonicalize(path: &Path) -> Result { + // Try full canonicalization first + match path.canonicalize() { + Ok(canonical) => Ok(canonical), + Err(e) => { + // Try partial resolution: canonicalize parent + filename + if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) { + if let Ok(canonical_parent) = parent.canonicalize() { + let partial = canonical_parent.join(name); + warn!( + "Using partially canonicalized path: {} (full canonicalization failed: {})", + partial.display(), + e + ); + return Ok(partial); + } + } + + // If path exists, use it as-is with a warning + if path.exists() { + warn!( + "Using non-canonical path: {} (canonicalization failed: {})", + path.display(), + e + ); + Ok(path.to_path_buf()) + } else { + Err(ActionError::Validation { + field: "path".to_string(), + message: format!("Cannot resolve path: {}", e), + }) + } + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct LocationAddInput { pub path: crate::domain::addressing::SdPath, @@ -60,6 +103,10 @@ impl LibraryAction for LocationAddAction { .map_err(ActionError::device_manager_error)?; // Get device record from database to get the integer ID + // Note: There's a theoretical race condition between this lookup and location creation. + // If the device is deleted between these operations, the location creation will fail + // with a foreign key constraint error. This is an acceptable failure mode as device + // deletion during location creation is extremely rare. let db = library.db().conn(); let device_record = entities::device::Entity::find() .filter(entities::device::Column::Uuid.eq(device_uuid)) @@ -68,6 +115,18 @@ impl LibraryAction for LocationAddAction { .map_err(ActionError::SeaOrm)? .ok_or_else(|| ActionError::DeviceNotFound(device_uuid))?; + // Canonicalize the path to match what was validated + let normalized_path = match &self.input.path { + crate::domain::addressing::SdPath::Physical { device_slug, path } => { + let canonical = safe_canonicalize(path)?; + crate::domain::addressing::SdPath::Physical { + device_slug: device_slug.clone(), + path: canonical, + } + } + other => other.clone(), + }; + // Add the location using LocationManager let location_manager = LocationManager::new(context.events.as_ref().clone()); @@ -91,7 +150,7 @@ impl LibraryAction for LocationAddAction { let (location_id, job_id_string) = location_manager .add_location( library.clone(), - self.input.path.clone(), + normalized_path.clone(), self.input.name.clone(), device_record.id, location_mode, @@ -112,7 +171,7 @@ impl LibraryAction for LocationAddAction { None }; - let mut output = LocationAddOutput::new(location_id, self.input.path, self.input.name); + let mut output = LocationAddOutput::new(location_id, normalized_path, self.input.name); if let Some(job_id) = job_id { output = output.with_job_id(job_id); @@ -137,19 +196,44 @@ impl LibraryAction for LocationAddAction { device_slug: _, path, } => { + // Safely canonicalize the path (handles Android and other edge cases) + let canonical_path = safe_canonicalize(path)?; + // Validate local filesystem path - if !path.exists() { + if !canonical_path.exists() { return Err(ActionError::Validation { field: "path".to_string(), message: "Path does not exist".to_string(), }); } - if !path.is_dir() { + if !canonical_path.is_dir() { return Err(ActionError::Validation { field: "path".to_string(), message: "Path must be a directory".to_string(), }); } + + // Verify read permissions by attempting to read the directory + match tokio::fs::read_dir(&canonical_path).await { + Ok(_) => { + // Can read directory, permissions are sufficient + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + return Err(ActionError::Validation { + field: "path".to_string(), + message: format!( + "Permission denied reading directory: {}", + canonical_path.display() + ), + }); + } + Err(e) => { + return Err(ActionError::Validation { + field: "path".to_string(), + message: format!("Cannot read directory: {}", e), + }); + } + } } SdPath::Cloud { service, diff --git a/core/src/ops/locations/trigger_job/action.rs b/core/src/ops/locations/trigger_job/action.rs index 94f431a942fc..7fec96c21a1d 100644 --- a/core/src/ops/locations/trigger_job/action.rs +++ b/core/src/ops/locations/trigger_job/action.rs @@ -202,7 +202,9 @@ impl LibraryAction for LocationTriggerJobAction { JobType::SpeechToText => { return Err(ActionError::Validation { field: "job_type".to_string(), - message: "Speech-to-text requires FFmpeg and Whisper support which is not enabled".to_string(), + message: + "Speech-to-text requires FFmpeg and Whisper support which is not enabled" + .to_string(), }); } diff --git a/core/src/volume/backend/local.rs b/core/src/volume/backend/local.rs index 130fd868e4f0..bd960046d330 100644 --- a/core/src/volume/backend/local.rs +++ b/core/src/volume/backend/local.rs @@ -176,6 +176,7 @@ impl VolumeBackend for LocalBackend { debug!("LocalBackend::read_dir: {}", full_path.display()); let mut entries = Vec::new(); + let mut skipped_count = 0u32; let mut dir = fs::read_dir(&full_path) .await .map_err(|e| VolumeError::Io(e))?; @@ -183,7 +184,23 @@ impl VolumeBackend for LocalBackend { while let Some(entry) = dir.next_entry().await.map_err(|e| VolumeError::Io(e))? { let metadata = match entry.metadata().await { Ok(m) => m, - Err(_) => continue, // Skip entries we can't read + Err(e) => { + // Log the error instead of silently skipping + // This is particularly important on Android where permission issues + // can cause files to be inaccessible without MANAGE_EXTERNAL_STORAGE + skipped_count += 1; + if skipped_count <= 3 { + // Only log filename (not full path) to avoid information disclosure + // in crash reports and remote logging systems + let filename = entry.file_name(); + tracing::warn!( + "Failed to read metadata for '{}': {} (check storage permissions on Android)", + filename.to_string_lossy(), + e.kind() + ); + } + continue; + } }; let kind = if metadata.is_dir() { @@ -205,6 +222,14 @@ impl VolumeBackend for LocalBackend { }); } + // Log a summary if entries were skipped (without exposing directory path) + if skipped_count > 0 { + tracing::warn!( + "Skipped {} entries while reading directory entries (often permission-related on Android).", + skipped_count + ); + } + Ok(entries) } diff --git a/core/src/volume/detection.rs b/core/src/volume/detection.rs index ac38b53380dd..cfb6b631b719 100644 --- a/core/src/volume/detection.rs +++ b/core/src/volume/detection.rs @@ -41,6 +41,11 @@ pub async fn detect_volumes( volumes.extend(detect_ios_volumes(device_id, config).await?); } + #[cfg(target_os = "android")] + { + volumes.extend(detect_android_volumes(device_id, config).await?); + } + // Enhance volumes with filesystem-specific capabilities enhance_volumes_with_fs_capabilities(&mut volumes).await?; @@ -199,3 +204,14 @@ async fn detect_ios_volumes( debug!("Starting iOS volume detection"); ios::detect_volumes(device_id, config).await } + +#[cfg(target_os = "android")] +async fn detect_android_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + use crate::volume::platform::android; + + debug!("Starting Android volume detection"); + android::detect_volumes(device_id, config).await +} diff --git a/core/src/volume/platform/android.rs b/core/src/volume/platform/android.rs new file mode 100644 index 000000000000..e825a6e6b6b9 --- /dev/null +++ b/core/src/volume/platform/android.rs @@ -0,0 +1,359 @@ +//! Android-specific volume detection using Linux APIs +//! +//! Android apps are sandboxed and can only access their own storage by default. +//! This module detects two types of storage: +//! +//! 1. **App's data directory** (`/data/data/com.spacedrive.app`) - private app storage +//! 2. **External storage** (`/storage/emulated/0`) - user-accessible storage via SAF +//! +//! The external storage path is essential for location creation, as Android's folder +//! picker (Storage Access Framework) returns paths under `/storage/emulated/0/...`. + +use crate::volume::{ + error::VolumeResult, + types::{ + DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig, VolumeFingerprint, + VolumeType, + }, +}; +use std::ffi::CString; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +/// Storage information retrieved from Android filesystem +struct AndroidVolumeInfo { + total_capacity: u64, + available_capacity: u64, + mount_point: PathBuf, +} + +/// Query Android device storage using statvfs +/// +/// Uses the data directory path to query filesystem statistics. +/// This works because Android is Linux-based and exposes statvfs. +fn query_device_storage(data_dir: &std::path::Path) -> Result { + use std::mem::MaybeUninit; + + let path_str = data_dir + .to_str() + .ok_or_else(|| "Invalid data directory path".to_string())?; + + let c_path = CString::new(path_str).map_err(|e| format!("Failed to create CString: {}", e))?; + + let mut stat: MaybeUninit = MaybeUninit::uninit(); + + let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) }; + + if result != 0 { + let errno = std::io::Error::last_os_error(); + return Err(format!("statvfs failed: {}", errno)); + } + + let stat = unsafe { stat.assume_init() }; + + // Calculate capacities + // Total = blocks * block_size + // Available = available_blocks * block_size (for unprivileged users) + let block_size = stat.f_frsize as u64; // Fragment size (actual block size) + let total_capacity = stat.f_blocks as u64 * block_size; + let available_capacity = stat.f_bavail as u64 * block_size; // Available to non-root + + Ok(AndroidVolumeInfo { + total_capacity, + available_capacity, + mount_point: data_dir.to_path_buf(), + }) +} + +/// Get Android device model name +/// +/// Reads from /system/build.prop or uses android.os.Build.MODEL equivalent. +/// Falls back to "Android Device" if unavailable. +fn get_device_name() -> String { + // Try reading device model from system properties + // Format: ro.product.model=Pixel 8a + if let Ok(content) = std::fs::read_to_string("/system/build.prop") { + for line in content.lines() { + if line.starts_with("ro.product.model=") { + if let Some(model) = line.strip_prefix("ro.product.model=") { + let model = model.trim(); + if !model.is_empty() { + return model.to_string(); + } + } + } + } + } + + // Fallback: try /proc/sys/kernel/hostname or just use generic name + if let Ok(hostname) = std::fs::read_to_string("/proc/sys/kernel/hostname") { + let hostname = hostname.trim(); + if !hostname.is_empty() && hostname != "localhost" { + return hostname.to_string(); + } + } + + "Android Device".to_string() +} + +/// Create a Volume struct from storage info +fn create_volume( + storage_info: &AndroidVolumeInfo, + device_id: Uuid, + name: String, + display_name: String, + volume_type: VolumeType, +) -> Volume { + let fingerprint = VolumeFingerprint::from_primary_volume(&storage_info.mount_point, device_id); + let volume_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, fingerprint.0.as_bytes()); + let now = chrono::Utc::now(); + + Volume { + id: volume_id, + fingerprint, + device_id, + name, + library_id: None, + is_tracked: false, + mount_point: storage_info.mount_point.clone(), + mount_points: vec![storage_info.mount_point.clone()], + volume_type, + mount_type: MountType::System, + disk_type: DiskType::SSD, // All Android devices use flash storage + file_system: FileSystem::Ext4, // Android typically uses ext4 or f2fs + total_capacity: storage_info.total_capacity, + available_space: storage_info.available_capacity, + is_read_only: false, + is_mounted: true, + hardware_id: None, + backend: None, + cloud_identifier: None, + cloud_config: None, + apfs_container: None, + container_volume_id: None, + path_mappings: Vec::new(), + is_user_visible: true, + auto_track_eligible: true, + read_speed_mbps: None, + write_speed_mbps: None, + created_at: now, + updated_at: now, + last_seen_at: now, + total_files: None, + total_directories: None, + last_stats_update: None, + display_name: Some(display_name), + is_favorite: false, + color: None, + icon: None, + error_message: None, + } +} + +/// Check if a storage device is removable by examining /sys/block/{device}/removable +fn is_removable_storage(mount_point: &Path) -> bool { + // Try to determine the block device from the mount point + // On Android, we can check /proc/mounts to find the device + if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") { + let mount_str = mount_point.to_string_lossy(); + for line in mounts.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 && parts[1] == mount_str { + let device = parts[0]; + // Extract device name (e.g., /dev/block/vold/public:179,65 -> check sysfs) + if device.contains("/dev/block/") { + // For vold-managed devices, check if they're under /mnt/media_rw (typically removable) + if mount_point.starts_with("/mnt/media_rw") + || mount_point.starts_with("/mnt/usb") + { + return true; + } + // Try to extract the block device name for sysfs check + if let Some(dev_name) = device.split('/').last() { + let sysfs_path = format!("/sys/block/{}/removable", dev_name); + if let Ok(removable) = std::fs::read_to_string(&sysfs_path) { + return removable.trim() == "1"; + } + } + } + } + } + } + // Default to checking path patterns for removable storage + let path_str = mount_point.to_string_lossy(); + // SD cards and USB drives are typically not under /storage/emulated + !path_str.contains("/emulated/") && !path_str.contains("/self/") +} + +/// Detect external volumes (SD cards, USB drives) on Android +fn detect_external_volumes(device_id: Uuid, device_name: &str) -> Vec { + let mut volumes = Vec::new(); + let search_paths = ["/storage", "/mnt/media_rw", "/mnt/usb"]; + + for base_path in &search_paths { + let base = Path::new(base_path); + if !base.exists() { + continue; + } + + let entries = match std::fs::read_dir(base) { + Ok(e) => e, + Err(e) => { + debug!("ANDROID_DETECT: Cannot read {}: {}", base_path, e); + continue; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + let name = match entry.file_name().into_string() { + Ok(n) => n, + Err(_) => continue, + }; + + // Skip emulated and self (already handled as primary storage) + if name == "emulated" || name == "self" { + continue; + } + + // Must be a directory and accessible + if !path.is_dir() { + continue; + } + + // Try to query storage info + match query_device_storage(&path) { + Ok(storage_info) => { + let is_removable = is_removable_storage(&path); + let display_name = if is_removable { + format!("SD Card ({})", name) + } else { + format!("External Storage ({})", name) + }; + + info!( + "ANDROID_DETECT: Found external volume at {} - removable: {}, total: {} bytes", + path.display(), + is_removable, + storage_info.total_capacity + ); + + let mut volume = create_volume( + &AndroidVolumeInfo { + total_capacity: storage_info.total_capacity, + available_capacity: storage_info.available_capacity, + mount_point: path.clone(), + }, + device_id, + name.clone(), + display_name, + VolumeType::External, // Both removable and non-removable external storage + ); + + // Set additional metadata for removable volumes + if is_removable { + volume.disk_type = DiskType::SSD; // SD cards are flash-based + volume.mount_type = MountType::External; + } + + volumes.push(volume); + } + Err(e) => { + debug!( + "ANDROID_DETECT: Cannot query storage at {}: {}", + path.display(), + e + ); + } + } + } + } + + volumes +} + +/// Detect Android device storage volumes +/// +/// Returns volumes representing accessible storage on Android: +/// 1. App's data directory (internal app storage) +/// 2. External storage (/storage/emulated/0) - user-accessible storage via SAF +/// 3. External volumes (SD cards, USB drives) if present +pub async fn detect_volumes( + device_id: Uuid, + _config: &VolumeDetectionConfig, +) -> VolumeResult> { + debug!("ANDROID_DETECT: Starting Android volume detection"); + + let mut volumes = Vec::new(); + let device_name = get_device_name(); + debug!("ANDROID_DETECT: Device name: {}", device_name); + + // 1. App's data directory (internal app storage) + let data_dir = std::env::var("SPACEDRIVE_DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/data/data/com.spacedrive.app")); + + if let Ok(storage_info) = query_device_storage(&data_dir) { + debug!( + "ANDROID_DETECT: App storage query succeeded - total: {} bytes, available: {} bytes", + storage_info.total_capacity, storage_info.available_capacity + ); + volumes.push(create_volume( + &storage_info, + device_id, + "App Storage".to_string(), + "App Storage".to_string(), + VolumeType::Primary, + )); + } else { + debug!("ANDROID_DETECT: Failed to query app data directory, continuing..."); + } + + // 2. External storage (user-accessible storage via SAF/folder picker) + // This is where paths like /storage/emulated/0/Pictures/... live + let external_storage = PathBuf::from("/storage/emulated/0"); + if external_storage.exists() { + match query_device_storage(&external_storage) { + Ok(storage_info) => { + debug!( + "ANDROID_DETECT: External storage query succeeded - total: {} bytes, available: {} bytes", + storage_info.total_capacity, storage_info.available_capacity + ); + volumes.push(create_volume( + &storage_info, + device_id, + device_name.clone(), + "Internal Storage".to_string(), + VolumeType::Primary, + )); + } + Err(e) => { + warn!("ANDROID_DETECT: Failed to query external storage: {}", e); + } + } + } else { + debug!("ANDROID_DETECT: External storage path does not exist"); + } + + // 3. Detect external volumes (SD cards, USB drives) + let external_volumes = detect_external_volumes(device_id, &device_name); + if !external_volumes.is_empty() { + info!( + "ANDROID_DETECT: Found {} external volume(s)", + external_volumes.len() + ); + volumes.extend(external_volumes); + } + + if volumes.is_empty() { + warn!("ANDROID_DETECT: No volumes detected on Android device"); + } else { + debug!( + "ANDROID_DETECT: Successfully detected {} Android volume(s)", + volumes.len() + ); + } + + Ok(volumes) +} diff --git a/core/src/volume/platform/mod.rs b/core/src/volume/platform/mod.rs index 53a3508564f8..b3ad5d29cf9f 100644 --- a/core/src/volume/platform/mod.rs +++ b/core/src/volume/platform/mod.rs @@ -11,3 +11,6 @@ pub mod windows; #[cfg(target_os = "ios")] pub mod ios; + +#[cfg(target_os = "android")] +pub mod android;