Browse Source

feat: implement user-accessible file storage with improved share dialog handling

- Add timeout mechanism for share dialogs to prevent hanging (15s timeout)
- Implement centralized share dialog handling with robust error handling
- Update file storage to use app's external storage (accessible via file managers)
- Add directory picker functionality for custom save locations
- Add listUserAccessibleFiles method to show saved files
- Add testListUserFiles functionality for debugging
- Improve logging and error handling throughout file operations
- Ensure compatibility with Android 11+ storage restrictions

Files are now saved to:
- Android: /storage/emulated/0/Android/data/app.timesafari.app/files/TimeSafari/
- iOS: /Documents/ (accessible via Files app)

Users can access files through file managers, app's file listing,
or use share dialog to save to Downloads folder.
capacitor-local-save
Matthew Raymer 3 days ago
parent
commit
2d516b90b0
  1. 254
      package-lock.json
  2. 8
      src/components/DataExportSection.vue
  3. 19
      src/libs/capacitor/app.ts
  4. 33
      src/services/PlatformService.ts
  5. 615
      src/services/platforms/CapacitorPlatformService.ts
  6. 72
      src/services/platforms/ElectronPlatformService.ts
  7. 77
      src/services/platforms/WebPlatformService.ts
  8. 143
      src/views/TestView.vue

254
package-lock.json

@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "0.4.8",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "0.4.8",
"version": "0.5.1",
"dependencies": {
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
@ -3835,9 +3835,9 @@
}
},
"node_modules/@electron/asar/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4524,9 +4524,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6686,9 +6686,9 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -8217,9 +8217,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz",
"integrity": "sha512-gldmAyS9hpj+H6LpRNlcjQWbuKUtb94lodB9uCz71Jm+7BxK1VIOo7y62tZZwxhA7j1ylv/yQz080L5WkS+LoQ==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz",
"integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==",
"cpu": [
"arm"
],
@ -8231,9 +8231,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.42.0.tgz",
"integrity": "sha512-bpRipfTgmGFdCZDFLRvIkSNO1/3RGS74aWkJJTFJBH7h3MRV4UijkaEUeOMbi9wxtxYmtAbVcnMtHTPBhLEkaw==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz",
"integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==",
"cpu": [
"arm64"
],
@ -8271,9 +8271,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.42.0.tgz",
"integrity": "sha512-fJcN4uSGPWdpVmvLuMtALUFwCHgb2XiQjuECkHT3lWLZhSQ3MBQ9pq+WoWeJq2PrNxr9rPM1Qx+IjyGj8/c6zQ==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz",
"integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==",
"cpu": [
"arm64"
],
@ -8285,9 +8285,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.42.0.tgz",
"integrity": "sha512-CziHfyzpp8hJpCVE/ZdTizw58gr+m7Y2Xq5VOuCSrZR++th2xWAz4Nqk52MoIIrV3JHtVBhbBsJcAxs6NammOQ==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz",
"integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==",
"cpu": [
"x64"
],
@ -8299,9 +8299,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.42.0.tgz",
"integrity": "sha512-UsQD5fyLWm2Fe5CDM7VPYAo+UC7+2Px4Y+N3AcPh/LdZu23YcuGPegQly++XEVaC8XUTFVPscl5y5Cl1twEI4A==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz",
"integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==",
"cpu": [
"arm"
],
@ -8313,9 +8313,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.42.0.tgz",
"integrity": "sha512-/i8NIrlgc/+4n1lnoWl1zgH7Uo0XK5xK3EDqVTf38KvyYgCU/Rm04+o1VvvzJZnVS5/cWSd07owkzcVasgfIkQ==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz",
"integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==",
"cpu": [
"arm"
],
@ -8353,9 +8353,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.42.0.tgz",
"integrity": "sha512-O8AplvIeavK5ABmZlKBq9/STdZlnQo7Sle0LLhVA7QT+CiGpNVe197/t8Aph9bhJqbDVGCHpY2i7QyfEDDStDg==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz",
"integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==",
"cpu": [
"loong64"
],
@ -8367,9 +8367,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.42.0.tgz",
"integrity": "sha512-6Qb66tbKVN7VyQrekhEzbHRxXXFFD8QKiFAwX5v9Xt6FiJ3BnCVBuyBxa2fkFGqxOCSGGYNejxd8ht+q5SnmtA==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz",
"integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==",
"cpu": [
"ppc64"
],
@ -8381,9 +8381,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.42.0.tgz",
"integrity": "sha512-KQETDSEBamQFvg/d8jajtRwLNBlGc3aKpaGiP/LvEbnmVUKlFta1vqJqTrvPtsYsfbE/DLg5CC9zyXRX3fnBiA==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz",
"integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==",
"cpu": [
"riscv64"
],
@ -8395,9 +8395,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.42.0.tgz",
"integrity": "sha512-qMvnyjcU37sCo/tuC+JqeDKSuukGAd+pVlRl/oyDbkvPJ3awk6G6ua7tyum02O3lI+fio+eM5wsVd66X0jQtxw==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz",
"integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==",
"cpu": [
"riscv64"
],
@ -8409,9 +8409,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.42.0.tgz",
"integrity": "sha512-I2Y1ZUgTgU2RLddUHXTIgyrdOwljjkmcZ/VilvaEumtS3Fkuhbw4p4hgHc39Ypwvo2o7sBFNl2MquNvGCa55Iw==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz",
"integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==",
"cpu": [
"s390x"
],
@ -8462,9 +8462,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.42.0.tgz",
"integrity": "sha512-F+5J9pelstXKwRSDq92J0TEBXn2nfUrQGg+HK1+Tk7VOL09e0gBqUHugZv7SW4MGrYj41oNCUe3IKCDGVlis2g==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz",
"integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==",
"cpu": [
"ia32"
],
@ -12199,9 +12199,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -12648,9 +12648,9 @@
}
},
"node_modules/cacache/node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -12868,9 +12868,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001721",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz",
"integrity": "sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==",
"version": "1.0.30001722",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001722.tgz",
"integrity": "sha512-DCQHBBZtiK6JVkAGw7drvAMK0Q0POD/xZvEmDp6baiMMP6QXXk9HpD6mNYBZWhOPG6LvIDb82ITqtWjhDckHCA==",
"devOptional": true,
"funding": [
{
@ -14931,9 +14931,9 @@
}
},
"node_modules/dir-compare/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -15865,9 +15865,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -17617,9 +17617,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -19127,9 +19127,9 @@
}
},
"node_modules/jake/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -25514,9 +25514,9 @@
}
},
"node_modules/replace/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -26094,9 +26094,9 @@
}
},
"node_modules/rollup": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.42.0.tgz",
"integrity": "sha512-LW+Vse3BJPyGJGAJt1j8pWDKPd73QM8cRXYK1IxOBgL2AGLu7Xd2YOW0M2sLUBCkF5MshXXtMApyEAEzMVMsnw==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz",
"integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -26110,33 +26110,33 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.42.0",
"@rollup/rollup-android-arm64": "4.42.0",
"@rollup/rollup-darwin-arm64": "4.42.0",
"@rollup/rollup-darwin-x64": "4.42.0",
"@rollup/rollup-freebsd-arm64": "4.42.0",
"@rollup/rollup-freebsd-x64": "4.42.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.42.0",
"@rollup/rollup-linux-arm-musleabihf": "4.42.0",
"@rollup/rollup-linux-arm64-gnu": "4.42.0",
"@rollup/rollup-linux-arm64-musl": "4.42.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.42.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.42.0",
"@rollup/rollup-linux-riscv64-gnu": "4.42.0",
"@rollup/rollup-linux-riscv64-musl": "4.42.0",
"@rollup/rollup-linux-s390x-gnu": "4.42.0",
"@rollup/rollup-linux-x64-gnu": "4.42.0",
"@rollup/rollup-linux-x64-musl": "4.42.0",
"@rollup/rollup-win32-arm64-msvc": "4.42.0",
"@rollup/rollup-win32-ia32-msvc": "4.42.0",
"@rollup/rollup-win32-x64-msvc": "4.42.0",
"@rollup/rollup-android-arm-eabi": "4.43.0",
"@rollup/rollup-android-arm64": "4.43.0",
"@rollup/rollup-darwin-arm64": "4.43.0",
"@rollup/rollup-darwin-x64": "4.43.0",
"@rollup/rollup-freebsd-arm64": "4.43.0",
"@rollup/rollup-freebsd-x64": "4.43.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.43.0",
"@rollup/rollup-linux-arm-musleabihf": "4.43.0",
"@rollup/rollup-linux-arm64-gnu": "4.43.0",
"@rollup/rollup-linux-arm64-musl": "4.43.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.43.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.43.0",
"@rollup/rollup-linux-riscv64-gnu": "4.43.0",
"@rollup/rollup-linux-riscv64-musl": "4.43.0",
"@rollup/rollup-linux-s390x-gnu": "4.43.0",
"@rollup/rollup-linux-x64-gnu": "4.43.0",
"@rollup/rollup-linux-x64-musl": "4.43.0",
"@rollup/rollup-win32-arm64-msvc": "4.43.0",
"@rollup/rollup-win32-ia32-msvc": "4.43.0",
"@rollup/rollup-win32-x64-msvc": "4.43.0",
"fsevents": "~2.3.2"
}
},
"node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.42.0.tgz",
"integrity": "sha512-JxHtA081izPBVCHLKnl6GEA0w3920mlJPLh89NojpU2GsBSB6ypu4erFg/Wx1qbpUbepn0jY4dVWMGZM8gplgA==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz",
"integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==",
"cpu": [
"arm64"
],
@ -26148,9 +26148,9 @@
]
},
"node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.42.0.tgz",
"integrity": "sha512-rv5UZaWVIJTDMyQ3dCEK+m0SAn6G7H3PRc2AZmExvbDvtaDc+qXkei0knQWcI3+c9tEs7iL/4I4pTQoPbNL2SA==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz",
"integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==",
"cpu": [
"x64"
],
@ -26162,9 +26162,9 @@
]
},
"node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.42.0.tgz",
"integrity": "sha512-eoujJFOvoIBjZEi9hJnXAbWg+Vo1Ov8n/0IKZZcPZ7JhBzxh2A+2NFyeMZIRkY9iwBvSjloKgcvnjTbGKHE44Q==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz",
"integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==",
"cpu": [
"arm64"
],
@ -26176,9 +26176,9 @@
]
},
"node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.42.0.tgz",
"integrity": "sha512-/3NrcOWFSR7RQUQIuZQChLND36aTU9IYE4j+TB40VU78S+RA0IiqHR30oSh6P1S9f9/wVOenHQnacs/Byb824g==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz",
"integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==",
"cpu": [
"arm64"
],
@ -26190,9 +26190,9 @@
]
},
"node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.42.0.tgz",
"integrity": "sha512-Gfm6cV6mj3hCUY8TqWa63DB8Mx3NADoFwiJrMpoZ1uESbK8FQV3LXkhfry+8bOniq9pqY1OdsjFWNsSbfjPugw==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz",
"integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==",
"cpu": [
"x64"
],
@ -26204,9 +26204,9 @@
]
},
"node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.42.0.tgz",
"integrity": "sha512-g86PF8YZ9GRqkdi0VoGlcDUb4rYtQKyTD1IVtxxN4Hpe7YqLBShA7oHMKU6oKTCi3uxwW4VkIGnOaH/El8de3w==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz",
"integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==",
"cpu": [
"x64"
],
@ -26218,9 +26218,9 @@
]
},
"node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.42.0.tgz",
"integrity": "sha512-+axkdyDGSp6hjyzQ5m1pgcvQScfHnMCcsXkx8pTgy/6qBmWVhtRVlgxjWwDp67wEXXUr0x+vD6tp5W4x6V7u1A==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz",
"integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==",
"cpu": [
"arm64"
],
@ -26232,9 +26232,9 @@
]
},
"node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz",
"integrity": "sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==",
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz",
"integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==",
"cpu": [
"x64"
],
@ -26424,9 +26424,9 @@
"license": "MIT"
},
"node_modules/sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/secp256k1": {
@ -28495,9 +28495,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"optional": true,
"peer": true,
@ -31045,9 +31045,9 @@
}
},
"node_modules/zod": {
"version": "3.25.58",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.58.tgz",
"integrity": "sha512-DVLmMQzSZwNYzQoMaM3MQWnxr2eq+AtM9Hx3w1/Yl0pH8sLTSjN4jGP7w6f7uand6Hw44tsnSu1hz1AOA6qI2Q==",
"version": "3.25.61",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.61.tgz",
"integrity": "sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

8
src/components/DataExportSection.vue

@ -162,13 +162,13 @@ export default class DataExportSection extends Vue {
downloadAnchor.click();
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
} else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory with enhanced options
// Native platform: Write to user-accessible location and share
const result = await this.platformService.writeAndShareFile(
fileName,
jsonStr,
{
allowLocationSelection: true,
saveToDownloads: false,
showLocationSelectionDialog: true,
mimeType: "application/json"
}
);
@ -188,9 +188,9 @@ export default class DataExportSection extends Vue {
title: "Export Successful",
text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup."
: "The backup file has been saved.",
: "Backup saved to multiple locations. Use the share dialog to access your file and choose where to save it permanently.",
},
3000,
5000,
);
} catch (error) {
logger.error("Export Error:", error);

19
src/libs/capacitor/app.ts

@ -12,6 +12,14 @@ import type { PluginListenerHandle } from "@capacitor/core";
* Supports 'backButton' and 'appUrlOpen' events from Capacitor
*/
interface AppInterface {
/**
* Force exit the app. This should only be used in conjunction with the `backButton` handler for Android to
* exit the app when navigation is complete.
*
* @returns Promise that resolves when the app has been exited
*/
exitApp(): Promise<void>;
/**
* Add listener for back button events
* @param eventName - Must be 'backButton'
@ -38,8 +46,19 @@ interface AppInterface {
/**
* App wrapper for Capacitor functionality
* Provides type-safe event listeners for back button and URL open events
* and app exit functionality
*/
export const App: AppInterface = {
/**
* Force exit the app. This should only be used in conjunction with the `backButton` handler for Android to
* exit the app when navigation is complete.
*
* @returns Promise that resolves when the app has been exited
*/
exitApp(): Promise<void> {
return CapacitorApp.exitApp();
},
addListener(
eventName: "backButton" | "appUrlOpen",
listenerFunc: BackButtonListener | ((data: AppLaunchUrl) => void),

33
src/services/PlatformService.ts

@ -74,7 +74,10 @@ export interface PlatformService {
options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
}
): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }>;
@ -92,6 +95,36 @@ export interface PlatformService {
*/
listFiles(directory: string): Promise<string[]>;
/**
* Tests the file sharing functionality by creating and sharing a test file.
* @returns Promise resolving to a test result message
*/
testFileSharing(): Promise<string>;
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
testFileSaveOnly(): Promise<string>;
/**
* Tests the location selection functionality using the file picker.
* @returns Promise resolving to a test result message
*/
testLocationSelection(): Promise<string>;
/**
* Tests location selection without showing the dialog (restores original behavior).
* @returns Promise resolving to a test result message
*/
testLocationSelectionSilent(): Promise<string>;
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
testListUserFiles(): Promise<string>;
// Camera operations
/**
* Activates the device camera to take a picture.

615
src/services/platforms/CapacitorPlatformService.ts

@ -6,6 +6,7 @@ import {
CameraDirection,
} from "@capacitor/camera";
import { Share } from "@capacitor/share";
import { FilePicker } from "@capawesome/capacitor-file-picker";
import {
SQLiteConnection,
SQLiteDBConnection,
@ -271,22 +272,24 @@ export class CapacitorPlatformService implements PlatformService {
);
if (this.getCapabilities().isIOS) {
// iOS uses different permission model
// iOS uses different permission model - Documents directory is accessible
logger.log("iOS platform - Documents directory is accessible by default");
return;
}
// Try to access a test directory to check permissions
// For Android, try to access external storage to check permissions
try {
await Filesystem.stat({
path: "/storage/emulated/0/Download",
directory: Directory.Documents,
// Try to list files in external storage to check permissions
await Filesystem.readdir({
path: ".",
directory: Directory.External,
});
logger.log(
"Storage permissions already granted",
"External storage permissions already granted",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
return;
} catch (error: unknown) {
} catch (error) {
const err = error as Error;
const errorLogData = {
error: {
@ -297,19 +300,11 @@ export class CapacitorPlatformService implements PlatformService {
timestamp: new Date().toISOString(),
};
// "File does not exist" is expected and not a permission error
if (err.message === "File does not exist") {
logger.log(
"Directory does not exist (expected), proceeding with write",
JSON.stringify(errorLogData, null, 2),
);
return;
}
// Check for actual permission errors
if (
err.message.includes("permission") ||
err.message.includes("access")
err.message.includes("access") ||
err.message.includes("denied")
) {
logger.log(
"Permission check failed, requesting permissions",
@ -319,45 +314,33 @@ export class CapacitorPlatformService implements PlatformService {
// The Filesystem plugin will automatically request permissions when needed
// We just need to try the operation again
try {
await Filesystem.stat({
path: "/storage/emulated/0/Download",
directory: Directory.Documents,
await Filesystem.readdir({
path: ".",
directory: Directory.External,
});
logger.log(
"Storage permissions granted after request",
"External storage permissions granted after request",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
return;
} catch (retryError: unknown) {
const retryErr = retryError as Error;
throw new Error(
`Failed to obtain storage permissions: ${retryErr.message}`,
`Failed to obtain external storage permissions: ${retryErr.message}`,
);
}
}
// For any other error, log it but don't treat as permission error
logger.log(
"Unexpected error during permission check",
"Unexpected error during permission check, proceeding anyway",
JSON.stringify(errorLogData, null, 2),
);
return;
}
} catch (error: unknown) {
const err = error as Error;
const errorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
logger.error(
"Error checking/requesting permissions",
JSON.stringify(errorLogData, null, 2),
);
throw new Error(`Failed to obtain storage permissions: ${err.message}`);
} catch (error) {
logger.error("Error in checkStoragePermissions:", error);
throw error;
}
}
@ -382,8 +365,8 @@ export class CapacitorPlatformService implements PlatformService {
* Enhanced file save and share functionality with location selection.
*
* Provides multiple options for saving files:
* 1. Save to app-private storage and share (current behavior)
* 2. Save to device Downloads folder (Android) or Documents (iOS)
* 1. Save to user-accessible storage (Downloads/Documents) and share (default behavior)
* 2. Save to app-private storage and share (for sensitive data)
* 3. Allow user to choose save location via file picker
* 4. Direct share without saving locally
*
@ -398,7 +381,10 @@ export class CapacitorPlatformService implements PlatformService {
options: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
} = {}
): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }> {
const timestamp = new Date().toISOString();
@ -420,15 +406,11 @@ export class CapacitorPlatformService implements PlatformService {
// Determine save strategy based on options and platform
if (options.allowLocationSelection) {
// Use file picker to let user choose location
fileUri = await this.saveFileWithPicker(fileName, content, options.mimeType);
saved = true;
} else if (options.saveToDownloads) {
// Save directly to Downloads folder
fileUri = await this.saveToDownloads(fileName, content);
// Use enhanced location selection with multiple save options
fileUri = await this.saveWithLocationOptions(fileName, content, options.mimeType, options.showLocationSelectionDialog);
saved = true;
} else {
// Fallback to app-private storage (current behavior)
} else if (options.saveToPrivateStorage) {
// Save to app-private storage (for sensitive data)
const result = await Filesystem.writeFile({
path: fileName,
data: content,
@ -438,6 +420,10 @@ export class CapacitorPlatformService implements PlatformService {
});
fileUri = result.uri;
saved = true;
} else {
// Default: Save to user-accessible location (Downloads/Documents)
fileUri = await this.saveToDownloads(fileName, content);
saved = true;
}
logger.log("[CapacitorPlatformService] File write successful:", {
@ -446,20 +432,53 @@ export class CapacitorPlatformService implements PlatformService {
timestamp: new Date().toISOString(),
});
// Share the file
// Share the file (unless explicitly disabled)
let shared = false;
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your backup file.",
url: fileUri,
dialogTitle: "Share your backup file",
});
if (options.showShareDialog !== false && !options.allowLocationSelection) {
try {
logger.log("[CapacitorPlatformService] Starting share operation:", {
uri: fileUri,
timestamp: new Date().toISOString(),
});
// Share the file with improved timeout handling
const shareResult = await this.handleShareDialog({
title: "TimeSafari Backup",
text: "Here is your backup file.",
url: fileUri,
dialogTitle: "Share your backup file",
});
shared = true;
logger.log("[CapacitorPlatformService] Share dialog completed successfully:", {
activityType: shareResult?.activityType,
timestamp: new Date().toISOString(),
});
} catch (shareError) {
const shareErr = shareError as Error;
logger.warn("[CapacitorPlatformService] Share operation completed (may have been cancelled or timed out):", {
error: shareErr.message,
timestamp: new Date().toISOString(),
});
// Check if it's a user cancellation, timeout, or other expected completion
if (shareErr.message.includes("cancel") ||
shareErr.message.includes("dismiss") ||
shareErr.message.includes("timeout") ||
shareErr.message.includes("Share dialog timeout")) {
logger.log("[CapacitorPlatformService] Share dialog completed (cancelled/timeout) - this is expected behavior");
// Don't treat cancellation/timeout as an error, file is still saved
// The dialog should close automatically on timeout
} else {
// Log other share errors but don't fail the operation
logger.error("[CapacitorPlatformService] Unexpected share error:", shareErr);
}
}
} else if (options.allowLocationSelection) {
// Location selection already handled the sharing, mark as shared
shared = true;
logger.log("[CapacitorPlatformService] File shared successfully");
} catch (shareError) {
logger.warn("[CapacitorPlatformService] Share failed, but file was saved:", shareError);
// Don't throw error if sharing fails, file is still saved
logger.log("[CapacitorPlatformService] Location selection handled sharing");
}
return { saved, uri: fileUri, shared };
@ -479,7 +498,59 @@ export class CapacitorPlatformService implements PlatformService {
}
/**
* Saves a file using the file picker to let user choose location.
* Handles share dialog with improved timeout and dismissal handling.
* @param shareOptions - Options for the share dialog
* @param timeoutMs - Timeout in milliseconds (default: 15 seconds)
* @returns Promise that resolves when share operation completes
*/
private async handleShareDialog(shareOptions: any, timeoutMs: number = 15000): Promise<any> {
try {
logger.log("[CapacitorPlatformService] Starting share dialog with timeout:", {
timeoutMs,
timestamp: new Date().toISOString(),
});
const sharePromise = Share.share(shareOptions);
// Add timeout to prevent hanging
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
logger.warn("[CapacitorPlatformService] Share dialog timeout reached - forcing completion");
reject(new Error("Share dialog timeout - forcing completion"));
}, timeoutMs);
});
// Race between share completion and timeout
const result = await Promise.race([sharePromise, timeoutPromise]);
logger.log("[CapacitorPlatformService] Share dialog completed successfully:", {
activityType: result?.activityType,
timestamp: new Date().toISOString(),
});
return result;
} catch (error) {
const err = error as Error;
// Check if it's an expected completion (timeout, cancellation, etc.)
if (err.message.includes("timeout") ||
err.message.includes("cancel") ||
err.message.includes("dismiss")) {
logger.log("[CapacitorPlatformService] Share dialog completed (expected):", {
reason: err.message,
timestamp: new Date().toISOString(),
});
// Return a success result even for timeout/cancellation
return { activityType: "timeout_or_cancellation" };
} else {
// Re-throw unexpected errors
throw error;
}
}
}
/**
* Saves a file using the native share dialog to let user choose save location.
* @param fileName - Name of the file to save
* @param content - File content
* @param mimeType - MIME type of the file
@ -491,29 +562,135 @@ export class CapacitorPlatformService implements PlatformService {
mimeType: string = "application/json"
): Promise<string> {
try {
// For now, fallback to regular save since file picker save API is complex
// Save to app-private storage and let user share to choose location
const result = await Filesystem.writeFile({
path: fileName,
logger.log("[CapacitorPlatformService] Using native share dialog for save location selection:", {
fileName,
mimeType,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// First, save the file to a temporary location
const tempFileName = `temp_${Date.now()}_${fileName}`;
const tempResult = await Filesystem.writeFile({
path: tempFileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
logger.log("[CapacitorPlatformService] File saved to temp location:", {
tempUri: tempResult.uri,
timestamp: new Date().toISOString(),
});
// Use the native share dialog which includes "Save to Files" options
// This allows users to choose where to save the file using the system's file manager
await this.handleShareDialog({
title: "Save TimeSafari File",
text: `Save ${fileName} to your preferred location`,
url: tempResult.uri,
dialogTitle: "Choose where to save your file",
});
logger.log("[CapacitorPlatformService] Native share dialog completed for location selection");
// Return the temp URI - the user can save it wherever they want via the share dialog
// The share dialog will provide options like "Save to Files", "Copy to Downloads", etc.
return tempResult.uri;
} catch (error) {
logger.error("[CapacitorPlatformService] Native share dialog for location selection failed:", error);
throw new Error(`Failed to open location selection dialog: ${error}`);
}
}
/**
* Provides multiple save location options for the user to choose from.
* @param fileName - Name of the file to save
* @param content - File content
* @param mimeType - MIME type of the file
* @param showDialog - Whether to show the location selection dialog
* @returns Promise resolving to the saved file URI
*/
private async saveWithLocationOptions(
fileName: string,
content: string,
mimeType: string = "application/json",
showDialog: boolean = true
): Promise<string> {
try {
logger.log("[CapacitorPlatformService] Providing save location options:", {
fileName,
mimeType,
showDialog,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Save to multiple locations and let user choose via share dialog
const locations = [];
// Save to Documents (iOS) or Downloads (Android)
const primaryLocation = await this.saveToDownloads(fileName, content);
locations.push(primaryLocation);
// Save to app data directory as backup
const backupFileName = `backup_${Date.now()}_${fileName}`;
const backupResult = await Filesystem.writeFile({
path: backupFileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
locations.push(backupResult.uri);
logger.log("[CapacitorPlatformService] File saved to app storage for picker fallback:", {
uri: result.uri,
logger.log("[CapacitorPlatformService] File saved to multiple locations:", {
primaryLocation,
backupLocation: backupResult.uri,
timestamp: new Date().toISOString(),
});
return result.uri;
// Use share dialog to let user choose which location to use (if enabled)
if (showDialog) {
try {
await this.handleShareDialog({
title: "TimeSafari File Saved",
text: `Your file has been saved to multiple locations. Choose where to access it.`,
url: primaryLocation,
dialogTitle: "Access your saved file",
});
logger.log("[CapacitorPlatformService] Share dialog completed for location selection");
} catch (shareError) {
const shareErr = shareError as Error;
logger.warn("[CapacitorPlatformService] Share dialog failed, but file was saved:", {
error: shareErr.message,
timestamp: new Date().toISOString(),
});
// Check if it's a user cancellation or timeout
if (shareErr.message.includes("cancel") ||
shareErr.message.includes("dismiss") ||
shareErr.message.includes("timeout")) {
logger.log("[CapacitorPlatformService] User cancelled location selection share dialog or timeout occurred");
} else {
logger.error("[CapacitorPlatformService] Location selection share error:", shareErr);
}
}
} else {
logger.log("[CapacitorPlatformService] Location selection dialog disabled, file saved silently");
}
// Return the primary location (user can access others via share dialog)
return primaryLocation;
} catch (error) {
logger.error("[CapacitorPlatformService] File picker save failed:", error);
throw new Error(`Failed to save file with picker: ${error}`);
logger.error("[CapacitorPlatformService] Save with location options failed:", error);
throw new Error(`Failed to save with location options: ${error}`);
}
}
/**
* Saves a file directly to the Downloads folder (Android) or Documents (iOS).
* These locations are user-accessible through file managers and the app.
* @param fileName - Name of the file to save
* @param content - File content
* @returns Promise resolving to the saved file URI
@ -521,22 +698,43 @@ export class CapacitorPlatformService implements PlatformService {
private async saveToDownloads(fileName: string, content: string): Promise<string> {
try {
if (this.getCapabilities().isIOS) {
// iOS: Save to Documents directory
// iOS: Save to Documents directory (user accessible)
const result = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Documents,
encoding: Encoding.UTF8,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
uri: result.uri,
fileName,
timestamp: new Date().toISOString(),
});
return result.uri;
} else {
// Android: Save to Downloads directory
// Android: Save to app's external storage (accessible via file managers)
// Due to Android 11+ restrictions, we can't directly write to public Downloads
// Users can access files through file managers or use share dialog to save to Downloads
const downloadsPath = `TimeSafari/${fileName}`;
const result = await Filesystem.writeFile({
path: fileName,
path: downloadsPath,
data: content,
directory: Directory.External,
directory: Directory.External, // App's external storage (accessible via file managers)
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to Android external storage:", {
uri: result.uri,
fileName,
downloadsPath,
note: "File is accessible via file managers. Use share dialog to save to Downloads.",
timestamp: new Date().toISOString(),
});
return result.uri;
}
} catch (error) {
@ -573,6 +771,175 @@ export class CapacitorPlatformService implements PlatformService {
);
}
/**
* Lists user-accessible files saved by the app.
* Returns files from Downloads (Android) or Documents (iOS) directories.
* @returns Promise resolving to array of file information
*/
async listUserAccessibleFiles(): Promise<Array<{name: string, uri: string, size?: number}>> {
try {
if (this.getCapabilities().isIOS) {
// iOS: List files in Documents directory
const result = await Filesystem.readdir({
path: ".",
directory: Directory.Documents,
});
return result.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
}));
} else {
// Android: List files in app's external storage (TimeSafari subdirectory)
try {
const result = await Filesystem.readdir({
path: "TimeSafari",
directory: Directory.External,
});
return result.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
}));
} catch (downloadsError) {
logger.warn("[CapacitorPlatformService] Could not read external storage directory:", downloadsError);
return [];
}
}
} catch (error) {
logger.error("[CapacitorPlatformService] Failed to list user accessible files:", error);
return [];
}
}
/**
* Tests the file sharing functionality by creating and sharing a test file.
* @returns Promise resolving to a test result message
*/
async testFileSharing(): Promise<string> {
try {
const testContent = {
message: "This is a test file for TimeSafari file sharing",
timestamp: new Date().toISOString(),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
test: true
};
const fileName = `timesafari-test-${Date.now()}.json`;
const content = JSON.stringify(testContent, null, 2);
const result = await this.writeAndShareFile(fileName, content, {
mimeType: "application/json"
});
if (result.saved) {
return `✅ File saved successfully! URI: ${result.uri}. Shared: ${result.shared ? 'Yes' : 'No'}`;
} else {
return `❌ File save failed: ${result.error}`;
}
} catch (error) {
const err = error as Error;
return `❌ Test failed: ${err.message}`;
}
}
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
async testFileSaveOnly(): Promise<string> {
try {
const testContent = {
message: "This is a test file saved without sharing",
timestamp: new Date().toISOString(),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
test: true,
saveOnly: true
};
const fileName = `timesafari-save-only-${Date.now()}.json`;
const content = JSON.stringify(testContent, null, 2);
const result = await this.writeAndShareFile(fileName, content, {
mimeType: "application/json",
showShareDialog: false
});
if (result.saved) {
return `✅ File saved successfully without sharing! URI: ${result.uri}`;
} else {
return `❌ File save failed: ${result.error}`;
}
} catch (error) {
const err = error as Error;
return `❌ Test failed: ${err.message}`;
}
}
/**
* Tests the location selection functionality using the file picker.
* @returns Promise resolving to a test result message
*/
async testLocationSelection(): Promise<string> {
try {
const testContent = {
message: "This is a test file for location selection",
timestamp: new Date().toISOString(),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
test: true,
locationSelection: true
};
const fileName = `timesafari-location-test-${Date.now()}.json`;
const content = JSON.stringify(testContent, null, 2);
// Use the FilePicker to let user choose where to save the file
const result = await this.saveFileWithLocationPicker(fileName, content, "application/json");
return `✅ Location selection test successful! File saved using directory picker. URI: ${result}`;
} catch (error) {
const err = error as Error;
return `❌ Location selection test failed: ${err.message}`;
}
}
/**
* Tests location selection without showing the dialog (restores original behavior).
* @returns Promise resolving to a test result message
*/
async testLocationSelectionSilent(): Promise<string> {
try {
const testContent = {
message: "This is a test file for silent location selection",
timestamp: new Date().toISOString(),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
test: true,
locationSelection: true,
silent: true
};
const fileName = `timesafari-silent-location-test-${Date.now()}.json`;
const content = JSON.stringify(testContent, null, 2);
const result = await this.writeAndShareFile(fileName, content, {
allowLocationSelection: true,
showLocationSelectionDialog: false,
mimeType: "application/json"
});
if (result.saved) {
return `✅ Silent location selection test successful! File saved to multiple locations without showing dialog. URI: ${result.uri}`;
} else {
return `❌ Silent location selection test failed: ${result.error}`;
}
} catch (error) {
const err = error as Error;
return `❌ Silent location selection test failed: ${err.message}`;
}
}
/**
* Opens the device camera to take a picture.
* Configures camera for high quality images with editing enabled.
@ -752,4 +1119,104 @@ export class CapacitorPlatformService implements PlatformService {
throw new Error(`Failed to save file: ${err.message}`);
}
}
/**
* Saves a file using the FilePicker to let user choose the save location.
* This provides true location selection rather than using a share dialog.
* @param fileName - Name of the file to save
* @param content - File content
* @param mimeType - MIME type of the file
* @returns Promise resolving to the saved file URI
*/
private async saveFileWithLocationPicker(
fileName: string,
content: string,
mimeType: string = "application/json"
): Promise<string> {
try {
logger.log("[CapacitorPlatformService] Using FilePicker pickDirectory for location selection:", {
fileName,
mimeType,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Use FilePicker to let user choose a directory to save the file
const directoryResult = await FilePicker.pickDirectory();
logger.log("[CapacitorPlatformService] User selected directory:", {
selectedPath: directoryResult.path,
timestamp: new Date().toISOString(),
});
// Save the file to the selected directory
const fullPath = `${directoryResult.path}/${fileName}`;
// Try to save to the user-selected directory
try {
const result = await Filesystem.writeFile({
path: fullPath,
data: content,
directory: Directory.ExternalStorage, // Use external storage for user-selected location
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to selected directory:", {
finalUri: result.uri,
fullPath,
timestamp: new Date().toISOString(),
});
return result.uri;
} catch (writeError) {
logger.warn("[CapacitorPlatformService] Failed to write to selected directory, trying alternative:", writeError);
// Fallback: Save to Downloads with the selected filename
const downloadsResult = await this.saveToDownloads(fileName, content);
logger.log("[CapacitorPlatformService] File saved to Downloads as fallback:", {
finalUri: downloadsResult,
originalPath: fullPath,
timestamp: new Date().toISOString(),
});
return downloadsResult;
}
} catch (error) {
logger.error("[CapacitorPlatformService] FilePicker directory selection failed:", error);
// If directory picker fails, fallback to Downloads
try {
logger.log("[CapacitorPlatformService] Falling back to Downloads directory");
const fallbackResult = await this.saveToDownloads(fileName, content);
return fallbackResult;
} catch (fallbackError) {
throw new Error(`Failed to select directory with FilePicker and fallback failed: ${error}`);
}
}
}
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
async testListUserFiles(): Promise<string> {
try {
const files = await this.listUserAccessibleFiles();
if (files.length === 0) {
return `📁 No user-accessible files found. Try saving some files first using the other test buttons.`;
}
const fileList = files.map(file =>
`- ${file.name} (${file.size ? `${file.size} bytes` : 'size unknown'})`
).join('\n');
return `📁 Found ${files.length} user-accessible file(s):\n${fileList}`;
} catch (error) {
const err = error as Error;
return `❌ Failed to list user files: ${err.message}`;
}
}
}

72
src/services/platforms/ElectronPlatformService.ts

@ -205,6 +205,7 @@ export class ElectronPlatformService implements PlatformService {
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
isNativeApp: true,
};
}
@ -234,11 +235,27 @@ export class ElectronPlatformService implements PlatformService {
* Writes content to a file and opens the system share dialog.
* @param _fileName - Name of the file to create
* @param _content - Content to write to the file
* @param _options - Options for file saving behavior
* @throws Error with "Not implemented" message
* @todo Implement using Electron's dialog and file system APIs
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("Not implemented");
async writeAndShareFile(
_fileName: string,
_content: string,
_options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
}
): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }> {
return {
saved: false,
shared: false,
error: "Not implemented in Electron platform"
};
}
/**
@ -284,6 +301,17 @@ export class ElectronPlatformService implements PlatformService {
throw new Error("Not implemented");
}
/**
* Should rotate the camera between front and back cameras.
* @returns Promise that resolves when the camera is rotated
* @throws Error with "Not implemented" message
* @todo Implement camera rotation using Electron's media APIs
*/
async rotateCamera(): Promise<void> {
logger.error("rotateCamera not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should handle deep link URLs for the desktop application.
* @param _url - The deep link URL to handle
@ -345,4 +373,44 @@ export class ElectronPlatformService implements PlatformService {
);
}
}
/**
* Tests the file sharing functionality.
* @returns Promise resolving to a test result message
*/
async testFileSharing(): Promise<string> {
return "File sharing not available in Electron platform - not implemented";
}
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
async testFileSaveOnly(): Promise<string> {
return "File save only not available in Electron platform - not implemented";
}
/**
* Tests the location selection functionality using the file picker.
* @returns Promise resolving to a test result message
*/
async testLocationSelection(): Promise<string> {
return "Location selection not available in Electron platform - not implemented";
}
/**
* Tests location selection without showing the dialog (restores original behavior).
* @returns Promise resolving to a test result message
*/
async testLocationSelectionSilent(): Promise<string> {
return "Location selection not available in Electron platform - not implemented";
}
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
async testListUserFiles(): Promise<string> {
return "File listing not available in Electron platform - not implemented";
}
}

77
src/services/platforms/WebPlatformService.ts

@ -29,10 +29,13 @@ export class WebPlatformService implements PlatformService {
return {
hasFileSystem: false,
hasCamera: true, // Through file input with capture
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
),
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: true,
needsFileHandlingInstructions: false,
isNativeApp: false,
};
}
@ -356,10 +359,26 @@ export class WebPlatformService implements PlatformService {
* Not supported in web platform.
* @param _fileName - Unused fileName parameter
* @param _content - Unused content parameter
* @throws Error indicating file system access is not available
* @param _options - Unused options parameter
* @returns Promise that resolves to a failure result
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform");
async writeAndShareFile(
_fileName: string,
_content: string,
_options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
}
): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }> {
return {
saved: false,
shared: false,
error: "File system access not available in web platform"
};
}
/**
@ -390,4 +409,54 @@ export class WebPlatformService implements PlatformService {
.query(sql, params)
.then((result: QueryExecResult[]) => result[0]?.values[0]);
}
/**
* Tests the file sharing functionality.
* @returns Promise resolving to a test result message
*/
async testFileSharing(): Promise<string> {
return "File sharing not available in web platform - use download instead";
}
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
async testFileSaveOnly(): Promise<string> {
return "File saving not available in web platform - use download instead";
}
/**
* Tests the location selection functionality using the file picker.
* @returns Promise resolving to a test result message
*/
async testLocationSelection(): Promise<string> {
return "Location selection not available in web platform - use download instead";
}
/**
* Tests location selection without showing the dialog (restores original behavior).
* @returns Promise resolving to a test result message
*/
async testLocationSelectionSilent(): Promise<string> {
return "Location selection not available in web platform - use download instead";
}
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
async testListUserFiles(): Promise<string> {
return "File listing not available in web platform - files are downloaded directly";
}
/**
* Rotates the camera between front and back cameras.
* Not supported in web platform.
* @returns Promise that resolves immediately
*/
async rotateCamera(): Promise<void> {
// Not supported in web platform
return Promise.resolve();
}
}

143
src/views/TestView.vue

@ -215,6 +215,46 @@
</div>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">File Sharing Test</h2>
Test the new file sharing functionality that saves to user-accessible locations.
<div>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testFileSharing()"
>
Test File Sharing
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testFileSaveOnly()"
>
Test Save Only (No Share Dialog)
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testLocationSelection()"
>
Test Location Selection
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testLocationSelectionSilent()"
>
Test Silent Location Selection
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testListUserFiles()"
>
List User Files
</button>
<div v-if="fileSharingResult" class="mt-2 p-2 bg-gray-100 rounded">
<strong>Result:</strong> {{ fileSharingResult }}
</div>
</div>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
@ -387,6 +427,9 @@ export default class Help extends Vue {
sqlQuery = "";
sqlResult: unknown = null;
// for file sharing test
fileSharingResult = "";
cryptoLib = cryptoLib;
async mounted() {
@ -620,5 +663,105 @@ export default class Help extends Vue {
);
}
}
public async testFileSharing() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testFileSharing();
this.fileSharingResult = result;
logger.log("File Sharing Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("File Sharing Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "File Sharing Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testFileSaveOnly() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testFileSaveOnly();
this.fileSharingResult = result;
logger.log("File Save Only Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("File Save Only Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "File Save Only Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testLocationSelection() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testLocationSelection();
this.fileSharingResult = result;
logger.log("Location Selection Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("Location Selection Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Location Selection Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testLocationSelectionSilent() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testLocationSelectionSilent();
this.fileSharingResult = result;
logger.log("Silent Location Selection Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("Silent Location Selection Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Silent Location Selection Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testListUserFiles() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testListUserFiles();
this.fileSharingResult = result;
logger.log("List User Files Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("List User Files Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "List User Files Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
}
</script>

Loading…
Cancel
Save