Browse Source

fix: improve Android file export with version-aware storage handling

- Add Android version detection from user agent
- Implement tiered storage strategy for Android 10+ restrictions
- Enhance directory creation logic with better error handling
- Add comprehensive fallback chain for file saving
- Improve permission denial handling for graceful degradation
- Add structured logging for better debugging

Resolves "Parent folder doesn't exist" errors on Android 10+
Ensures files are always saved with multiple fallback levels
capacitor-local-save
Matthew Raymer 1 day ago
parent
commit
7c8a6d0666
  1. 323
      package-lock.json
  2. 17
      src/components/BackupFilesList.vue
  3. 396
      src/services/platforms/CapacitorPlatformService.ts

323
package-lock.json

@ -6983,6 +6983,29 @@
"integrity": "sha512-meL9DERHj+fFVWoOX9fXqfcYcSpUfSYJPcFvDPKrxitICbwAoWR+Ut4j5NO9zAT917HUHLQmqzQbAsGNHlDcxQ==",
"license": "Apache-2.0 OR MIT"
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -7839,9 +7862,9 @@
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.3.tgz",
"integrity": "sha512-Vy8DQXCJ21YSAiHxrNBz35VqVlZPpRYm50xRTWRf660JwHuJkFQG8cUkrLzm7AUriqUXxwpkQHcY+b0ibw9ejQ==",
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.80.0.tgz",
"integrity": "sha512-MlScsKAz99zoYghe5Rf5mUqsqz2rMB02640NxtPtBMSHNdGxxRlWu/pp1bFexDa1DYJwyIjnLgt3Z/Y90ikHfw==",
"license": "MIT",
"optional": true,
"peer": true,
@ -7947,20 +7970,20 @@
}
},
"node_modules/@react-native/community-cli-plugin": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.79.3.tgz",
"integrity": "sha512-N/+p4HQqN4yK6IRzn7OgMvUIcrmEWkecglk1q5nj+AzNpfIOzB+mqR20SYmnPfeXF+mZzYCzRANb3KiM+WsSDA==",
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.80.0.tgz",
"integrity": "sha512-uadfVvzZfz5tGpqwslL12i+rELK9m6cLhtqICX0JQvS7Bu12PJwrozhKzEzIYwN9i3wl2dWrKDUr08izt7S9Iw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@react-native/dev-middleware": "0.79.3",
"@react-native/dev-middleware": "0.80.0",
"chalk": "^4.0.0",
"debug": "^2.2.0",
"debug": "^4.4.0",
"invariant": "^2.2.4",
"metro": "^0.82.0",
"metro-config": "^0.82.0",
"metro-core": "^0.82.0",
"metro": "^0.82.2",
"metro-config": "^0.82.2",
"metro-core": "^0.82.2",
"semver": "^7.1.3"
},
"engines": {
@ -7975,25 +7998,97 @@
}
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-frontend": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.80.0.tgz",
"integrity": "sha512-lpu9Z3xtKUaKFvEcm5HSgo1KGfkDa/W3oZHn22Zy0WQ9MiOu2/ar1txgd1wjkoNiK/NethKcRdCN7mqnc6y2mA==",
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/dev-middleware": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.80.0.tgz",
"integrity": "sha512-lLyTnJ687A5jF3fn8yR/undlCis3FG+N/apQ+Q0Lcl+GV6FsZs0U5H28YmL6lZtjOj4TLek6uGPMPmZasHx7cQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@isaacs/ttlcache": "^1.4.1",
"@react-native/debugger-frontend": "0.80.0",
"chrome-launcher": "^0.15.2",
"chromium-edge-launcher": "^0.2.0",
"connect": "^3.6.5",
"debug": "^4.4.0",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"open": "^7.0.3",
"serve-static": "^1.16.2",
"ws": "^6.2.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ms": "2.0.0"
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@react-native/community-cli-plugin/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/ws": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
"integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"async-limiter": "~1.0.0"
}
},
"node_modules/@react-native/debugger-frontend": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.79.3.tgz",
@ -8078,9 +8173,9 @@
}
},
"node_modules/@react-native/gradle-plugin": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.79.3.tgz",
"integrity": "sha512-imfpZLhNBc9UFSzb/MOy2tNcIBHqVmexh/qdzw83F75BmUtLb/Gs1L2V5gw+WI1r7RqDILbWk7gXB8zUllwd+g==",
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.80.0.tgz",
"integrity": "sha512-drmS68rabSMOuDD+YsAY2luNT8br82ycodSDORDqAg7yWQcieHMp4ZUOcdOi5iW+JCqobablT/b6qxcrBg+RaA==",
"license": "MIT",
"optional": true,
"peer": true,
@ -8089,9 +8184,9 @@
}
},
"node_modules/@react-native/js-polyfills": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.79.3.tgz",
"integrity": "sha512-PEBtg6Kox6KahjCAch0UrqCAmHiNLEbp2SblUEoFAQnov4DSxBN9safh+QSVaCiMAwLjvNfXrJyygZz60Dqz3Q==",
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.80.0.tgz",
"integrity": "sha512-dMX7IcBuwghySTgIeK8q03tYz/epg5ScGmJEfBQAciuhzMDMV1LBR/9wwdgD73EXM/133yC5A+TlHb3KQil4Ew==",
"license": "MIT",
"optional": true,
"peer": true,
@ -8108,9 +8203,9 @@
"peer": true
},
"node_modules/@react-native/virtualized-lists": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.3.tgz",
"integrity": "sha512-/0rRozkn+iIHya2vnnvprDgT7QkfI54FLrACAN3BLP7MRlfOIGOrZsXpRLndnLBVnjNzkcre84i1RecjoXnwIA==",
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.80.0.tgz",
"integrity": "sha512-d9zZdPS/ZRexVAkxo1eRp85U7XnnEpXA1ZpSomRKxBuStYKky1YohfEX5YD5MhphemKK24tT7JR4UhaLlmeX8Q==",
"license": "MIT",
"optional": true,
"peer": true,
@ -8874,9 +8969,9 @@
}
},
"node_modules/@stencil/core": {
"version": "4.33.1",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.33.1.tgz",
"integrity": "sha512-12k9xhAJBkpg598it+NRmaYIdEe6TSnsL/v6/KRXDcUyTK11VYwZQej2eHnMWtqot+znJ+GNTqb5YbiXi+5Low==",
"version": "4.35.0",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.35.0.tgz",
"integrity": "sha512-x0IFtj7IJStK+ZqIkhReWbiC0UMjMJnNXV8OXG+DCLDExZaVaxL3MLuq6BJBBcQ1MHZduTHDv3Iz0Zshoj3zjQ==",
"license": "MIT",
"bin": {
"stencil": "bin/stencil"
@ -11184,13 +11279,13 @@
}
},
"node_modules/app-builder-lib/node_modules/minimatch": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
@ -12868,9 +12963,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001722",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001722.tgz",
"integrity": "sha512-DCQHBBZtiK6JVkAGw7drvAMK0Q0POD/xZvEmDp6baiMMP6QXXk9HpD6mNYBZWhOPG6LvIDb82ITqtWjhDckHCA==",
"version": "1.0.30001723",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz",
"integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==",
"devOptional": true,
"funding": [
{
@ -15347,9 +15442,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.166",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.166.tgz",
"integrity": "sha512-QPWqHL0BglzPYyJJ1zSSmwFFL6MFXhbACOCcsCdUMCkzPdS9/OIBVxg516X/Ado2qwAq8k0nJJ7phQPCqiaFAw==",
"version": "1.5.167",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.167.tgz",
"integrity": "sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==",
"devOptional": true,
"license": "ISC"
},
@ -16079,9 +16174,9 @@
}
},
"node_modules/ethers": {
"version": "6.14.3",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.14.3.tgz",
"integrity": "sha512-qq7ft/oCJohoTcsNPFaXSQUm457MA5iWqkf1Mb11ujONdg7jBI6sAOrHaTi3j0CBqIGFSCeR/RMc+qwRRub7IA==",
"version": "6.14.4",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.14.4.tgz",
"integrity": "sha512-Jm/dzRs2Z9iBrT6e9TvGxyb5YVKAPLlpna7hjxH7KH/++DSh2T/JVmQUv7iHI5E55hDbp/gEVvstWYXVxXFzsA==",
"funding": [
{
"type": "individual",
@ -16178,21 +16273,21 @@
"license": "MIT"
},
"node_modules/ethr-did": {
"version": "3.0.37",
"resolved": "https://registry.npmjs.org/ethr-did/-/ethr-did-3.0.37.tgz",
"integrity": "sha512-L9UUhAS8B1T7jTRdKLwAt514lx2UrJebJK7uc6UU4AJ9RhY8Vcfwc93Ux82jREE7yvvqDPXsVNH+lS3aw18a9A==",
"version": "3.0.38",
"resolved": "https://registry.npmjs.org/ethr-did/-/ethr-did-3.0.38.tgz",
"integrity": "sha512-gUxtErXVOQUJf+bmnxRdSJdlU9aFbQSBNaJCYGt+PLqw6l4qqInTfMRiWpwe/brhRtdjE+64tnayOVk8ataeQA==",
"license": "Apache-2.0",
"dependencies": {
"did-jwt": "^8.0.0",
"did-resolver": "^4.1.0",
"ethers": "^6.8.1",
"ethr-did-resolver": "11.0.3"
"ethr-did-resolver": "11.0.4"
}
},
"node_modules/ethr-did-resolver": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/ethr-did-resolver/-/ethr-did-resolver-11.0.3.tgz",
"integrity": "sha512-lQ1T/SZfgR6Kp05/GSIXnMELxQ5H6M6OCTH4wBTVSAgHzbJiDNVIYWzg/c+NniIM88B0ViAi4CaiCHaiUlvPQg==",
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/ethr-did-resolver/-/ethr-did-resolver-11.0.4.tgz",
"integrity": "sha512-EJ/dL2QsFzvhBJd0nlPFjma3bxpQOWyp2TytQZyAeqi6SfZ4ALCB0VaA4dSeT4T8ZtI2pzs/sD7t/7A0584J6Q==",
"license": "Apache-2.0",
"dependencies": {
"did-resolver": "^4.1.0",
@ -24054,9 +24149,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.4",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz",
"integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==",
"version": "8.5.5",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz",
"integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==",
"funding": [
{
"type": "opencollective",
@ -24954,44 +25049,43 @@
"peer": true
},
"node_modules/react-native": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.79.3.tgz",
"integrity": "sha512-EzH1+9gzdyEo9zdP6u7Sh3Jtf5EOMwzy+TK65JysdlgAzfEVfq4mNeXcAZ6SmD+CW6M7ARJbvXLyTD0l2S5rpg==",
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.80.0.tgz",
"integrity": "sha512-b9K1ygb2MWCBtKAodKmE3UsbUuC29Pt4CrJMR0ocTA8k+8HJQTPleBPDNKL4/p0P01QO9aL/gZUddoxHempLow==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.79.3",
"@react-native/codegen": "0.79.3",
"@react-native/community-cli-plugin": "0.79.3",
"@react-native/gradle-plugin": "0.79.3",
"@react-native/js-polyfills": "0.79.3",
"@react-native/normalize-colors": "0.79.3",
"@react-native/virtualized-lists": "0.79.3",
"@react-native/assets-registry": "0.80.0",
"@react-native/codegen": "0.80.0",
"@react-native/community-cli-plugin": "0.80.0",
"@react-native/gradle-plugin": "0.80.0",
"@react-native/js-polyfills": "0.80.0",
"@react-native/normalize-colors": "0.80.0",
"@react-native/virtualized-lists": "0.80.0",
"abort-controller": "^3.0.0",
"anser": "^1.4.9",
"ansi-regex": "^5.0.0",
"babel-jest": "^29.7.0",
"babel-plugin-syntax-hermes-parser": "0.25.1",
"babel-plugin-syntax-hermes-parser": "0.28.1",
"base64-js": "^1.5.1",
"chalk": "^4.0.0",
"commander": "^12.0.0",
"event-target-shim": "^5.0.1",
"flow-enums-runtime": "^0.0.6",
"glob": "^7.1.1",
"invariant": "^2.2.4",
"jest-environment-node": "^29.7.0",
"memoize-one": "^5.0.0",
"metro-runtime": "^0.82.0",
"metro-source-map": "^0.82.0",
"metro-runtime": "^0.82.2",
"metro-source-map": "^0.82.2",
"nullthrows": "^1.1.1",
"pretty-format": "^29.7.0",
"promise": "^8.3.0",
"react-devtools-core": "^6.1.1",
"react-refresh": "^0.14.0",
"regenerator-runtime": "^0.13.2",
"scheduler": "0.25.0",
"scheduler": "0.26.0",
"semver": "^7.1.3",
"stacktrace-parser": "^0.1.10",
"whatwg-fetch": "^3.0.0",
@ -25005,8 +25099,8 @@
"node": ">=18"
},
"peerDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0"
"@types/react": "^19.1.0",
"react": "^19.1.0"
},
"peerDependenciesMeta": {
"@types/react": {
@ -25039,14 +25133,46 @@
"react-native": "*"
}
},
"node_modules/react-native/node_modules/@react-native/codegen": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.80.0.tgz",
"integrity": "sha512-X9TsPgytoUkNrQjzAZh4dXa4AuouvYT0NzYyvnjw1ry4LESCZtKba+eY4x3+M30WPR52zjgu+UFL//14BSdCCA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"glob": "^7.1.1",
"hermes-parser": "0.28.1",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"yargs": "^17.6.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/react-native/node_modules/@react-native/normalize-colors": {
"version": "0.79.3",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.79.3.tgz",
"integrity": "sha512-T75NIQPRFCj6DFMxtcVMJTZR+3vHXaUMSd15t+CkJpc5LnyX91GVaPxpRSAdjFh7m3Yppl5MpdjV/fntImheYQ==",
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.80.0.tgz",
"integrity": "sha512-bJZDSopadjJxMDvysc634eTfLL4w7cAx5diPe14Ez5l+xcKjvpfofS/1Ja14DlgdMJhxGd03MTXlrxoWust3zg==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.28.1.tgz",
"integrity": "sha512-meT17DOuUElMNsL5LZN56d+KBp22hb0EfxWfuPUeoSi54e40v1W4C2V36P75FpsH9fVEfDKpw5Nnkahc8haSsQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"hermes-parser": "0.28.1"
}
},
"node_modules/react-native/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@ -25058,6 +25184,25 @@
"node": ">=18"
}
},
"node_modules/react-native/node_modules/hermes-estree": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.28.1.tgz",
"integrity": "sha512-w3nxl/RGM7LBae0v8LH2o36+8VqwOZGv9rX1wyoWT6YaKZLqpJZ0YQ5P0LVr3tuRpf7vCx0iIG4i/VmBJejxTQ==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/react-native/node_modules/hermes-parser": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.28.1.tgz",
"integrity": "sha512-nf8o+hE8g7UJWParnccljHumE9Vlq8F7MqIdeahl+4x0tvCUJYRrT0L7h0MMg/X9YJmkNwsfbaNNrzPtFXOscg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"hermes-estree": "0.28.1"
}
},
"node_modules/react-native/node_modules/ws": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
@ -25960,15 +26105,15 @@
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz",
"integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==",
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^4.0.1",
"minimatch": "^10.0.0",
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
@ -26010,13 +26155,13 @@
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
@ -26410,9 +26555,9 @@
"license": "ISC"
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT",
"optional": true,
"peer": true
@ -31045,9 +31190,9 @@
}
},
"node_modules/zod": {
"version": "3.25.61",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.61.tgz",
"integrity": "sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ==",
"version": "3.25.64",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.64.tgz",
"integrity": "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

17
src/components/BackupFilesList.vue

@ -268,14 +268,25 @@ export default class BackupFilesList extends Vue {
return true;
} catch (error) {
logger.error('[BackupFilesList] Storage permission denied:', error);
// Get specific guidance for the platform
let guidance = "This app needs permission to access your files to list and restore backups.";
if (typeof platformService.getStoragePermissionGuidance === 'function') {
try {
guidance = await platformService.getStoragePermissionGuidance();
} catch (guidanceError) {
logger.warn('[BackupFilesList] Could not get permission guidance:', guidanceError);
}
}
this.$notify(
{
group: "alert",
type: "danger",
type: "warning",
title: "Storage Permission Required",
text: "This app needs permission to access your files to list and restore backups. Please grant storage permission and try again.",
text: guidance,
},
7000,
10000, // Show for 10 seconds to give user time to read
);
return false;
}

396
src/services/platforms/CapacitorPlatformService.ts

@ -304,7 +304,8 @@ export class CapacitorPlatformService implements PlatformService {
if (
err.message.includes("permission") ||
err.message.includes("access") ||
err.message.includes("denied")
err.message.includes("denied") ||
err.message.includes("User denied storage permission")
) {
logger.log(
"Permission check failed, requesting permissions",
@ -325,6 +326,20 @@ export class CapacitorPlatformService implements PlatformService {
return;
} catch (retryError: unknown) {
const retryErr = retryError as Error;
// If permission is still denied, log it but don't throw an error
// This allows the app to continue with limited functionality
if (retryErr.message.includes("User denied storage permission")) {
logger.warn(
"External storage permissions denied by user, continuing with limited functionality",
JSON.stringify({
error: retryErr.message,
timestamp: new Date().toISOString()
}, null, 2),
);
return; // Don't throw error, just return
}
throw new Error(
`Failed to obtain external storage permissions: ${retryErr.message}`,
);
@ -688,6 +703,113 @@ export class CapacitorPlatformService implements PlatformService {
}
}
/**
* Ensures a directory exists by creating it if necessary.
* This is a workaround for Android's Filesystem.writeFile recursive option not working reliably.
* @param path - The directory path to ensure exists
* @param directory - The base directory type
* @returns Promise that resolves when the directory is ready
*/
private async ensureDirectoryExists(path: string, directory: Directory): Promise<void> {
try {
// Try to read the directory to see if it exists
await Filesystem.readdir({
path,
directory,
});
logger.log("[CapacitorPlatformService] Directory already exists:", {
path,
directory,
timestamp: new Date().toISOString(),
});
} catch (error) {
// Directory doesn't exist, try to create it
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log("[CapacitorPlatformService] Directory doesn't exist, attempting to create:", {
path,
directory,
error: errorMessage,
timestamp: new Date().toISOString(),
});
try {
// For Android 10+, we need to handle storage restrictions differently
if (!this.getCapabilities().isIOS) {
// Try creating the directory by writing a temporary file with recursive=true
const tempFileName = `.temp-${Date.now()}`;
const tempPath = `${path}/${tempFileName}`;
await Filesystem.writeFile({
path: tempPath,
data: "",
directory,
encoding: Encoding.UTF8,
recursive: true,
});
// Clean up the temporary file, leaving the directory
try {
await Filesystem.deleteFile({
path: tempPath,
directory,
});
} catch (deleteError) {
// Ignore delete errors - the directory was created successfully
const deleteErrorMessage = deleteError instanceof Error ? deleteError.message : String(deleteError);
logger.log("[CapacitorPlatformService] Temporary file cleanup failed (non-critical):", {
tempPath,
error: deleteErrorMessage,
timestamp: new Date().toISOString(),
});
}
logger.log("[CapacitorPlatformService] Directory created successfully:", {
path,
directory,
method: "temporary_file",
timestamp: new Date().toISOString(),
});
} else {
// For iOS, use the standard approach
const tempFileName = `.temp-${Date.now()}`;
const tempPath = `${path}/${tempFileName}`;
await Filesystem.writeFile({
path: tempPath,
data: "",
directory,
encoding: Encoding.UTF8,
recursive: true,
});
await Filesystem.deleteFile({
path: tempPath,
directory,
});
logger.log("[CapacitorPlatformService] Directory created successfully:", {
path,
directory,
method: "temporary_file",
timestamp: new Date().toISOString(),
});
}
} catch (createError) {
const createErrorMessage = createError instanceof Error ? createError.message : String(createError);
logger.warn("[CapacitorPlatformService] Failed to create directory, will try without it:", {
path,
directory,
error: createErrorMessage,
timestamp: new Date().toISOString(),
});
// For Android 10+, some directories may not be accessible
// We'll let the calling method handle the fallback
throw createError;
}
}
}
/**
* Saves a file directly to user-accessible storage that persists between installations.
* On Android: Saves to external storage (Downloads or app-specific directory)
@ -707,57 +829,97 @@ export class CapacitorPlatformService implements PlatformService {
encoding: Encoding.UTF8,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents (persistent):", {
uri: result.uri,
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
fileName,
note: "File persists between app installations and is accessible via Files app",
uri: result.uri,
timestamp: new Date().toISOString(),
});
return result.uri;
} else {
// Android: Save to external storage that persists between installations
// Try to save to Downloads first, then fallback to app's external storage
// Android: Check for storage restrictions
const hasRestrictions = await this.hasStorageRestrictions();
const androidVersion = await this.getAndroidVersion();
logger.log("[CapacitorPlatformService] Android storage analysis:", {
hasRestrictions,
androidVersion,
fileName,
timestamp: new Date().toISOString(),
});
if (hasRestrictions) {
// For Android 10+ with storage restrictions, try app-specific external storage first
try {
// Try to save to app's external storage directory
await this.ensureDirectoryExists("TimeSafari", Directory.ExternalStorage);
const result = await Filesystem.writeFile({
path: `TimeSafari/${fileName}`,
data: content,
directory: Directory.ExternalStorage,
encoding: Encoding.UTF8,
});
logger.log("[CapacitorPlatformService] File saved to app external storage (Android 10+):", {
fileName,
uri: result.uri,
androidVersion,
timestamp: new Date().toISOString(),
});
return result.uri;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn("[CapacitorPlatformService] Failed to save to app external storage, trying Downloads:", {
fileName,
error: errorMessage,
androidVersion,
timestamp: new Date().toISOString(),
});
}
}
// Try Downloads directory (works on older Android versions)
try {
// Attempt to save to Downloads directory (most accessible)
const downloadsPath = `Download/TimeSafari/${fileName}`;
await this.ensureDirectoryExists("Download/TimeSafari", Directory.ExternalStorage);
const result = await Filesystem.writeFile({
path: downloadsPath,
path: `Download/TimeSafari/${fileName}`,
data: content,
directory: Directory.ExternalStorage, // External storage (persists between installations)
directory: Directory.ExternalStorage,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to Android Downloads (persistent):", {
uri: result.uri,
logger.log("[CapacitorPlatformService] File saved to Downloads (Android):", {
fileName,
downloadsPath,
note: "File persists between app installations and is accessible via file managers",
uri: result.uri,
androidVersion,
timestamp: new Date().toISOString(),
});
return result.uri;
} catch (downloadsError) {
logger.warn("[CapacitorPlatformService] Could not save to Downloads, using app external storage:", downloadsError);
// Fallback: Save to app's external storage directory
const appStoragePath = `TimeSafari/${fileName}`;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn("[CapacitorPlatformService] Could not save to Downloads, using app external storage:", {
fileName,
error: errorMessage,
androidVersion,
timestamp: new Date().toISOString(),
});
// Final fallback: app's external storage without subdirectory
const result = await Filesystem.writeFile({
path: appStoragePath,
path: fileName,
data: content,
directory: Directory.ExternalStorage, // External storage (persists between installations)
directory: Directory.ExternalStorage,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to Android app external storage (persistent):", {
uri: result.uri,
logger.log("[CapacitorPlatformService] File saved to external storage (fallback):", {
fileName,
appStoragePath,
note: "File persists between app installations and is accessible via file managers",
uri: result.uri,
androidVersion,
timestamp: new Date().toISOString(),
});
@ -765,8 +927,30 @@ export class CapacitorPlatformService implements PlatformService {
}
}
} catch (error) {
logger.error("[CapacitorPlatformService] Save to persistent storage failed:", error);
throw new Error(`Failed to save to persistent storage: ${error}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("[CapacitorPlatformService] Save to persistent storage failed:", {
fileName,
error: errorMessage,
timestamp: new Date().toISOString(),
});
// Final fallback: app's data directory (not user-accessible but guaranteed to work)
logger.warn("[CapacitorPlatformService] Attempting final fallback to app data directory");
const result = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
logger.log("[CapacitorPlatformService] File saved to app data directory (fallback):", {
fileName,
uri: result.uri,
timestamp: new Date().toISOString(),
});
return result.uri;
}
}
@ -823,6 +1007,7 @@ export class CapacitorPlatformService implements PlatformService {
} else {
// Android: List files from persistent storage locations
const allFiles: Array<{name: string, uri: string, size?: number}> = [];
// Try to list files from Downloads/TimeSafari directory
try {
const downloadsResult = await Filesystem.readdir({
@ -840,8 +1025,14 @@ export class CapacitorPlatformService implements PlatformService {
}));
allFiles.push(...downloadFiles);
} catch (downloadsError) {
logger.warn("[CapacitorPlatformService] Could not read Downloads/TimeSafari directory:", downloadsError);
const err = downloadsError as Error;
if (err.message.includes("User denied storage permission")) {
logger.warn("[CapacitorPlatformService] Storage permission denied for Downloads/TimeSafari, skipping");
} else {
logger.warn("[CapacitorPlatformService] Could not read Downloads/TimeSafari directory:", downloadsError);
}
}
// Try to list files from app's external storage directory
try {
const appStorageResult = await Filesystem.readdir({
@ -859,8 +1050,14 @@ export class CapacitorPlatformService implements PlatformService {
}));
allFiles.push(...appStorageFiles);
} catch (appStorageError) {
logger.warn("[CapacitorPlatformService] Could not read app external storage directory:", appStorageError);
const err = appStorageError as Error;
if (err.message.includes("User denied storage permission")) {
logger.warn("[CapacitorPlatformService] Storage permission denied for TimeSafari, skipping");
} else {
logger.warn("[CapacitorPlatformService] Could not read app external storage directory:", appStorageError);
}
}
// Remove duplicates based on filename
const uniqueFiles = allFiles.filter((file, index, self) =>
index === self.findIndex(f => f.name === file.name)
@ -873,6 +1070,11 @@ export class CapacitorPlatformService implements PlatformService {
return uniqueFiles;
}
} catch (error) {
const err = error as Error;
if (err.message.includes("User denied storage permission")) {
logger.warn("[CapacitorPlatformService] Storage permission denied, returning empty file list");
return [];
}
logger.error("[CapacitorPlatformService] Failed to list user accessible files:", error);
return [];
}
@ -1294,6 +1496,9 @@ export class CapacitorPlatformService implements PlatformService {
// Try to save to the user-selected directory
try {
// Ensure the selected directory exists before writing
await this.ensureDirectoryExists(directoryResult.path, Directory.ExternalStorage);
const result = await Filesystem.writeFile({
path: fullPath,
data: content,
@ -1817,7 +2022,12 @@ export class CapacitorPlatformService implements PlatformService {
}));
allFiles.push(...appStorageFiles);
} catch (error) {
logger.warn("[CapacitorPlatformService] Could not read TimeSafari external storage:", error);
const err = error as Error;
if (err.message.includes("User denied storage permission")) {
logger.warn("[CapacitorPlatformService] Storage permission denied for TimeSafari, skipping");
} else {
logger.warn("[CapacitorPlatformService] Could not read TimeSafari external storage:", error);
}
}
// 2. Common user-chosen locations (if accessible) with recursive search
@ -1903,7 +2113,12 @@ export class CapacitorPlatformService implements PlatformService {
}
}
} catch (subDirError) {
logger.warn(`[CapacitorPlatformService] Could not read subdirectory ${path}/${fileName}:`, subDirError);
const subDirErr = subDirError as Error;
if (subDirErr.message.includes("User denied storage permission")) {
logger.warn(`[CapacitorPlatformService] Storage permission denied for subdirectory ${path}/${fileName}, skipping`);
} else {
logger.warn(`[CapacitorPlatformService] Could not read subdirectory ${path}/${fileName}:`, subDirError);
}
}
} else {
// Check if file matches backup criteria
@ -1932,9 +2147,13 @@ export class CapacitorPlatformService implements PlatformService {
});
allFiles.push(...relevantFiles);
}
} catch (error) {
// Silently skip inaccessible directories
logger.debug(`[CapacitorPlatformService] Could not access ${path}:`, error);
} catch (pathError) {
const pathErr = pathError as Error;
if (pathErr.message.includes("User denied storage permission")) {
logger.warn(`[CapacitorPlatformService] Storage permission denied for ${path}, skipping`);
} else {
logger.warn(`[CapacitorPlatformService] Could not read ${path}:`, pathError);
}
}
}
}
@ -2277,8 +2496,113 @@ export class CapacitorPlatformService implements PlatformService {
return entries;
} catch (error) {
const err = error as Error;
if (err.message.includes("User denied storage permission")) {
logger.warn("[CapacitorPlatformService] Storage permission denied for directory listing, returning empty array:", { path, error: err.message });
return [];
}
logger.error("[CapacitorPlatformService] Failed to list directory:", { path, error });
return [];
}
}
/**
* Provides user guidance when storage permissions are denied
* @returns Promise resolving to guidance message
*/
async getStoragePermissionGuidance(): Promise<string> {
try {
if (this.getCapabilities().isIOS) {
return "On iOS, files are saved to the Documents folder and can be accessed via the Files app. No additional permissions are required.";
} else {
// For Android, provide guidance on how to grant permissions
return "To access your backup files, please grant storage permissions:\n\n" +
"1. Go to your device Settings\n" +
"2. Find 'Apps' or 'Application Manager'\n" +
"3. Find 'TimeSafari' in the list\n" +
"4. Tap 'Permissions'\n" +
"5. Enable 'Storage' permission\n" +
"6. Return to the app and try again\n\n" +
"Alternatively, you can save files to your app's private storage which doesn't require external storage permissions.";
}
} catch (error) {
return "Unable to provide permission guidance. Please check your device settings for app permissions.";
}
}
/**
* Tests directory creation functionality to ensure the ensureDirectoryExists method works correctly.
* @returns Promise resolving to a test result message
*/
async testDirectoryCreation(): Promise<string> {
try {
logger.log("[CapacitorPlatformService] Testing directory creation functionality");
if (this.getCapabilities().isIOS) {
return "✅ Directory creation test not needed on iOS - using Documents directory";
}
// Test creating the Downloads/TimeSafari directory
await this.ensureDirectoryExists("Download/TimeSafari", Directory.ExternalStorage);
// Test creating the TimeSafari directory
await this.ensureDirectoryExists("TimeSafari", Directory.ExternalStorage);
// Test creating a nested directory structure
await this.ensureDirectoryExists("Download/TimeSafari/Test", Directory.ExternalStorage);
logger.log("[CapacitorPlatformService] Directory creation tests completed successfully");
return "✅ Directory creation tests passed successfully";
} catch (error) {
const err = error as Error;
logger.error("[CapacitorPlatformService] Directory creation test failed:", error);
return `❌ Directory creation test failed: ${err.message}`;
}
}
/**
* Detects the Android version to handle storage restrictions appropriately
* @returns Promise resolving to Android version number or null if not Android
*/
private async getAndroidVersion(): Promise<number | null> {
try {
if (!this.getCapabilities().isIOS) {
// For Android, we can try to detect version from user agent
// This is a fallback since we don't have the Device plugin
const userAgent = navigator.userAgent;
const androidMatch = userAgent.match(/Android\s+(\d+)/);
if (androidMatch) {
const version = parseInt(androidMatch[1], 10);
logger.log("[CapacitorPlatformService] Android version detected from user agent:", {
version,
userAgent: userAgent.substring(0, 100) + "...",
timestamp: new Date().toISOString(),
});
return version;
}
// If we can't detect from user agent, assume Android 10+ for safety
logger.log("[CapacitorPlatformService] Could not detect Android version, assuming 10+ for safety");
return 10;
}
return null;
} catch (error) {
logger.warn("[CapacitorPlatformService] Could not detect Android version:", {
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
return null;
}
}
/**
* Checks if the current Android version has storage restrictions
* @returns Promise resolving to true if storage restrictions apply
*/
private async hasStorageRestrictions(): Promise<boolean> {
const androidVersion = await this.getAndroidVersion();
// Android 10 (API 29) and above have stricter storage restrictions
return androidVersion !== null && androidVersion >= 29;
}
}

Loading…
Cancel
Save