Compare commits

..
Author SHA1 Message Date
Matthew Raymer 6b38b1a347 test: increase timeout for record offer test to 60s
The record offer test occasionally hits the 45s timeout limit.
Increasing to 60s provides more headroom, aligning with actual
test durations seen in Firefox (up to 43.1s for similar operations).
2025-04-21 06:14:34 +00:00
trentlarson ca455e9593 modify files to make the ios build & distribution work 2025-04-18 20:40:41 -06:00
trentlarson 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
48 changed files with 944 additions and 1212 deletions
+3
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:
@@ -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.
+2 -2
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 {
+2
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')
}
@@ -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());
}
}
+4 -8
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>
@@ -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"
}
]
@@ -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

@@ -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>
+1 -1
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
+6
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')
+10 -17
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
@@ -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 */,
+1 -1
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">
@@ -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>
+16 -1
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>
+4
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
+29 -5
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
-22
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
+229 -649
View File
File diff suppressed because it is too large Load Diff
+3
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
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
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
+3 -3
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
+53 -50
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,
};
}
}
+2 -13
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;
+35 -61
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;
}
+12 -23
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;
}
}
+324 -132
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);
});
}
}
@@ -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");
}
}
@@ -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");
}
}
+20 -50
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;
}
}
}
+8 -2
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) : "";
+54 -11
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,
);
+1 -1
View File
@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record an offer', async ({ page }) => {
test.setTimeout(45000);
test.setTimeout(60000);
// Generate a random string of 3 characters, skipping the "0." at the beginning
const randomString = Math.random().toString(36).substring(2, 5);
+1 -15
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: {