Compare commits

..

13 Commits

Author SHA1 Message Date
ca455e9593 modify files to make the ios build & distribution work 2025-04-18 20:40:41 -06:00
5ada70b05e fix the reference to the secrets file 2025-04-15 21:24:03 -06:00
Matthew Raymer
4f9b146a66 fix: improve file sharing on Android using app-private storage
- Replace direct file writing with app-private storage + share dialog
- Add Share plugin for cross-platform file sharing
- Update file paths configuration for Android
- Fix permission issues by using Directory.Data instead of Documents
- Simplify file export flow in DataExportSection
2025-04-11 07:13:07 +00:00
Matthew Raymer
2b638ce2a7 chore: add an android build script to simplify creation of versions 2025-04-10 10:55:40 +00:00
Matthew Raymer
0b528af2a6 WIP: Fix Android file writing permissions and path handling
- Refactor writeFile method to properly handle Android Storage Access Framework (SAF) URIs
- Update path construction for Android to use Download directory with correct permissions
- Enhance error handling and logging throughout file operations
- Add detailed logging for debugging file system operations
- Fix permission checking logic to handle "File does not exist" case correctly
- Improve error messages and stack traces in logs
- Add timestamp to all log entries for better debugging
- Use proper directory types (ExternalStorage for Android, Documents for iOS)
- Add UTF-8 encoding specification for file writes

This is a work in progress as we're still seeing permission issues with Android file writing.
2025-04-09 13:05:42 +00:00
Matthew Raymer
008211bc21 feat(android): implement file picker for data export
- Add @capawesome/capacitor-file-picker dependency
- Update DataExportSection UI text to reflect new file picker behavior
- Implement file picker in CapacitorPlatformService
- Add debug logging for path handling
- Fix logger to show messages in Capacitor environment

WIP: File path handling still needs refinement
2025-04-09 10:04:03 +00:00
Matthew Raymer
6955a36458 chore: clean up lock file 2025-04-09 08:25:20 +00:00
Matthew Raymer
ba079ea983 chore: remove generated index.html from git repo 2025-04-09 07:53:01 +00:00
Matthew Raymer
d7b3c5ec9d fix: remove last "any" lint messages 2025-04-09 07:37:54 +00:00
Matthew Raymer
d83a25f47e chore: linted with auto-fix 2025-04-09 07:17:21 +00:00
Matthew Raymer
fb40dc0ff7 feat(android): update Capacitor assets and fix Xcode project version
- Generate new launcher icons for all densities (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)
- Update both regular and round icon variants
- Fix Xcode project version numbers (0920 -> 920)
- Add missing file provider paths meta-data
2025-04-09 07:10:30 +00:00
Matthew Raymer
d03fa55001 refactor(platform): replace platform checks with capability-based system
- Add PlatformCapabilities interface to define available features
- Remove isWeb(), isCapacitor(), isElectron(), isPyWebView() methods
- Update platform services to implement getCapabilities()
- Refactor DataExportSection to use capability checks instead of platform checks
- Improve platform abstraction and separation of concerns
- Make platform-specific logic more maintainable and extensible

This change decouples components from specific platform implementations,
making the codebase more maintainable and easier to extend with new platforms.
2025-04-08 11:29:39 +00:00
Matthew Raymer
c8eff4d39e chore(deps): update React Native and Metro dependencies
- Update react-native from 0.78.2 to 0.79.0
- Update metro and related packages from 0.81.4 to 0.82.1
- Update @react-native/virtualized-lists to 0.79.0
- Update @react-native/normalize-colors to 0.79.0
- Update @react-native/codegen to 0.79.0
- Remove unused dependencies and their related code
- Update debug configuration to use v4.4.0 instead of v2.6.9
- Update sudo-prompt to 9.1.1
- Update synckit to 0.11.3
- Gradle updated from 8.9.0 to 8.9.1

This update improves build stability and removes deprecated dependencies.
2025-04-08 11:14:23 +00:00
47 changed files with 943 additions and 1211 deletions

View File

@@ -215,6 +215,9 @@ Prerequisites: Android Studio with SDK installed
rm -rf dist
npm run build:web
npm run build:capacitor
cd android
./gradlew clean
./gradlew assembleDebug
```
2. Update Android project with latest build:

View File

@@ -1,2 +1,2 @@
#Fri Mar 21 07:27:50 UTC 2025
gradle.version=8.2.1
#Wed Apr 09 09:01:13 UTC 2025
gradle.version=8.11.1

Binary file not shown.

View File

@@ -14,7 +14,7 @@ project.ext.MY_KEY_PASSWORD = System.getenv('ANDROID_KEY_PASSWORD') ?: ""
// If no environment variables, try to load from secrets file
if (!project.ext.MY_KEYSTORE_FILE) {
def secretsPropertiesFile = rootProject.file("gradle.properties.secrets")
def secretsPropertiesFile = rootProject.file("app/gradle.properties.secrets")
if (secretsPropertiesFile.exists()) {
Properties secretsProperties = new Properties()
secretsProperties.load(new FileInputStream(secretsPropertiesFile))
@@ -31,7 +31,7 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9
versionCode 10
versionName "0.4.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {

View File

@@ -12,6 +12,8 @@ dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-camera')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-file-picker')
}

View File

@@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("app.timesafari", appContext.getPackageName());
assertEquals("app.timesafari.app", appContext.getPackageName());
}
}

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -8,7 +7,6 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
@@ -16,7 +14,6 @@
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -28,7 +25,6 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
</activity>
<provider
@@ -36,13 +32,13 @@
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
</manifest>

View File

@@ -10,5 +10,13 @@
{
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
}
]

View File

@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<title>TimeSafari</title>
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script>
</head>
<body>
<noscript>
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -2,4 +2,5 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
<files-path name="my_files" path="." />
</paths>

View File

@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.9.0'
classpath 'com.android.tools.build:gradle:8.9.1'
classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong

View File

@@ -10,3 +10,9 @@ project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/c
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')

27
ios/.gitignore vendored
View File

@@ -1,23 +1,16 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
*.xcuserstate
App/Pods
App/*.xcodeproj/xcuserdata/
App/*.xcworkspace/xcuserdata/
App/*/public
# Generated Config files
App/*/capacitor.config.json
App/*/config.xml
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
# User-specific Xcode files
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
DerivedData

View File

@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
2BC611FE3D7967BDB623FF21 /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0C2082015AEE6A0776A3EAB /* Pods_App.framework */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
@@ -14,21 +15,22 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* Time Safari.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Time Safari.app"; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
821226CEE4D47A540167CC8F /* Pods-Time Safari.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Time Safari.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Time Safari/Pods-Time Safari.debug.xcconfig"; sourceTree = "<group>"; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
E0C2082015AEE6A0776A3EAB /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
EF03C3F99471948925ED5AC3 /* Pods-Time Safari.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Time Safari.release.xcconfig"; path = "Pods/Target Support Files/Pods-Time Safari/Pods-Time Safari.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -37,7 +39,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
2BC611FE3D7967BDB623FF21 /* Pods_App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -47,7 +49,7 @@
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
E0C2082015AEE6A0776A3EAB /* Pods_App.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -65,7 +67,7 @@
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* Time Safari.app */,
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
@@ -90,6 +92,8 @@
children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
821226CEE4D47A540167CC8F /* Pods-Time Safari.debug.xcconfig */,
EF03C3F99471948925ED5AC3 /* Pods-Time Safari.release.xcconfig */,
);
name = Pods;
sourceTree = "<group>";
@@ -97,9 +101,9 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* Time Safari */ = {
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
@@ -111,9 +115,9 @@
);
dependencies = (
);
name = "Time Safari";
productName = App;
productReference = 504EC3041FED79650016851F /* Time Safari.app */;
name = App;
productName = "Time Safari";
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -122,8 +126,8 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -132,7 +136,7 @@
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */;
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
@@ -141,13 +145,11 @@
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* Time Safari */,
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
@@ -348,14 +350,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = GM3FS5JQPH;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.4.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -371,14 +371,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = GM3FS5JQPH;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.4.4;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -390,7 +388,7 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */ = {
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
@@ -399,7 +397,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */ = {
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,

View File

@@ -2,7 +2,7 @@
<Workspace
version = "1.0">
<FileRef
location = "group:Time Safari.xcodeproj">
location = "group:App.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -22,6 +22,10 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Upload photos and scan friends' QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Upload photos for gifts</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -45,5 +49,16 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array></dict>
</plist>

View File

@@ -12,6 +12,10 @@ def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
end
target 'App' do

View File

@@ -1,28 +1,52 @@
PODS:
- Capacitor (6.2.0):
- Capacitor (6.2.1):
- CapacitorCordova
- CapacitorApp (6.0.2):
- Capacitor
- CapacitorCordova (6.2.0)
- CapacitorCamera (6.1.2):
- Capacitor
- CapacitorCordova (6.2.1)
- CapacitorFilesystem (6.0.3):
- Capacitor
- CapacitorShare (6.0.3):
- Capacitor
- CapawesomeCapacitorFilePicker (6.2.0):
- Capacitor
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
EXTERNAL SOURCES:
Capacitor:
:path: "../../node_modules/@capacitor/ios"
CapacitorApp:
:path: "../../node_modules/@capacitor/app"
CapacitorCamera:
:path: "../../node_modules/@capacitor/camera"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorFilesystem:
:path: "../../node_modules/@capacitor/filesystem"
CapacitorShare:
:path: "../../node_modules/@capacitor/share"
CapawesomeCapacitorFilePicker:
:path: "../../node_modules/@capawesome/capacitor-file-picker"
SPEC CHECKSUMS:
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8
PODFILE CHECKSUM: 1e9280368fd410520414f5741bf8fdfe7847b965
COCOAPODS: 1.16.2

View File

@@ -1,22 +0,0 @@
default_platform(:ios)
platform :ios do
desc "Build and deploy iOS app"
lane :beta do
build_ios_app(
scheme: "App",
workspace: "App.xcworkspace",
export_method: "app-store"
)
upload_to_testflight
end
lane :release do
build_ios_app(
scheme: "App",
workspace: "App.xcworkspace",
export_method: "app-store"
)
upload_to_app_store
end
end

878
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"build:web": "vite build --config vite.config.web.mts",
"electron:dev": "npm run build && electron dist-electron",
"electron:start": "electron dist-electron",
"build:android": "rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
@@ -49,6 +50,8 @@
"@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",

22
scripts/copy-web-assets.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Clean the public directory
rm -rf android/app/src/main/assets/public/*
# Copy web assets
cp -r dist/* android/app/src/main/assets/public/
# Ensure the directory structure exists
mkdir -p android/app/src/main/assets/public/assets
# Copy the main index file
cp dist/index.html android/app/src/main/assets/public/
# Copy all assets
cp -r dist/assets/* android/app/src/main/assets/public/assets/
# Copy other necessary files
cp dist/favicon.ico android/app/src/main/assets/public/
cp dist/robots.txt android/app/src/main/assets/public/
echo "Web assets copied successfully!"

22
scripts/generate-icons.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Create directories if they don't exist
mkdir -p android/app/src/main/res/mipmap-mdpi
mkdir -p android/app/src/main/res/mipmap-hdpi
mkdir -p android/app/src/main/res/mipmap-xhdpi
mkdir -p android/app/src/main/res/mipmap-xxhdpi
mkdir -p android/app/src/main/res/mipmap-xxxhdpi
# Generate placeholder icons using ImageMagick
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
# Copy to round versions
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

View File

@@ -103,7 +103,7 @@ const cleanIosPlatform = async (log) => {
// Get app name from package.json
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const appName = packageJson.name || 'App';
const appId = packageJson.capacitor?.appId || 'io.ionic.starter';
const appId = packageJson.build.appId || 'io.ionic.starter';
// Create a minimal capacitor config
const capacitorConfig = `
@@ -467,12 +467,12 @@ const configureIosProject = async (log) => {
// Build and test iOS project
const buildAndTestIos = async (log, simulator) => {
const simulatorName = simulator[0].name;
log('🏗️ Building iOS project...');
log('🏗️ Building iOS project...', simulator[0]);
execSync('cd ios/App && xcodebuild clean -workspace App.xcworkspace -scheme App', { stdio: 'inherit' });
log('✅ Xcode clean completed');
log(`🏗️ Building for simulator: ${simulatorName}`);
execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,name=${simulatorName}"`, { stdio: 'inherit' });
execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,OS=17.2,name=${simulatorName}"`, { stdio: 'inherit' });
log('✅ Xcode build completed');
// Check if the project is configured for testing by querying the scheme capabilities

View File

@@ -29,24 +29,33 @@ backup and database export, with platform-specific download instructions. * *
(excluding Identifier Data)
</button>
<a
v-if="platformService?.needsSecondaryDownloadLink()"
ref="downloadLink"
:class="computedDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
</a>
<div
v-if="platformService?.getExportInstructions().length > 0"
class="mt-4"
>
<p
v-for="instruction in platformService?.getExportInstructions()"
:key="instruction"
class="list-disc list-outside ml-4"
>
{{ instruction }}
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4">
<p>
After the download, you can save the file in your preferred storage
location.
</p>
<ul>
<li
v-if="platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On iOS: You will be prompted to choose a location to save your backup
file.
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On Android: You will be prompted to choose a location to save your
backup file.
</li>
</ul>
</div>
</div>
</template>
@@ -57,7 +66,10 @@ import { NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { PlatformService } from "../services/PlatformService";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
/**
* @vue-component
@@ -89,26 +101,26 @@ export default class DataExportSection extends Vue {
/**
* Platform service instance for platform-specific operations
*/
private platformService?: PlatformService;
private platformService: PlatformService =
PlatformServiceFactory.getInstance();
/**
* Platform capabilities for the current platform
*/
private get platformCapabilities(): PlatformCapabilities {
return this.platformService.getCapabilities();
}
/**
* Lifecycle hook to clean up resources
* Revokes object URL when component is unmounted (web platform only)
*/
beforeUnmount() {
if (this.downloadUrl && this.platformService?.needsDownloadCleanup()) {
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) {
URL.revokeObjectURL(this.downloadUrl);
}
}
async mounted() {
this.platformService = await PlatformServiceFactory.getInstance();
logger.log(
"DataExportSection mounted on platform:",
process.env.VITE_PLATFORM,
);
}
/**
* Exports the database to a JSON file
* Uses platform-specific methods for saving the exported data
@@ -119,42 +131,36 @@ export default class DataExportSection extends Vue {
*/
public async exportDatabase() {
try {
if (!this.platformService) {
this.platformService = await PlatformServiceFactory.getInstance();
}
logger.log(
"Starting database export on platform:",
process.env.VITE_PLATFORM,
);
const blob = await db.export({ prettyJson: true });
const fileName = `${db.name}-backup.json`;
logger.log("Database export details:", {
fileName,
blobSize: `${blob.size} bytes`,
platform: process.env.VITE_PLATFORM,
});
await this.platformService.exportDatabase(blob, fileName);
logger.log("Database export completed successfully:", {
fileName,
platform: process.env.VITE_PLATFORM,
});
if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link
this.downloadUrl = URL.createObjectURL(blob);
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
downloadAnchor.href = this.downloadUrl;
downloadAnchor.download = fileName;
downloadAnchor.click();
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
} else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory
const content = await blob.text();
await this.platformService.writeAndShareFile(fileName, content);
}
this.$notify(
{
group: "alert",
type: "success",
title: "Export Successful",
text: this.platformService.getExportSuccessMessage(),
text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup. It is in the Dexie format."
: "Please choose a location to save your backup file.",
},
-1,
);
} catch (error) {
logger.error("Database export failed:", {
error,
platform: process.env.VITE_PLATFORM,
});
logger.error("Export Error:", error);
this.$notify(
{
group: "alert",
@@ -173,8 +179,7 @@ export default class DataExportSection extends Vue {
*/
public computedStartDownloadLinkClassNames() {
return {
hidden:
this.downloadUrl && this.platformService?.needsSecondaryDownloadLink(),
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload,
};
}
@@ -184,9 +189,7 @@ export default class DataExportSection extends Vue {
*/
public computedDownloadLinkClassNames() {
return {
hidden:
!this.downloadUrl ||
!this.platformService?.needsSecondaryDownloadLink(),
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
};
}
}

View File

@@ -108,7 +108,6 @@ import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { PlatformService } from "../services/PlatformService";
@Component({ components: { VuePictureCropper } })
export default class PhotoDialog extends Vue {
@@ -124,14 +123,13 @@ export default class PhotoDialog extends Vue {
uploading = false;
visible = false;
private platformService?: PlatformService;
private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL;
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.platformService = await PlatformServiceFactory.getInstance();
} catch (err: unknown) {
logger.error("Error retrieving settings from database:", err);
this.$notify(
@@ -139,10 +137,7 @@ export default class PhotoDialog extends Vue {
group: "alert",
type: "danger",
title: "Error",
text:
err instanceof Error
? err.message
: "There was an error retrieving your settings.",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
@@ -186,9 +181,6 @@ export default class PhotoDialog extends Vue {
async takePhoto() {
try {
if (!this.platformService) {
this.platformService = await PlatformServiceFactory.getInstance();
}
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
@@ -208,9 +200,6 @@ export default class PhotoDialog extends Vue {
async pickPhoto() {
try {
if (!this.platformService) {
this.platformService = await PlatformServiceFactory.getInstance();
}
const result = await this.platformService.pickImage();
this.blob = result.blob;
this.fileName = result.fileName;

View File

@@ -9,13 +9,38 @@ export interface ImageResult {
fileName: string;
}
/**
* Platform capabilities interface defining what features are available
* on the current platform implementation
*/
export interface PlatformCapabilities {
/** Whether the platform supports native file system access */
hasFileSystem: boolean;
/** Whether the platform supports native camera access */
hasCamera: boolean;
/** Whether the platform is a mobile device */
isMobile: boolean;
/** Whether the platform is iOS specifically */
isIOS: boolean;
/** Whether the platform supports native file download */
hasFileDownload: boolean;
/** Whether the platform requires special file handling instructions */
needsFileHandlingInstructions: boolean;
}
/**
* Platform-agnostic interface for handling platform-specific operations.
* Provides a common API for file system operations, camera interactions,
* platform detection, and deep linking across different platforms
* (web, mobile, desktop).
* and platform detection across different platforms (web, mobile, desktop).
*/
export interface PlatformService {
// Platform capabilities
/**
* Gets the current platform's capabilities
* @returns Object describing what features are available on this platform
*/
getCapabilities(): PlatformCapabilities;
// File system operations
/**
* Reads the contents of a file at the specified path.
@@ -32,6 +57,14 @@ export interface PlatformService {
*/
writeFile(path: string, content: string): Promise<void>;
/**
* Writes content to a file at the specified path and shares it.
* @param fileName - The filename of the file to write
* @param content - The content to write to the file
* @returns Promise that resolves when the write is complete
*/
writeAndShareFile(fileName: string, content: string): Promise<void>;
/**
* Deletes a file at the specified path.
* @param path - The path to the file to delete
@@ -46,15 +79,6 @@ export interface PlatformService {
*/
listFiles(directory: string): Promise<string[]>;
/**
* Exports a database blob to a file, handling platform-specific save operations.
* @param blob - The database blob to export
* @param fileName - The name of the file to save
* @returns Promise that resolves when the export is complete
* @throws Error if export fails
*/
exportDatabase(blob: Blob, fileName: string): Promise<void>;
// Camera operations
/**
* Activates the device camera to take a picture.
@@ -68,60 +92,10 @@ export interface PlatformService {
*/
pickImage(): Promise<ImageResult>;
// Platform specific features
/**
* Checks if the current platform is Capacitor (mobile).
* @returns true if running on Capacitor
*/
isCapacitor(): boolean;
/**
* Checks if the current platform is Electron (desktop).
* @returns true if running on Electron
*/
isElectron(): boolean;
/**
* Checks if the current platform is PyWebView.
* @returns true if running on PyWebView
*/
isPyWebView(): boolean;
/**
* Checks if the current platform is web browser.
* @returns true if running in a web browser
*/
isWeb(): boolean;
// Deep linking
/**
* Handles deep link URLs for the application.
* @param url - The deep link URL to handle
* @returns Promise that resolves when the deep link has been handled
*/
handleDeepLink(url: string): Promise<void>;
/**
* Gets platform-specific instructions for saving exported files
* @returns Array of instruction strings for the current platform
*/
getExportInstructions(): string[];
/**
* Gets the success message for database export
* @returns Success message appropriate for the current platform
*/
getExportSuccessMessage(): string;
/**
* Checks if the platform requires a secondary download link
* @returns true if platform needs a secondary download link
*/
needsSecondaryDownloadLink(): boolean;
/**
* Checks if the platform needs cleanup after download
* @returns true if platform needs cleanup after download
*/
needsDownloadCleanup(): boolean;
}

View File

@@ -1,5 +1,8 @@
import { PlatformService } from "./PlatformService";
import { WebPlatformService } from "./platforms/WebPlatformService";
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService";
import { ElectronPlatformService } from "./platforms/ElectronPlatformService";
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService";
/**
* Factory class for creating platform-specific service implementations.
@@ -27,43 +30,29 @@ export class PlatformServiceFactory {
*
* @returns {PlatformService} The singleton instance of PlatformService
*/
public static async getInstance(): Promise<PlatformService> {
public static getInstance(): PlatformService {
if (PlatformServiceFactory.instance) {
return PlatformServiceFactory.instance;
}
const platform = process.env.VITE_PLATFORM || "web";
let service: PlatformService;
switch (platform) {
case "capacitor": {
const { CapacitorPlatformService } = await import(
"./platforms/CapacitorPlatformService"
);
service = new CapacitorPlatformService();
case "capacitor":
PlatformServiceFactory.instance = new CapacitorPlatformService();
break;
}
case "electron": {
const { ElectronPlatformService } = await import(
"./platforms/ElectronPlatformService"
);
service = new ElectronPlatformService();
case "electron":
PlatformServiceFactory.instance = new ElectronPlatformService();
break;
}
case "pywebview": {
const { PyWebViewPlatformService } = await import(
"./platforms/PyWebViewPlatformService"
);
service = new PyWebViewPlatformService();
case "pywebview":
PlatformServiceFactory.instance = new PyWebViewPlatformService();
break;
}
case "web":
default:
service = new WebPlatformService();
PlatformServiceFactory.instance = new WebPlatformService();
break;
}
PlatformServiceFactory.instance = service;
return service;
return PlatformServiceFactory.instance;
}
}

View File

@@ -1,8 +1,12 @@
import { ImageResult, PlatformService } from "../PlatformService";
import { Filesystem, Directory } from "@capacitor/filesystem";
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { logger } from "../../utils/logger";
import { Share } from "@capacitor/share";
import { logger } from "../../utils/logger";
/**
* Platform service implementation for Capacitor (mobile) platform.
@@ -12,6 +16,128 @@ import { Share } from "@capacitor/share";
* - Platform-specific features
*/
export class CapacitorPlatformService implements PlatformService {
/**
* Gets the capabilities of the Capacitor platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: true,
hasCamera: true,
isMobile: true,
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: false,
needsFileHandlingInstructions: true,
};
}
/**
* Checks and requests storage permissions if needed
* @returns Promise that resolves when permissions are granted
* @throws Error if permissions are denied
*/
private async checkStoragePermissions(): Promise<void> {
try {
const logData = {
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log(
"Checking storage permissions",
JSON.stringify(logData, null, 2),
);
if (this.getCapabilities().isIOS) {
// iOS uses different permission model
return;
}
// Try to access a test directory to check permissions
try {
await Filesystem.stat({
path: "/storage/emulated/0/Download",
directory: Directory.Documents,
});
logger.log(
"Storage permissions already granted",
JSON.stringify({ timestamp: new Date().toISOString() }, 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(),
};
// "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")
) {
logger.log(
"Permission check failed, requesting permissions",
JSON.stringify(errorLogData, null, 2),
);
// 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,
});
logger.log(
"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}`,
);
}
}
// For any other error, log it but don't treat as permission error
logger.log(
"Unexpected error during permission check",
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}`);
}
}
/**
* Reads a file from the app's data directory.
* @param path - Relative path to the file in the app's data directory
@@ -30,17 +156,202 @@ export class CapacitorPlatformService implements PlatformService {
}
/**
* Writes content to a file in the app's data directory.
* @param path - Relative path where to write the file
* @param content - Content to write to the file
* @throws Error if write operation fails
* Writes content to a file in the app's safe storage and offers sharing.
*
* Platform-specific behavior:
* - Saves to app's Documents directory
* - Offers sharing functionality to move file elsewhere
*
* The method handles:
* 1. Writing to app-safe storage
* 2. Sharing the file with user's preferred app
* 3. Error handling and logging
*
* @param fileName - The name of the file to create (e.g. "backup.json")
* @param content - The content to write to the file
*
* @throws Error if:
* - File writing fails
* - Sharing fails
*
* @example
* ```typescript
* // Save and share a JSON file
* await platformService.writeFile(
* "backup.json",
* JSON.stringify(data)
* );
* ```
*/
async writeFile(path: string, content: string): Promise<void> {
await Filesystem.writeFile({
path,
data: content,
directory: Directory.Data,
});
async writeFile(fileName: string, content: string): Promise<void> {
try {
const logData = {
targetFileName: fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log(
"Starting writeFile operation",
JSON.stringify(logData, null, 2),
);
// For Android, we need to handle content URIs differently
if (this.getCapabilities().isIOS) {
// Write to app's Documents directory for iOS
const writeResult = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
const writeSuccessLogData = {
path: writeResult.uri,
timestamp: new Date().toISOString(),
};
logger.log(
"File write successful",
JSON.stringify(writeSuccessLogData, null, 2),
);
// Offer to share the file
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
url: writeResult.uri,
dialogTitle: "Share your backup",
});
logger.log(
"Share dialog shown",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
} catch (shareError) {
// Log share error but don't fail the operation
logger.error(
"Share dialog failed",
JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(),
},
null,
2,
),
);
}
} else {
// For Android, first write to app's Documents directory
const writeResult = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
const writeSuccessLogData = {
path: writeResult.uri,
timestamp: new Date().toISOString(),
};
logger.log(
"File write successful to app storage",
JSON.stringify(writeSuccessLogData, null, 2),
);
// Then share the file to let user choose where to save it
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
url: writeResult.uri,
dialogTitle: "Save your backup",
});
logger.log(
"Share dialog shown for Android",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
} catch (shareError) {
// Log share error but don't fail the operation
logger.error(
"Share dialog failed for Android",
JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(),
},
null,
2,
),
);
}
}
} catch (error: unknown) {
const err = error as Error;
const finalErrorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
logger.error(
"Error in writeFile operation:",
JSON.stringify(finalErrorLogData, null, 2),
);
throw new Error(`Failed to save file: ${err.message}`);
}
}
/**
* Writes content to a file in the device's app-private storage.
* Then shares the file using the system share dialog.
*
* Works on both Android and iOS without needing external storage permissions.
*
* @param fileName - The name of the file to create (e.g. "backup.json")
* @param content - The content to write to the file
*/
async writeAndShareFile(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: 'writeAndShareFile',
fileName,
contentLength: content.length,
timestamp,
};
logger.log('[CapacitorPlatformService]', JSON.stringify(logData, null, 2));
try {
const { uri } = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log('[CapacitorPlatformService] File write successful:', { uri, timestamp: new Date().toISOString() });
await Share.share({
title: 'TimeSafari Backup',
text: 'Here is your backup file.',
url: uri,
dialogTitle: 'Share your backup file',
});
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error('[CapacitorPlatformService] Error writing or sharing file:', JSON.stringify(errLog, null, 2));
throw new Error(`Failed to write or share file: ${err.message}`);
}
}
/**
@@ -149,38 +460,6 @@ export class CapacitorPlatformService implements PlatformService {
return new Blob(byteArrays, { type: "image/jpeg" });
}
/**
* Checks if running on Capacitor platform.
* @returns true, as this is the Capacitor implementation
*/
isCapacitor(): boolean {
return true;
}
/**
* Checks if running on Electron platform.
* @returns false, as this is not Electron
*/
isElectron(): boolean {
return false;
}
/**
* Checks if running on PyWebView platform.
* @returns false, as this is not PyWebView
*/
isPyWebView(): boolean {
return false;
}
/**
* Checks if running on web platform.
* @returns false, as this is not web
*/
isWeb(): boolean {
return false;
}
/**
* Handles deep link URLs for the application.
* Note: Capacitor handles deep links automatically.
@@ -191,91 +470,4 @@ export class CapacitorPlatformService implements PlatformService {
// This is just a placeholder for the interface
return Promise.resolve();
}
getExportInstructions(): string[] {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
return [
"On iOS: Choose 'More...' and select a place in iCloud, or go 'Back' and save to another location.",
];
} else {
return [
"On Android: Choose 'Open' and then share to your preferred place.",
];
}
}
getExportSuccessMessage(): string {
return "The backup has been saved to your device.";
}
needsSecondaryDownloadLink(): boolean {
return false;
}
needsDownloadCleanup(): boolean {
return false;
}
async exportDatabase(blob: Blob, fileName: string): Promise<void> {
logger.log("Starting database export on Capacitor platform:", {
fileName,
blobSize: `${blob.size} bytes`,
});
// Create a File object from the Blob
const file = new File([blob], fileName, { type: "application/json" });
try {
logger.log("Attempting to use native share sheet");
// Use the native share sheet
await navigator.share({
files: [file],
title: fileName,
});
logger.log("Database export completed via native share sheet");
} catch (error) {
logger.log("Native share failed, falling back to Capacitor Share API");
// Fallback to Capacitor Share API if Web Share API fails
// First save to temporary file
const base64Data = await this.blobToBase64(blob);
const result = await Filesystem.writeFile({
path: fileName,
data: base64Data,
directory: Directory.Cache, // Use Cache instead of Documents for temporary files
recursive: true,
});
logger.log("Temporary file created for sharing:", result.uri);
// Then share using Capacitor Share API
await Share.share({
title: fileName,
url: result.uri,
});
logger.log("Database export completed via Capacitor Share API");
// Clean up the temporary file
try {
await Filesystem.deleteFile({
path: fileName,
directory: Directory.Cache,
});
logger.log("Temporary file cleaned up successfully");
} catch (cleanupError) {
logger.warn("Failed to clean up temporary file:", cleanupError);
}
}
}
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result as string;
resolve(base64data.split(",")[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
}

View File

@@ -1,4 +1,8 @@
import { ImageResult, PlatformService } from "../PlatformService";
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
@@ -14,6 +18,21 @@ import { logger } from "../../utils/logger";
* - System-level features
*/
export class ElectronPlatformService implements PlatformService {
/**
* Gets the capabilities of the Electron platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false, // Not implemented yet
hasCamera: false, // Not implemented yet
isMobile: false,
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
};
}
/**
* Reads a file from the filesystem.
* @param _path - Path to the file to read
@@ -79,38 +98,6 @@ export class ElectronPlatformService implements PlatformService {
throw new Error("Not implemented");
}
/**
* Checks if running on Capacitor platform.
* @returns false, as this is not Capacitor
*/
isCapacitor(): boolean {
return false;
}
/**
* Checks if running on Electron platform.
* @returns true, as this is the Electron implementation
*/
isElectron(): boolean {
return true;
}
/**
* Checks if running on PyWebView platform.
* @returns false, as this is not PyWebView
*/
isPyWebView(): boolean {
return false;
}
/**
* Checks if running on web platform.
* @returns false, as this is not web
*/
isWeb(): boolean {
return false;
}
/**
* Should handle deep link URLs for the desktop application.
* @param _url - The deep link URL to handle
@@ -121,16 +108,4 @@ export class ElectronPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Exports a database blob to a file.
* @param _blob - The database blob to export
* @param _fileName - The name of the file to save
* @throws Error with "Not implemented" message
* @todo Implement file export using Electron's file system API
*/
async exportDatabase(_blob: Blob, _fileName: string): Promise<void> {
logger.error("exportDatabase not implemented in Electron platform");
throw new Error("Not implemented");
}
}

View File

@@ -1,4 +1,8 @@
import { ImageResult, PlatformService } from "../PlatformService";
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
@@ -15,6 +19,21 @@ import { logger } from "../../utils/logger";
* - Python-JavaScript bridge functionality
*/
export class PyWebViewPlatformService implements PlatformService {
/**
* Gets the capabilities of the PyWebView platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false, // Not implemented yet
hasCamera: false, // Not implemented yet
isMobile: false,
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
};
}
/**
* Reads a file using the Python backend.
* @param _path - Path to the file to read
@@ -80,38 +99,6 @@ export class PyWebViewPlatformService implements PlatformService {
throw new Error("Not implemented");
}
/**
* Checks if running on Capacitor platform.
* @returns false, as this is not Capacitor
*/
isCapacitor(): boolean {
return false;
}
/**
* Checks if running on Electron platform.
* @returns false, as this is not Electron
*/
isElectron(): boolean {
return false;
}
/**
* Checks if running on PyWebView platform.
* @returns true, as this is the PyWebView implementation
*/
isPyWebView(): boolean {
return true;
}
/**
* Checks if running on web platform.
* @returns false, as this is not web
*/
isWeb(): boolean {
return false;
}
/**
* Should handle deep link URLs through the Python backend.
* @param _url - The deep link URL to handle
@@ -122,16 +109,4 @@ export class PyWebViewPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in PyWebView platform");
throw new Error("Not implemented");
}
/**
* Exports a database blob to a file using the Python backend.
* @param _blob - The database blob to export
* @param _fileName - The name of the file to save
* @throws Error with "Not implemented" message
* @todo Implement file export through pywebview's Python-JavaScript bridge
*/
async exportDatabase(_blob: Blob, _fileName: string): Promise<void> {
logger.error("exportDatabase not implemented in PyWebView platform");
throw new Error("Not implemented");
}
}

View File

@@ -1,4 +1,8 @@
import { ImageResult, PlatformService } from "../PlatformService";
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
@@ -15,6 +19,21 @@ import { logger } from "../../utils/logger";
* due to browser security restrictions. These methods throw appropriate errors.
*/
export class WebPlatformService implements PlatformService {
/**
* Gets the capabilities of the web platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false,
hasCamera: true, // Through file input with capture
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: true,
needsFileHandlingInstructions: false,
};
}
/**
* Not supported in web platform.
* @param _path - Unused path parameter
@@ -209,53 +228,4 @@ export class WebPlatformService implements PlatformService {
// Web platform can handle deep links through URL parameters
return Promise.resolve();
}
getExportInstructions(): string[] {
return [
"After the download, you can save the file in your preferred storage location.",
];
}
getExportSuccessMessage(): string {
return "See your downloads directory for the backup. It is in the Dexie format.";
}
needsSecondaryDownloadLink(): boolean {
return true;
}
needsDownloadCleanup(): boolean {
return true;
}
async exportDatabase(blob: Blob, fileName: string): Promise<void> {
logger.log("Starting database export on web platform:", {
fileName,
blobSize: `${blob.size} bytes`,
});
try {
logger.log("Creating download link for database export");
// Create a download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
logger.log("Triggering download");
// Trigger the download
document.body.appendChild(a);
a.click();
logger.log("Cleaning up download link");
// Clean up
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
logger.log("Database export completed successfully");
} catch (error) {
logger.error("Failed to export database:", error);
throw error;
}
}
}

View File

@@ -21,7 +21,10 @@ function safeStringify(obj: unknown) {
export const logger = {
log: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor"
) {
// eslint-disable-next-line no-console
console.log(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
@@ -29,7 +32,10 @@ export const logger = {
}
},
warn: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor"
) {
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";

View File

@@ -350,6 +350,47 @@ import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "types";
interface Claim {
claim?: Claim; // For nested claims in Verifiable Credentials
agent?: {
identifier?: string;
did?: string;
};
recipient?: {
identifier?: string;
did?: string;
};
provider?:
| {
identifier?: string;
}
| Array<{ identifier?: string }>;
object?: {
amountOfThisGood?: number;
unitCode?: string;
};
description?: string;
image?: string;
}
interface FulfillsPlan {
locLat?: number;
locLon?: number;
name?: string;
}
interface Provider {
identifier?: string;
}
interface ProvidedByPlan {
name?: string;
}
interface FeedError {
userMessage?: string;
}
/**
* HomeView Component
*
@@ -987,7 +1028,7 @@ export default class HomeView extends Vue {
* @param claim The claim object containing giver information
* @returns The giver's DID
*/
private extractGiverDid(claim: any) {
private extractGiverDid(claim: Claim) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return claim.agent?.identifier || (claim.agent as any)?.did;
}
@@ -998,7 +1039,7 @@ export default class HomeView extends Vue {
* @internal
* Called by processRecord()
*/
private extractRecipientDid(claim: any) {
private extractRecipientDid(claim: Claim) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return claim.recipient?.identifier || (claim.recipient as any)?.did;
}
@@ -1056,7 +1097,7 @@ export default class HomeView extends Vue {
*/
private shouldIncludeRecord(
record: GiveSummaryRecord,
fulfillsPlan: any,
fulfillsPlan?: FulfillsPlan,
): boolean {
if (!this.isAnyFeedFilterOn) {
return true;
@@ -1090,7 +1131,7 @@ export default class HomeView extends Vue {
* @internal
* Called by processRecord()
*/
private extractProvider(claim: any) {
private extractProvider(claim: Claim): Provider | undefined {
return Array.isArray(claim.provider) ? claim.provider[0] : claim.provider;
}
@@ -1100,7 +1141,7 @@ export default class HomeView extends Vue {
* @internal
* Called by processRecord()
*/
private async getProvidedByPlan(provider: any) {
private async getProvidedByPlan(provider: Provider | undefined) {
return await getPlanFromCache(
provider?.identifier as string,
this.axios,
@@ -1138,12 +1179,12 @@ export default class HomeView extends Vue {
*/
private createFeedRecord(
record: GiveSummaryRecord,
claim: any,
claim: Claim,
giverDid: string,
recipientDid: string,
provider: any,
fulfillsPlan: any,
providedByPlan: any,
provider: Provider | undefined,
fulfillsPlan?: FulfillsPlan,
providedByPlan?: ProvidedByPlan,
): GiveRecordWithContactInfo {
return {
...record,
@@ -1202,14 +1243,16 @@ export default class HomeView extends Vue {
* @internal
* Called by updateAllFeed()
*/
private handleFeedError(e: any) {
private handleFeedError(e: unknown) {
logger.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Feed Error",
text: e.userMessage || "There was an error retrieving feed data.",
text:
(e as FeedError)?.userMessage ||
"There was an error retrieving feed data.",
},
-1,
);

View File

@@ -36,21 +36,7 @@ export async function createBuildConfig(mode: string) {
assetsDir: 'assets',
chunkSizeWarningLimit: 1000,
rollupOptions: {
external: isCapacitor
? [
'@capacitor/app',
'@capacitor/share',
'@capacitor/filesystem',
'@capacitor/camera',
'@capacitor/core'
]
: [
'@capacitor/app',
'@capacitor/share',
'@capacitor/filesystem',
'@capacitor/camera',
'@capacitor/core'
]
external: isCapacitor ? ['@capacitor/app'] : []
}
},
define: {