Compare commits

...

31 Commits

Author SHA1 Message Date
Jose Olarte III
402bd2681f chore(ios): log share extension diagnostics on startup (temporary)
Call getShareExtensionDiagnostics() during the iOS shared-image startup
check and print the result to Xcode logs for share-target investigation.
2026-06-24 19:43:27 +08:00
Jose Olarte III
498a4926bf feat(ios): add Share Extension startup diagnostic marker and API
Write shareExtensionLastStart on ShareViewController.viewDidLoad and
expose getShareExtensionDiagnostics() through SharedImagePlugin with
shareId, file path, and fileExists for debugging failed share flows.
2026-06-24 19:38:25 +08:00
Jose Olarte III
f0ca49b5dc feat(ios): add diagnostic logging to Share Extension share flow
Log start/end, shareId, attachment counts, UTType, byte counts, filenames,
and every early return across viewDidLoad through completeRequest to trace
how far a share progresses when debugging failures.
2026-06-24 19:28:09 +08:00
Jose Olarte III
07463246f0 feat(ios): add share-target diagnostic logging in SharedImageUtility
Log shareId, sharedPhotoFilePath, metadataExists, and fileExists at the
start of getSharedImageData() and hasSharedImage() to debug pending
App Group shares without changing retrieval behavior.
2026-06-24 17:19:54 +08:00
Jose Olarte III
79ceebbd1d feat(ios): make shared image retrieval non-destructive (Phase 1C)
Stop deleting App Group metadata and image files in getSharedImageData()
so retrieval is read-only while preserving the existing plugin API shape.
Document removed deletion paths in the iOS share target audit.
2026-06-24 16:49:45 +08:00
Jose Olarte III
ddbd07f315 feat(ios): use UUID-based filenames for shared images (Phase 1B)
Store shared images as <shareId>.<ext> in the App Group container while
keeping the original filename in metadata, preventing on-disk collisions
without changing retrieval, deletion, or JS consumer behavior.
2026-06-23 19:37:11 +08:00
Jose Olarte III
35a6a6bfb3 feat(ios): add share ID tracking for share target (Phase 1A)
Generate a UUID per incoming share in the Share Extension, persist it
as sharedPhotoShareId in App Group metadata, and add [ShareTarget] logs
for receive/store/retrieve events without changing retrieval or deletion.
2026-06-23 19:13:44 +08:00
Jose Olarte III
08a55202f5 docs(ios): add share target implementation audit
Document the Share Extension → App Group → main app flow, including
read/write/delete points, startup detection hooks, timing behavior,
and race conditions to support share-target reliability work.
2026-06-23 17:20:53 +08:00
ec41dd52d5 bump version to v 1.4.3 build 69 2026-06-21 10:59:24 -06:00
463db39a6b remove hard-coded daily android notification 2026-06-19 23:43:40 -06:00
fe97dff752 Merge pull request 'Rework Thanks Button' (#234) from thanks-button-rework into master
Reviewed-on: #234
2026-06-19 07:21:37 +00:00
Jose Olarte III
903047f13b style(gift): center entity selection step heading and entity type toggle 2026-06-18 21:09:32 +08:00
Jose Olarte III
48be234af4 fix(home): offset scrolled Thank button for safe-area-inset-bottom 2026-06-17 17:57:13 +08:00
6c0907d905 remove unused function & duplicate comment 2026-06-16 16:09:31 -06:00
Jose Olarte III
8d8bcf2a7e style(home): rework Thank button and sticky scroll action bar
Replace the floating circular plus FAB with a full-width bottom bar that
matches the inline Thank button. Wrap the quick-action section in a styled
container and raise the scroll threshold to 120px.
2026-06-16 21:48:34 +08:00
a4b47904c8 Merge pull request 'Add footer to Gifted Details view' (#233) from gifted-details-footer into master
Reviewed-on: #233
2026-06-16 08:27:47 +00:00
Jose Olarte III
bb890baacf fix(gifted-details): add QuickNav footer navigation 2026-06-15 17:20:18 +08:00
dae23300fe point to a single .entitlements file (undo most of previous commit) 2026-06-02 15:50:17 -06:00
9e401febea add 'share' to the entitlements for production, for sharing with this app 2026-06-02 15:46:36 -06:00
cd4b279703 Merge pull request '16kb-pages' (#232) from 16kb-pages into master
Reviewed-on: #232
2026-05-25 20:01:18 +00:00
a3a2d97b9a update version to v 1.4.2 build 68 2026-05-24 21:50:39 -06:00
802050259c update android build, fix ios build for new version of MLKit BarcodeScanner (both build) 2026-05-24 21:24:18 -06:00
efd7d50a84 fix build error 2026-05-24 19:12:40 -06:00
39c389cda8 make do-not-pair group verbiage more clear 2026-05-24 18:38:56 -06:00
93fdcaf7ff fix timing error for a click (that only showed in firefox) 2026-05-24 18:29:11 -06:00
ad419efa0d utilize 'userMessage' if sent by server 2026-05-24 16:23:47 -06:00
ce45ddb2bd update after 'audit fix' 2026-05-24 16:23:09 -06:00
7d306bd204 add first cut for 16kb page sizes, all by Claude 2026-05-10 10:15:10 -06:00
9713313a40 fix HTML syntax warning 2026-05-10 09:43:46 -06:00
Jose Olarte III
ffa7bac319 fix(ios): ensure capacitor-assets output dirs exist on fresh clones
Gitignored AppIcon.appiconset and Splash.imageset are absent after clone,
which made `capacitor-assets generate --ios` fail (missing paths and
Contents.json). Add ensure_ios_capacitor_asset_directories in common.sh
to mkdir and seed minimal Contents.json when needed; call it from
build-ios.sh before asset generation and from the build:native npm script.
Document the behavior in ios/.gitignore.
2026-04-13 16:20:51 +08:00
e0e0a0a183 bump version and add -beta 2026-04-05 20:08:24 -06:00
37 changed files with 5564 additions and 4541 deletions

View File

@@ -1 +1 @@
18.19.0 20.18.1

2
.nvmrc
View File

@@ -1 +1 @@
18.19.0 20.18.1

View File

@@ -333,11 +333,11 @@ The `serve` functionality provides a local HTTP server for testing production bu
- If there are DB changes: before updating the test server, open browser(s) with - If there are DB changes: before updating the test server, open browser(s) with
current version to test DB migrations. current version to test DB migrations.
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run - Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run:
`npm install`. `npm install`.
- Run a build to make sure package-lock version is updated, linting works, etc: - Run a build to make sure linting works, etc:
`npm install && npm run build:web` `npm run build:web`
- Commit everything (since the commit hash is used the app). - Commit everything (since the commit hash is used the app).
@@ -346,7 +346,7 @@ current version to test DB migrations.
- Tag with the new version, - Tag with the new version,
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
`git tag 1.0.2 && git push origin 1.0.2`. `git tag 1.3.13 && git push origin 1.3.13`.
- For test, build the app: - For test, build the app:
@@ -1140,7 +1140,7 @@ export GEM_PATH=$shortened_path
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here: ##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash ```bash
cd ios/App && xcrun agvtool new-version 67 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.12;/g" App.xcodeproj/project.pbxproj && cd - cd ios/App && xcrun agvtool new-version 69 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.3;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
``` ```
@@ -1419,8 +1419,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run: ##### 1. Bump the version in package.json, then update these versions & run:
```bash ```bash
perl -p -i -e 's/versionCode .*/versionCode 67/g' android/app/build.gradle perl -p -i -e 's/versionCode .*/versionCode 69/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.3.12"/g' android/app/build.gradle perl -p -i -e 's/versionName .*/versionName "1.4.3"/g' android/app/build.gradle
``` ```
##### 2. Build ##### 2. Build

View File

@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.3] - 2026.06.19
### Removed
- Automatic "Check your starred projects" daily notification
### Changed
- Positioning for 'Thank' button and entity-type toggle link
## [1.4.2] - 2026.05.24
### Changed
- Support 16 KB page sizes
## [1.3.13] - 2026.04.05 ## [1.3.13] - 2026.04.05
### Added ### Added
- Ability to select project that the current one fulfills - Ability to select project that the current one fulfills

View File

@@ -29,16 +29,16 @@ android {
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_21
} }
defaultConfig { defaultConfig {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 67 versionCode 69
versionName "1.3.12" versionName "1.4.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -72,13 +72,14 @@ android {
} }
packagingOptions { packagingOptions {
jniLibs { jniLibs {
// Required for 16 KB page-size support: keep native libs uncompressed and
// page-aligned inside the APK (default on AGP 8.x with minSdk 23+, set
// explicitly so it does not regress).
useLegacyPackaging = false
pickFirsts += ['**/lib/x86_64/libbarhopper_v3.so', '**/lib/x86_64/libimage_processing_util_jni.so', '**/lib/x86_64/libsqlcipher.so'] pickFirsts += ['**/lib/x86_64/libbarhopper_v3.so', '**/lib/x86_64/libimage_processing_util_jni.so', '**/lib/x86_64/libsqlcipher.so']
} }
} }
// Configure for 16 KB page size compatibility
// Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease) // Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease)
bundle { bundle {
language { language {

View File

@@ -2,8 +2,8 @@
android { android {
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_21
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"appId": "app.timesafari", "appId": "app.timesafari",
"appName": "TimeSafari", "appName": "Giftopia",
"webDir": "dist", "webDir": "dist",
"server": { "server": {
"cleartext": true "cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false, "iosIsEncryption": false,
"iosBiometric": { "iosBiometric": {
"biometricAuth": false, "biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari" "biometricTitle": "Biometric login for Giftopia"
}, },
"androidIsEncryption": false, "androidIsEncryption": false,
"androidBiometric": { "androidBiometric": {
"biometricAuth": false, "biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari" "biometricTitle": "Biometric login for Giftopia"
}, },
"electronIsEncryption": false "electronIsEncryption": false
}, },
@@ -100,7 +100,7 @@
}, },
"buildOptions": { "buildOptions": {
"appId": "app.timesafari", "appId": "app.timesafari",
"productName": "TimeSafari", "productName": "Giftopia",
"directories": { "directories": {
"output": "dist-electron-packages" "output": "dist-electron-packages"
}, },

View File

@@ -9,7 +9,6 @@ import org.timesafari.dailynotification.FetchContext;
import org.timesafari.dailynotification.NativeNotificationContentFetcher; import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent; import org.timesafari.dailynotification.NotificationContent;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -47,22 +46,11 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
// This should query the TimeSafari API for notification content // This should query the TimeSafari API for notification content
// using the configured apiBaseUrl, activeDid, and jwtToken // using the configured apiBaseUrl, activeDid, and jwtToken
// For now, return a placeholder notification // Not implemented yet: return no content rather than fabricating a
long scheduledTime = fetchContext.scheduledTime != null // placeholder notification (previously hardcoded "Check your starred
? fetchContext.scheduledTime // projects for updates!", which showed on every app startup).
: System.currentTimeMillis() + 60000; // 1 minute from now Log.d(TAG, "Content fetching not yet implemented; returning no notifications");
return Collections.<NotificationContent>emptyList();
NotificationContent content = new NotificationContent(
"TimeSafari Update",
"Check your starred projects for updates!",
scheduledTime
);
List<NotificationContent> results = new ArrayList<>();
results.add(content);
Log.d(TAG, "Returning " + results.size() + " notification(s)");
return results;
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Fetch failed", e); Log.e(TAG, "Fetch failed", e);

View File

@@ -1,5 +1,5 @@
ext { ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1' androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1' cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
} }
@@ -9,7 +9,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.2.1' classpath 'com.android.tools.build:gradle:8.7.2'
} }
} }
@@ -17,10 +17,10 @@ apply plugin: 'com.android.library'
android { android {
namespace "capacitor.cordova.android.plugins" namespace "capacitor.cordova.android.plugins"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34 compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig { defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22 minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34 targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
} }
@@ -28,8 +28,8 @@ android {
abortOnError false abortOnError false
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_21
} }
} }

View File

@@ -1,6 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
ext { ext {
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22 cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
// Plugin gradle extensions can append to this to have code run at the end. // Plugin gradle extensions can append to this to have code run at the end.
cdvPluginPostBuildExtras = [] cdvPluginPostBuildExtras = []
cordovaConfig = [:] cordovaConfig = [:]

View File

@@ -13,4 +13,11 @@ ext {
androidxJunitVersion = '1.1.5' androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1' androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1' cordovaAndroidVersion = '10.1.1'
// Pin CameraX to 1.4.2: first stable line shipping a 16 KB page-size-aligned
// libimage_processing_util_jni.so. The barcode-scanning plugin still defaults to 1.1.0.
androidxCameraCamera2Version = '1.4.2'
androidxCameraCoreVersion = '1.4.2'
androidxCameraLifecycleVersion = '1.4.2'
androidxCameraViewVersion = '1.4.2'
} }

View File

@@ -0,0 +1,449 @@
# iOS Share Target Implementation Audit
**Generated:** 2026-06-23 17:07:21 PST
## Overview
The iOS share target uses a **Share Extension** (`TimeSafariShareExtension`) that writes a shared image to an **App Group** container (`group.app.timesafari.share`), then opens the main app via `timesafari://`. The main app reads the image through a native **Capacitor plugin** (`SharedImagePlugin`) and stores it in the JS temp database before routing to `/shared-photo`.
### App Group Storage Model
| Key | Storage | Written by | Purpose |
|-----|---------|------------|---------|
| `sharedPhotoFilePath` | UserDefaults (suite) | Share Extension | On-disk filename in container (`<shareId>.<ext>`) |
| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Original display filename from source app |
| `sharedPhotoShareId` | UserDefaults (suite) | Share Extension | Unique UUID per incoming share (Phase 1A) |
| `sharedPhotoReady` | UserDefaults (suite) | Share Extension | Boolean signal that a new share is available |
| `sharedPhotoBase64` | UserDefaults (suite) | *(legacy, not written)* | Removed on write for cleanup |
| Image file | App Group filesystem | Share Extension | Raw image bytes at `{container}/{sharedPhotoFilePath}` (`<shareId>.<ext>`) |
---
## End-to-End Flow
```
External App (Photos, Safari, etc.)
┌─────────────────────────────────────┐
│ TimeSafariShareExtension │
│ ShareViewController │
│ 1. viewDidLoad → processAndOpenApp │
│ 2. processSharedImage (async) │
│ 3. storeImageData → file + metadata │
│ 4. setSharedPhotoReadyFlag │
│ 5. openMainApp (timesafari://) │
│ 6. completeRequest │
└─────────────────────────────────────┘
▼ App Group: group.app.timesafari.share
│ (UserDefaults keys + image file)
┌─────────────────────────────────────┐
│ Main App (app.timesafari) │
│ │
│ Native detection: │
│ • AppDelegate.applicationDidBecome │
│ Active → checkForSharedImageOn │
│ Activation (flag only) │
│ • AppDelegate.application(open:) │
│ → Capacitor URL handling │
│ │
│ JS detection (main.capacitor.ts): │
│ • setTimeout 1000ms startup check │
│ • appStateChange (isActive) │
│ • appUrlOpen → timesafari:// │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ SharedImagePlugin.getSharedImage() │
│ → SharedImageUtility │
│ .getSharedImageData() │
│ (read-only; leaves native data intact) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ main.capacitor.ts │
│ storeSharedImageInTempDB() │
│ → SQLite temp table │
│ → router.push/replace /shared-photo│
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ SharedPhotoView.vue │
│ loadSharedImage() │
│ → reads temp DB, deletes temp row │
│ → displays image, user action │
└─────────────────────────────────────┘
```
---
## Component Responsibilities
### Share Extension (Writer)
| File | Method | Responsibility |
|------|--------|----------------|
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | `viewDidLoad()` | Entry point; triggers share processing on load |
| | `processAndOpenApp()` | Orchestrates image extraction, flag set, app open, extension completion |
| | `processSharedImage(from:completion:)` | Iterates `NSExtensionItem` attachments; loads first `UTType.image` via `loadItem` |
| | `storeImageData(_:fileName:)` | Writes image file to App Group container; writes metadata to UserDefaults |
| | `setSharedPhotoReadyFlag()` | Sets `sharedPhotoReady = true` in App Group UserDefaults |
| | `openMainApp()` | Opens `timesafari://` via responder chain or `extensionContext.open` |
| | `getFileNameWithExtension(_:newExtension:)` | Helper for PNG fallback filename |
| `ios/App/TimeSafariShareExtension/Info.plist` | — | Declares share-services extension; accepts 1 image max |
| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | — | Grants App Group `group.app.timesafari.share` |
### Main App Native Layer (Reader)
| File | Method | Responsibility |
|------|--------|----------------|
| `ios/App/App/SharedImageUtility.swift` | `getSharedImageData()` | Read-only: reads file from App Group, returns base64 + fileName; leaves metadata and file intact (Phase 1C) |
| | `hasSharedImage()` | Non-destructive existence check (metadata + file on disk) |
| | `isSharedPhotoReady()` | Reads `sharedPhotoReady` flag |
| | `clearSharedPhotoReadyFlag()` | Removes `sharedPhotoReady` key |
| `ios/App/App/SharedImagePlugin.swift` | `getSharedImage(_:)` | Capacitor bridge to `getSharedImageData()` |
| | `hasSharedImage(_:)` | Capacitor bridge to `hasSharedImage()` |
| `ios/App/App/AppDelegate.swift` | `application(_:didFinishLaunchingWithOptions:)` | Registers `SharedImagePlugin` with retry loop |
| | `registerSharedImagePlugin()` | Manually registers plugin instance on Capacitor bridge |
| | `applicationDidBecomeActive(_:)` | Calls `checkForSharedImageOnActivation()` |
| | `checkForSharedImageOnActivation()` | Checks ready flag, clears it, posts `SharedPhotoReady` NSNotification |
| | `application(_:open:options:)` | Forwards URL opens (including `timesafari://`) to Capacitor |
| `ios/App/App/App.entitlements` | — | Grants App Group `group.app.timesafari.share` |
### JavaScript Layer (Consumer)
| File | Method | Responsibility |
|------|--------|----------------|
| `src/plugins/SharedImagePlugin.ts` | — | Registers Capacitor plugin name `SharedImage` |
| `src/plugins/definitions.ts` | — | TypeScript interface for `getSharedImage` / `hasSharedImage` |
| `src/main.capacitor.ts` | `checkAndStoreNativeSharedImage()` | Calls `SharedImage.getSharedImage()`, stores in temp DB; guarded by `isProcessingSharedImage` lock |
| | `storeSharedImageInTempDB()` | Clears old temp row, inserts base64 data URL into SQLite `temp` table |
| | `checkForSharedImageAndNavigate()` | Checks native share, navigates to `/shared-photo` on success |
| | `handleDeepLink()` | Handles `timesafari://` empty-path URLs from share extension on iOS |
| | `registerDeepLinkListener()` | Registers Capacitor `appUrlOpen` listener |
| `src/views/SharedPhotoView.vue` | `mounted()` / `onRouteQueryChange()` | Loads image from temp DB for display |
| | `loadSharedImage()` | Reads `SHARED_PHOTO_BASE64_KEY` from temp DB; **deletes temp row** after load |
| `src/router/index.ts` | — | Defines `/shared-photo` route |
| `src/libs/util.ts` | `SHARED_PHOTO_BASE64_KEY` | Temp DB key constant (`"shared-photo-base64"`) |
---
## Read / Write / Delete Inventory
### Writes — Shared Image Metadata (App Group UserDefaults)
| File | Method | Keys Written |
|------|--------|--------------|
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
| `ShareViewController.swift` | `setSharedPhotoReadyFlag()` | `sharedPhotoReady` (= `true`) |
### Writes — Shared Image Files (App Group Container)
| File | Method | Details |
|------|--------|---------|
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `imageData.write(to:)` at `{containerURL}/{actualFileName}` |
### Reads — Shared Image Metadata (App Group UserDefaults)
| File | Method | Keys Read |
|------|--------|-----------|
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
| `SharedImageUtility.swift` | `hasSharedImage()` | `sharedPhotoFilePath` |
| `SharedImageUtility.swift` | `isSharedPhotoReady()` | `sharedPhotoReady` |
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `isSharedPhotoReady()`) |
### Reads — Shared Image Files (App Group Container)
| File | Method | Details |
|------|--------|---------|
| `ShareViewController.swift` | `processSharedImage` (URL path) | Reads source image via `Data(contentsOf: url)` from security-scoped URL |
| `SharedImageUtility.swift` | `getSharedImageData()` | `Data(contentsOf: fileURL)` from App Group container |
| `SharedImageUtility.swift` | `hasSharedImage()` | `FileManager.fileExists(atPath:)` only (no data read) |
### Deletes — Shared Image Metadata (App Group UserDefaults)
| File | Method | Keys Removed |
|------|--------|--------------|
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoBase64` (legacy cleanup) |
| `SharedImageUtility.swift` | `clearSharedPhotoReadyFlag()` | `sharedPhotoReady` |
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `clearSharedPhotoReadyFlag()`) |
**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`):
| Keys / files | Mechanism |
|--------------|-----------|
| `sharedPhotoFilePath` | `userDefaults.removeObject(forKey:)` |
| `sharedPhotoFileName` | `userDefaults.removeObject(forKey:)` |
| Image file at `sharedPhotoFilePath` | `FileManager.removeItem(at:)` |
| `userDefaults.synchronize()` after deletion | Called after metadata/file removal |
### Deletes — Shared Image Files (App Group Container)
| File | Method | Details |
|------|--------|---------|
| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | Removes previous pending share file at prior `sharedPhotoFilePath` before write |
**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`):
| Operation | Mechanism |
|-----------|-----------|
| Delete image file after successful read | `FileManager.removeItem(at: fileURL)` |
### Secondary Storage (Post-Native Consumption)
After native read, image data lives in SQLite `temp` table under key `shared-photo-base64`:
| File | Method | Operation |
|------|--------|-----------|
| `main.capacitor.ts` | `storeSharedImageInTempDB()` | **DELETE** old row, then **INSERT OR REPLACE** |
| `SharedPhotoView.vue` | `loadSharedImage()` | **READ** then **DELETE** temp row |
---
## Timing, Delays, Retries, and Polling
| Location | Mechanism | Values | Purpose |
|----------|-----------|--------|---------|
| `AppDelegate.swift` `didFinishLaunching` | Plugin registration retry loop | Initial delay **0.5s**; up to **5** attempts; backoff **0.5s × attempt** | Ensure Capacitor bridge exists before registering `SharedImagePlugin` |
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** (iOS only) | Deferred check for shared image on cold launch |
| `main.capacitor.ts` startup | `setTimeout` | **2000ms** | Deferred registration of `appUrlOpen` deep-link listener |
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** | Log app initialization status |
| `main.capacitor.ts` | `CapacitorApp.addListener("appStateChange")` | On every `isActive === true` | Re-check shared image when app foregrounds |
| `main.capacitor.ts` | `isProcessingSharedImage` flag | Synchronous JS lock | Prevents concurrent `checkAndStoreNativeSharedImage()` calls |
| `main.capacitor.ts` | Comment at line 214 | References polling | **Stale comment**`checkAndStoreNativeSharedImage()` does **not** poll or retry |
**No native polling or retry** exists for reading App Group data. `hasSharedImage()` is exposed but **never called** from application JS code.
---
## Current Startup Detection Points
| # | Layer | Trigger | File | Method | Action |
|---|-------|---------|------|--------|--------|
| 1 | Native | App becomes active (cold start + resume) | `AppDelegate.swift` | `applicationDidBecomeActive``checkForSharedImageOnActivation` | Reads `sharedPhotoReady` flag; clears flag; posts `SharedPhotoReady` NSNotification *(no JS listener)* |
| 2 | JS | Module load + 1000ms delay | `main.capacitor.ts` | `setTimeout``checkForSharedImageAndNavigate` | Calls `SharedImage.getSharedImage()`, stores in temp DB, navigates to `/shared-photo` |
| 3 | JS | App foreground | `main.capacitor.ts` | `appStateChange` listener → `checkForSharedImageAndNavigate` | Same as above |
| 4 | JS | Deep link `timesafari://` | `main.capacitor.ts` | `appUrlOpen``handleDeepLink``checkAndStoreNativeSharedImage` | iOS-only empty-path URL handling; navigates to `/shared-photo` |
| 5 | Native | URL open | `AppDelegate.swift` | `application(_:open:options:)` | Forwards to Capacitor `ApplicationDelegateProxy` (enables #4) |
| 6 | Native | Cold launch plugin setup | `AppDelegate.swift` | `didFinishLaunching``tryRegister` | Registers `SharedImagePlugin` (not a share check, but required for JS reads) |
**Note:** Detection points 14 can all fire for a single share event. Only the JS paths (#24) actually read and consume the image.
---
## Current Deletion Points
### App Group (Native)
| When | File | Method | What is deleted |
|------|------|--------|-----------------|
| Before new share write | `ShareViewController.swift` | `storeImageData` | Previous pending share file at prior `sharedPhotoFilePath` |
| Legacy cleanup on write | `ShareViewController.swift` | `storeImageData` | `sharedPhotoBase64` UserDefaults key |
| On app activation (flag only) | `AppDelegate.swift` | `checkForSharedImageOnActivation` | `sharedPhotoReady` flag |
**Removed in Phase 1C** (no longer deleted on retrieve):
| When | File | Method | What was deleted |
|------|------|--------|------------------|
| On successful read | `SharedImageUtility.swift` | `getSharedImageData` | `sharedPhotoFilePath`, `sharedPhotoFileName`, image file |
### Temp Database (JS)
| When | File | Method | What is deleted |
|------|------|--------|-----------------|
| Before storing new share | `main.capacitor.ts` | `storeSharedImageInTempDB` | Prior `shared-photo-base64` temp row |
| After view loads image | `SharedPhotoView.vue` | `loadSharedImage` | `shared-photo-base64` temp row |
**Important:** Native image file and metadata persist after `getSharedImageData()` (Phase 1C). Cleanup is deferred to a later phase. The `sharedPhotoReady` flag is still cleared independently by `AppDelegate` on activation.
---
## Potential Race Conditions
1. **Multiple JS detection paths, repeatable native read.** `applicationDidBecomeActive`, the 1000ms startup timer, `appStateChange`, and `appUrlOpen` can all invoke `checkAndStoreNativeSharedImage()` close together. Since Phase 1C, `getSharedImageData()` is read-only and returns the same data on every call until a new share overwrites metadata or explicit cleanup is added. The `isProcessingSharedImage` JS lock still reduces duplicate temp-DB writes and navigations.
2. **Deep-link listener registered 2 seconds after mount.** The share extension opens `timesafari://` immediately. If Capacitor does not buffer the launch URL until the `appUrlOpen` listener is registered (at T+2000ms), the deep-link path may be missed on cold start. The 1000ms startup check and `appStateChange` paths partially compensate.
3. **Plugin registration vs. first `getSharedImage()` call.** `SharedImagePlugin` is registered with up to 5 retries starting at T+500ms. A `getSharedImage()` call before registration completes will fail. The 1000ms startup delay usually avoids this, but `appStateChange` can fire earlier.
4. **`sharedPhotoReady` flag cleared before JS reads image.** `AppDelegate.checkForSharedImageOnActivation` clears the flag and posts `SharedPhotoReady`, but no JavaScript code listens for that NSNotification. The flag is therefore a redundant signal; reliance is entirely on file/metadata presence. If file write failed but flag were set, the flag would be cleared with no image available (current code sets flag only after successful `storeImageData`).
5. **`SharedPhotoReady` NSNotification is a dead signal.** Posted in `AppDelegate` but not bridged to Capacitor/JS. All actual consumption happens through JS-initiated `getSharedImage()` calls.
6. **Concurrent share while app is open.** A second share overwrites the App Group file and metadata. If the first share has already been read into temp DB but the user has not yet reached `SharedPhotoView`, the second share can replace native data; navigation refresh via `_refresh` query param handles re-navigation but temp DB overwrite in `storeSharedImageInTempDB` can clobber an in-flight first image.
7. **Extension `completeRequest` timing.** `completeRequest` runs in the `processSharedImage` completion handler after `storeImageData`, flag set, and `openMainApp` — so the file should exist before the extension exits. However, `loadItem` is asynchronous; if the extension process is terminated aggressively by iOS after `completeRequest`, this is generally safe because all writes complete in the callback before completion.
8. **Stale comment implies polling that does not exist.** `handleDeepLink` comments reference internal polling in `checkAndStoreNativeSharedImage`, but no retry loop exists. A single failed read at the wrong moment is not retried on iOS (unlike Android's multi-delay startup checks).
9. **`hasSharedImage()` unused.** A non-destructive pre-check is available natively but JS always calls `getSharedImage()` directly. Since Phase 1C both methods are non-destructive on the native layer.
---
## Share ID Tracking
**Implemented:** 2026-06-23 (Phase 1A)
Phase 1A adds a unique share identifier to the iOS share flow for observability and future reliability work. Existing retrieval and deletion behavior is unchanged.
### Identifier
| Property | Value |
|----------|-------|
| UserDefaults key | `sharedPhotoShareId` |
| Format | `UUID().uuidString` (e.g. `A1B2C3D4-E5F6-7890-ABCD-EF1234567890`) |
| Generated in | `ShareViewController.processSharedImage` when the first image attachment is found |
| Persisted in | `ShareViewController.storeImageData` alongside `sharedPhotoFilePath` and `sharedPhotoFileName` |
### Logging
All log lines use the prefix `[ShareTarget]` and include `shareId=<id>`:
| Event | File | Method | When |
|-------|------|--------|------|
| share received | `ShareViewController.swift` | `processSharedImage` | UUID generated before `loadItem` |
| file stored | `ShareViewController.swift` | `storeImageData` | After successful `imageData.write(to:)` |
| metadata stored | `ShareViewController.swift` | `storeImageData` | After UserDefaults `synchronize()` |
| share retrieved | `SharedImageUtility.swift` | `getSharedImageData` | After successful file read (Phase 1C log format) |
Example log sequence for a single share:
```
[ShareTarget] share received shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890
[ShareTarget] file stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg
[ShareTarget] metadata stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg
[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 retrieved
[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 left intact after retrieval
```
### Phase 1A Scope (Intentionally Unchanged)
- `getSharedImageData()` still returns only `base64` and `fileName` to JavaScript
- `sharedPhotoShareId` is not deleted on retrieve (cleanup deferred to a later phase)
- `hasSharedImage()`, `isSharedPhotoReady()`, and JS consumption paths are unchanged
- Android code is unchanged
### Write Inventory Addition
| File | Method | Key Written |
|------|--------|-------------|
| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | `sharedPhotoShareId` |
### Read Inventory Addition
| File | Method | Key Read |
|------|--------|----------|
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoShareId` (logging only) |
---
## Unique Stored Filenames
**Implemented:** 2026-06-23 (Phase 1B)
Phase 1B eliminates on-disk filename collisions by storing each shared image under a UUID-based filename while preserving the original filename as metadata for consumers.
### On-Disk vs Metadata
| Field | UserDefaults key | Example | Purpose |
|-------|------------------|---------|---------|
| Stored filename | `sharedPhotoFilePath` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg` | Unique file in App Group container |
| Original filename | `sharedPhotoFileName` | `vacation-photo.jpg` | Returned to JS as `fileName` |
| Share ID | `sharedPhotoShareId` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890` | Correlates logs across extension and main app |
Stored filename format: `<shareId>.<extension>`, where extension is taken from the original filename (defaults to `jpg` when absent).
### Implementation
| File | Method | Change |
|------|--------|--------|
| `ShareViewController.swift` | `fileExtension(from:)` | Extracts extension from original filename |
| `ShareViewController.swift` | `storedFileName(shareId:originalFileName:)` | Builds `<shareId>.<ext>` |
| `ShareViewController.swift` | `storeImageData` | Writes to stored filename; saves original in `sharedPhotoFileName` |
| `SharedImageUtility.swift` | `getSharedImageData` | Reads file via `sharedPhotoFilePath`; returns original `sharedPhotoFileName` |
When a new share arrives before the previous one is retrieved, `storeImageData` removes the file at the previous `sharedPhotoFilePath` before writing, preserving single-pending-share semantics.
### Logging (Phase 1B)
Store and retrieve events include all three identifiers:
```
[ShareTarget] file stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
[ShareTarget] metadata stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
[ShareTarget] shareId=<id> retrieved
[ShareTarget] shareId=<id> left intact after retrieval
```
### Phase 1B Scope (Intentionally Unchanged)
- `getSharedImageData()` still returns only `base64` and original `fileName` to JavaScript
- Android code is unchanged
---
## Non-Destructive Retrieval
**Implemented:** 2026-06-24 (Phase 1C)
Phase 1C makes native shared-content retrieval read-only. `getSharedImageData()` and `SharedImagePlugin.getSharedImage()` no longer delete App Group metadata or image files after a successful read. Explicit cleanup is deferred to a later phase.
### Behavior Change
| Aspect | Before Phase 1C | After Phase 1C |
|--------|-----------------|----------------|
| `sharedPhotoFilePath` after retrieve | Removed | Retained |
| `sharedPhotoFileName` after retrieve | Removed | Retained |
| `sharedPhotoShareId` after retrieve | Retained (since Phase 1A) | Retained |
| Image file after retrieve | Deleted | Retained |
| Return value to JS | `{ base64, fileName }` | Unchanged |
| Repeat `getSharedImage()` calls | Return `null` after first success | Return same data until overwritten or cleaned up |
### Logging
After a successful read:
```
[ShareTarget] shareId=<id> retrieved
[ShareTarget] shareId=<id> left intact after retrieval
```
### Removed Deletion Paths
All removal logic was in `SharedImageUtility.getSharedImageData()`:
| # | What was deleted | Code removed |
|---|------------------|--------------|
| 1 | `sharedPhotoFilePath` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFilePathKey)` |
| 2 | `sharedPhotoFileName` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFileNameKey)` |
| 3 | Image file at `{container}/{sharedPhotoFilePath}` | `FileManager.default.removeItem(at: fileURL)` |
| 4 | Post-deletion UserDefaults flush | `userDefaults.synchronize()` after removals |
`SharedImagePlugin.getSharedImage(_:)` delegated to `getSharedImageData()` and had no independent deletion logic. Comment updated to reflect read-only behavior.
### Phase 1C Scope (Intentionally Unchanged)
- No new cleanup or purge APIs added
- `clearSharedPhotoReadyFlag()` and share-extension write-side file removal unchanged
- JS temp DB deletion in `main.capacitor.ts` and `SharedPhotoView.vue` unchanged
- Android code unchanged
---
## Configuration References
| Resource | Value |
|----------|-------|
| App Group ID | `group.app.timesafari.share` |
| URL scheme | `timesafari://` |
| Extension bundle ID | `app.timesafari.TimeSafariShareExtension` |
| Main app bundle ID | `app.timesafari` |
| Capacitor plugin name | `SharedImage` |
| Temp DB key | `shared-photo-base64` (`SHARED_PHOTO_BASE64_KEY`) |
| Route | `/shared-photo` |

3
ios/.gitignore vendored
View File

@@ -17,6 +17,7 @@ App/App/config.xml
App/App.xcodeproj/xcuserdata/*.xcuserdatad/ App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate App/App.xcodeproj/*.xcuserstate
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md) # Generated by capacitor-assets at build time (not in repo). Fresh clones lack these
# folders; scripts/common.sh ensure_ios_capacitor_asset_directories creates them before generate.
App/App/Assets.xcassets/AppIcon.appiconset App/App/Assets.xcassets/AppIcon.appiconset
App/App/Assets.xcassets/Splash.imageset App/App/Assets.xcassets/Splash.imageset

View File

@@ -452,7 +452,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -508,7 +508,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
@@ -524,18 +524,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67; CURRENT_PROJECT_VERSION = 69;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia; INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.12; MARKETING_VERSION = 1.4.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -553,18 +553,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67; CURRENT_PROJECT_VERSION = 69;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia; INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.12; MARKETING_VERSION = 1.4.3;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -582,7 +582,7 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67; CURRENT_PROJECT_VERSION = 69;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -596,7 +596,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.12; MARKETING_VERSION = 1.4.3;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -620,7 +620,7 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67; CURRENT_PROJECT_VERSION = 69;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -634,7 +634,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.12; MARKETING_VERSION = 1.4.3;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -24,7 +24,8 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
public var pluginMethods: [CAPPluginMethod] { public var pluginMethods: [CAPPluginMethod] {
return [ return [
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise), CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise) CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise),
CAPPluginMethod(#selector(getShareExtensionDiagnostics(_:)), returnType: .promise)
] ]
} }
@@ -33,7 +34,7 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
/** /**
* Get shared image data from App Group UserDefaults * Get shared image data from App Group UserDefaults
* Returns base64 string and fileName, or null if no image exists * Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading * Read-only: native metadata and file are left intact after retrieval (Phase 1C)
*/ */
@objc public func getSharedImage(_ call: CAPPluginCall) { @objc public func getSharedImage(_ call: CAPPluginCall) {
guard let sharedData = SharedImageUtility.getSharedImageData() else { guard let sharedData = SharedImageUtility.getSharedImageData() else {
@@ -62,5 +63,12 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
"hasImage": hasImage "hasImage": hasImage
]) ])
} }
/**
* Diagnostic snapshot of Share Extension startup and pending share state
*/
@objc public func getShareExtensionDiagnostics(_ call: CAPPluginCall) {
call.resolve(SharedImageUtility.getShareExtensionDiagnostics())
}
} }

View File

@@ -13,6 +13,8 @@ public class SharedImageUtility {
private static let appGroupIdentifier = "group.app.timesafari.share" private static let appGroupIdentifier = "group.app.timesafari.share"
private static let sharedPhotoFileNameKey = "sharedPhotoFileName" private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath" private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private static let sharedPhotoShareIdKey = "sharedPhotoShareId"
private static let shareExtensionLastStartKey = "shareExtensionLastStart"
private static let sharedPhotoReadyKey = "sharedPhotoReady" private static let sharedPhotoReadyKey = "sharedPhotoReady"
/// Get the App Group container URL for accessing shared files /// Get the App Group container URL for accessing shared files
@@ -20,15 +22,35 @@ public class SharedImageUtility {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
} }
private static func logShareDiagnostic(method: String, userDefaults: UserDefaults?) {
let shareId = userDefaults?.string(forKey: sharedPhotoShareIdKey)
let filePath = userDefaults?.string(forKey: sharedPhotoFilePathKey)
let metadataExists = filePath != nil
let fileExists: Bool
if let filePath = filePath, let containerURL = appGroupContainerURL {
let fileURL = containerURL.appendingPathComponent(filePath)
fileExists = FileManager.default.fileExists(atPath: fileURL.path)
} else {
fileExists = false
}
let shareIdLog = shareId ?? "nil"
let filePathLog = filePath ?? "nil"
print("[ShareTarget] \(method) shareId=\(shareIdLog) sharedPhotoFilePath=\(filePathLog) metadataExists=\(metadataExists) fileExists=\(fileExists)")
}
/** /**
* Get shared image data from App Group container file * Get shared image data from App Group container file
* All images are stored as files for consistency and to avoid UserDefaults size limits * All images are stored as files for consistency and to avoid UserDefaults size limits
* Clears the data after reading to prevent re-reading * Read-only: metadata and file are left intact after retrieval (Phase 1C)
* *
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image * @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/ */
static func getSharedImageData() -> [String: String]? { static func getSharedImageData() -> [String: String]? {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
logShareDiagnostic(method: "getSharedImageData", userDefaults: userDefaults)
guard let userDefaults = userDefaults else {
return nil return nil
} }
@@ -39,6 +61,7 @@ public class SharedImageUtility {
} }
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg" let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
let fileURL = containerURL.appendingPathComponent(filePath) let fileURL = containerURL.appendingPathComponent(filePath)
// Read image data from file // Read image data from file
@@ -46,18 +69,13 @@ public class SharedImageUtility {
return nil return nil
} }
let resolvedShareId = shareId ?? "unknown"
print("[ShareTarget] shareId=\(resolvedShareId) retrieved")
print("[ShareTarget] shareId=\(resolvedShareId) left intact after retrieval")
// Convert file data to base64 for JavaScript consumption // Convert file data to base64 for JavaScript consumption
let base64String = imageData.base64EncodedString() let base64String = imageData.base64EncodedString()
// Clear the shared data after reading
userDefaults.removeObject(forKey: sharedPhotoFilePathKey)
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
// Remove the file
try? FileManager.default.removeItem(at: fileURL)
userDefaults.synchronize()
return ["base64": base64String, "fileName": fileName] return ["base64": base64String, "fileName": fileName]
} }
@@ -67,7 +85,10 @@ public class SharedImageUtility {
* @returns true if shared image file exists, false otherwise * @returns true if shared image file exists, false otherwise
*/ */
static func hasSharedImage() -> Bool { static func hasSharedImage() -> Bool {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier), let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
logShareDiagnostic(method: "hasSharedImage", userDefaults: userDefaults)
guard let userDefaults = userDefaults,
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey), let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
let containerURL = appGroupContainerURL else { let containerURL = appGroupContainerURL else {
return false return false
@@ -77,6 +98,41 @@ public class SharedImageUtility {
return FileManager.default.fileExists(atPath: fileURL.path) return FileManager.default.fileExists(atPath: fileURL.path)
} }
/**
* Diagnostic snapshot of Share Extension startup and pending share state
* Read-only: does not modify App Group storage
*/
static func getShareExtensionDiagnostics() -> [String: Any] {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return [
"shareExtensionLastStart": NSNull(),
"sharedPhotoShareId": NSNull(),
"sharedPhotoFilePath": NSNull(),
"fileExists": false
]
}
let shareExtensionLastStart = userDefaults.string(forKey: shareExtensionLastStartKey)
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey)
let fileExists: Bool
if let filePath = filePath, let containerURL = appGroupContainerURL {
let fileURL = containerURL.appendingPathComponent(filePath)
fileExists = FileManager.default.fileExists(atPath: fileURL.path)
} else {
fileExists = false
}
print("[ShareTarget] getShareExtensionDiagnostics shareExtensionLastStart=\(shareExtensionLastStart ?? "nil") sharedPhotoShareId=\(shareId ?? "nil") sharedPhotoFilePath=\(filePath ?? "nil") fileExists=\(fileExists)")
return [
"shareExtensionLastStart": shareExtensionLastStart ?? NSNull(),
"sharedPhotoShareId": shareId ?? NSNull(),
"sharedPhotoFilePath": filePath ?? NSNull(),
"fileExists": fileExists
]
}
/** /**
* Check if shared photo ready flag is set * Check if shared photo ready flag is set
* This flag is set by the Share Extension when image is ready * This flag is set by the Share Extension when image is ready

View File

@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0' platform :ios, '15.5'
use_frameworks! use_frameworks!
# workaround to avoid Xcode caching of Pods that requires # workaround to avoid Xcode caching of Pods that requires
@@ -30,9 +30,4 @@ end
post_install do |installer| post_install do |installer|
assertDeploymentTarget(installer) assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
end
end
end end

View File

@@ -1,94 +1,84 @@
PODS: PODS:
- Capacitor (6.2.1): - Capacitor (7.6.4):
- CapacitorCordova - CapacitorCordova
- CapacitorApp (6.0.2): - CapacitorApp (7.1.2):
- Capacitor - Capacitor
- CapacitorCamera (6.1.2): - CapacitorCamera (7.0.5):
- Capacitor - Capacitor
- CapacitorClipboard (6.0.2): - CapacitorClipboard (7.0.4):
- Capacitor - Capacitor
- CapacitorCommunitySqlite (6.0.2): - CapacitorCommunitySqlite (7.0.3):
- Capacitor - Capacitor
- SQLCipher - SQLCipher
- ZIPFoundation - ZIPFoundation
- CapacitorCordova (6.2.1) - CapacitorCordova (7.6.4)
- CapacitorFilesystem (6.0.3): - CapacitorFilesystem (7.1.8):
- Capacitor - Capacitor
- CapacitorMlkitBarcodeScanning (6.2.0): - IONFilesystemLib (~> 1.1.1)
- CapacitorMlkitBarcodeScanning (7.5.0):
- Capacitor - Capacitor
- GoogleMLKit/BarcodeScanning (= 5.0.0) - GoogleMLKit/BarcodeScanning (= 7.0.0)
- CapacitorShare (6.0.3): - CapacitorShare (7.0.4):
- Capacitor - Capacitor
- CapacitorStatusBar (6.0.2): - CapacitorStatusBar (7.0.6):
- Capacitor - Capacitor
- CapawesomeCapacitorFilePicker (6.2.0): - CapawesomeCapacitorFilePicker (7.2.0):
- Capacitor - Capacitor
- GoogleDataTransport (9.4.1): - GoogleDataTransport (10.1.0):
- GoogleUtilities/Environment (~> 7.7) - nanopb (~> 3.30910.0)
- nanopb (< 2.30911.0, >= 2.30908.0) - PromisesObjC (~> 2.4)
- PromisesObjC (< 3.0, >= 1.2) - GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/BarcodeScanning (5.0.0):
- GoogleMLKit/MLKitCore - GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 4.0.0) - MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/MLKitCore (5.0.0): - GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 10.0.0) - MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/DebugUtils (2.3.2): - GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Defines (= 2.3.2) - GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (2.3.2) - GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleToolboxForMac/Logger (2.3.2): - "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 2.3.2) - GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (2.3.2)": - GoogleUtilities/Environment (8.1.0):
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)":
- GoogleToolboxForMac/DebugUtils (= 2.3.2)
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)"
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)"
- GoogleUtilities/Environment (7.13.3):
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2) - GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3) - GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (7.13.3): - GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilitiesComponents (1.1.0):
- GoogleUtilities/Logger
- GTMSessionFetcher/Core (3.5.0) - GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta5) - IONFilesystemLib (1.1.2)
- MLKitBarcodeScanning (4.0.0): - MLImage (1.0.0-beta6)
- MLKitCommon (~> 10.0) - MLKitBarcodeScanning (6.0.0):
- MLKitVision (~> 6.0) - MLKitCommon (~> 12.0)
- MLKitCommon (10.0.0): - MLKitVision (~> 8.0)
- GoogleDataTransport (~> 9.0) - MLKitCommon (12.0.0):
- GoogleToolboxForMac/Logger (~> 2.1) - GoogleDataTransport (~> 10.0)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)" - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/UserDefaults (~> 7.0) - GoogleUtilities/Logger (~> 8.0)
- GoogleUtilitiesComponents (~> 1.0) - GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 1.1) - GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (6.0.0): - MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (~> 2.1) - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)" - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 1.1) - GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta5) - MLImage (= 1.0.0-beta6)
- MLKitCommon (~> 10.0) - MLKitCommon (~> 12.0)
- nanopb (2.30910.0): - nanopb (3.30910.0):
- nanopb/decode (= 2.30910.0) - nanopb/decode (= 3.30910.0)
- nanopb/encode (= 2.30910.0) - nanopb/encode (= 3.30910.0)
- nanopb/decode (2.30910.0) - nanopb/decode (3.30910.0)
- nanopb/encode (2.30910.0) - nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- SQLCipher (4.9.0): - SQLCipher (4.10.0):
- SQLCipher/standard (= 4.9.0) - SQLCipher/standard (= 4.10.0)
- SQLCipher/common (4.9.0) - SQLCipher/common (4.10.0)
- SQLCipher/standard (4.9.0): - SQLCipher/standard (4.10.0):
- SQLCipher/common - SQLCipher/common
- TimesafariDailyNotificationPlugin (2.0.0): - TimesafariDailyNotificationPlugin (4.0.1):
- Capacitor - Capacitor
- ZIPFoundation (0.9.19) - ZIPFoundation (0.9.20)
DEPENDENCIES: DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)" - "Capacitor (from `../../node_modules/@capacitor/ios`)"
@@ -110,8 +100,8 @@ SPEC REPOS:
- GoogleMLKit - GoogleMLKit
- GoogleToolboxForMac - GoogleToolboxForMac
- GoogleUtilities - GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher - GTMSessionFetcher
- IONFilesystemLib
- MLImage - MLImage
- MLKitBarcodeScanning - MLKitBarcodeScanning
- MLKitCommon - MLKitCommon
@@ -148,33 +138,33 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@timesafari/daily-notification-plugin" :path: "../../node_modules/@timesafari/daily-notification-plugin"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf Capacitor: 69dc07ebc6bd064747c5e76922f97e4862d9cc23
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79 CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
CapacitorClipboard: 4443c3cdb7c77b1533dfe3ff0f9f7756aa8579df CapacitorClipboard: d1f123674cf413125db816a45e8f70e8770972fc
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74 CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e CapacitorShare: 25f7fc5dd0e4edbde5d6801c6de5d14a8b450a41
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd CapawesomeCapacitorFilePicker: 0f4a913a00e39dd77213449f0d917e92f35a5ca9
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711 GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2 MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79 MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
nanopb: 438bc412db1928dac798aa6fd75726007be04262 MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f PODFILE CHECKSUM: 87c07d03f36ef38ab0c873802aee1ce9b5d34448
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -13,6 +13,8 @@ class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari.share" private let appGroupIdentifier = "group.app.timesafari.share"
private let sharedPhotoFileNameKey = "sharedPhotoFileName" private let sharedPhotoFileNameKey = "sharedPhotoFileName"
private let sharedPhotoFilePathKey = "sharedPhotoFilePath" private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private let sharedPhotoShareIdKey = "sharedPhotoShareId"
private let shareExtensionLastStartKey = "shareExtensionLastStart"
private let sharedImageFileName = "shared-image" private let sharedImageFileName = "shared-image"
/// Get the App Group container URL for storing shared files /// Get the App Group container URL for storing shared files
@@ -21,6 +23,14 @@ class ShareViewController: UIViewController {
} }
override func viewDidLoad() { override func viewDidLoad() {
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) {
let timestamp = ISO8601DateFormatter().string(from: Date())
userDefaults.set(timestamp, forKey: shareExtensionLastStartKey)
userDefaults.synchronize()
print("[ShareTarget] shareExtensionLastStart=\(timestamp)")
}
print("[ShareTarget] viewDidLoad started")
super.viewDidLoad() super.viewDidLoad()
// Set a minimal background (transparent or loading indicator) // Set a minimal background (transparent or loading indicator)
@@ -28,18 +38,32 @@ class ShareViewController: UIViewController {
// Process image immediately without showing UI // Process image immediately without showing UI
processAndOpenApp() processAndOpenApp()
print("[ShareTarget] viewDidLoad completed")
} }
private func processAndOpenApp() { private func processAndOpenApp() {
print("[ShareTarget] processAndOpenApp started")
// extensionContext is automatically available on UIViewController when used as extension principal class // extensionContext is automatically available on UIViewController when used as extension principal class
guard let context = extensionContext, guard let context = extensionContext,
let inputItems = context.inputItems as? [NSExtensionItem] else { let inputItems = context.inputItems as? [NSExtensionItem] else {
print("[ShareTarget] processAndOpenApp failed: missing extensionContext or inputItems")
print("[ShareTarget] completeRequest starting")
extensionContext?.completeRequest(returningItems: [], completionHandler: nil) extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
print("[ShareTarget] completeRequest completed")
print("[ShareTarget] processAndOpenApp completed")
return return
} }
let attachmentCount = inputItems.reduce(0) { count, item in
count + (item.attachments?.count ?? 0)
}
print("[ShareTarget] processAndOpenApp inputItems=\(inputItems.count) attachmentCount=\(attachmentCount)")
processSharedImage(from: inputItems) { [weak self] success in processSharedImage(from: inputItems) { [weak self] success in
guard let self = self, let context = self.extensionContext else { guard let self = self, let context = self.extensionContext else {
print("[ShareTarget] processAndOpenApp failed: self or extensionContext unavailable in completion")
print("[ShareTarget] processAndOpenApp completed")
return return
} }
@@ -48,43 +72,68 @@ class ShareViewController: UIViewController {
self.setSharedPhotoReadyFlag() self.setSharedPhotoReadyFlag()
// Open the main app (using minimal URL - app will detect shared data on activation) // Open the main app (using minimal URL - app will detect shared data on activation)
self.openMainApp() self.openMainApp()
} else {
print("[ShareTarget] processAndOpenApp failed: processSharedImage returned false")
} }
// Complete immediately - no UI shown // Complete immediately - no UI shown
print("[ShareTarget] completeRequest starting")
context.completeRequest(returningItems: [], completionHandler: nil) context.completeRequest(returningItems: [], completionHandler: nil)
print("[ShareTarget] completeRequest completed")
print("[ShareTarget] processAndOpenApp completed")
} }
} }
private func setSharedPhotoReadyFlag() { private func setSharedPhotoReadyFlag() {
print("[ShareTarget] setSharedPhotoReadyFlag started")
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("[ShareTarget] setSharedPhotoReadyFlag failed: UserDefaults unavailable for app group")
print("[ShareTarget] setSharedPhotoReadyFlag completed")
return return
} }
userDefaults.set(true, forKey: "sharedPhotoReady") userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize() userDefaults.synchronize()
print("[ShareTarget] setSharedPhotoReadyFlag success")
print("[ShareTarget] setSharedPhotoReadyFlag completed")
} }
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) { private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
let attachmentCount = items.reduce(0) { count, item in
count + (item.attachments?.count ?? 0)
}
print("[ShareTarget] processSharedImage started attachmentCount=\(attachmentCount)")
// Find the first image attachment // Find the first image attachment
for item in items { for item in items {
guard let attachments = item.attachments else { guard let attachments = item.attachments else {
print("[ShareTarget] processSharedImage skipping item with no attachments")
continue continue
} }
for attachment in attachments { for attachment in attachments {
// Skip non-image attachments // Skip non-image attachments
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else { guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
print("[ShareTarget] processSharedImage skipping non-image attachment")
continue continue
} }
let shareId = UUID().uuidString
print("[ShareTarget] processSharedImage found image attachment shareId=\(shareId) UTType=\(UTType.image.identifier)")
print("[ShareTarget] share received shareId=\(shareId)")
// Try to load raw data first to preserve original format // Try to load raw data first to preserve original format
// This preserves the original image format without conversion // This preserves the original image format without conversion
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
guard let self = self else { guard let self = self else {
print("[ShareTarget] processSharedImage failed: self unavailable in loadItem callback shareId=\(shareId)")
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
completion(false) completion(false)
return return
} }
if error != nil { if let error = error {
print("[ShareTarget] processSharedImage failed: loadItem error shareId=\(shareId) error=\(error.localizedDescription)")
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
completion(false) completion(false)
return return
} }
@@ -95,6 +144,7 @@ class ShareViewController: UIViewController {
var fileName: String = "shared-image" var fileName: String = "shared-image"
if let url = data as? URL { if let url = data as? URL {
print("[ShareTarget] processSharedImage loadItem returned URL shareId=\(shareId) url=\(url.lastPathComponent)")
// Most common case: Image provided as file URL - read raw data to preserve format // Most common case: Image provided as file URL - read raw data to preserve format
let accessing = url.startAccessingSecurityScopedResource() let accessing = url.startAccessingSecurityScopedResource()
defer { defer {
@@ -109,24 +159,37 @@ class ShareViewController: UIViewController {
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless) // Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
if imageData == nil, let image = UIImage(contentsOfFile: url.path) { if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
print("[ShareTarget] processSharedImage using UIImage PNG fallback shareId=\(shareId)")
imageData = image.pngData() imageData = image.pngData()
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png") fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
} else if imageData == nil {
print("[ShareTarget] processSharedImage failed: could not read image data from URL shareId=\(shareId)")
} }
} else if let data = data as? Data { } else if let data = data as? Data {
print("[ShareTarget] processSharedImage loadItem returned Data shareId=\(shareId)")
// Less common: Image provided as raw Data - use directly to preserve format // Less common: Image provided as raw Data - use directly to preserve format
imageData = data imageData = data
fileName = attachment.suggestedName ?? "shared-image" fileName = attachment.suggestedName ?? "shared-image"
} else {
print("[ShareTarget] processSharedImage failed: loadItem returned unexpected type shareId=\(shareId) type=\(String(describing: type(of: data)))")
} }
guard let finalImageData = imageData else { guard let finalImageData = imageData else {
print("[ShareTarget] processSharedImage failed: no image data shareId=\(shareId)")
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
completion(false) completion(false)
return return
} }
print("[ShareTarget] image loaded bytes=\(finalImageData.count) filename=\(fileName) shareId=\(shareId)")
// Store image as file in App Group container // Store image as file in App Group container
if self.storeImageData(finalImageData, fileName: fileName) { if self.storeImageData(finalImageData, fileName: fileName, shareId: shareId) {
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=true")
completion(true) completion(true)
} else { } else {
print("[ShareTarget] processSharedImage failed: storeImageData returned false shareId=\(shareId)")
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
completion(false) completion(false)
} }
} }
@@ -135,6 +198,8 @@ class ShareViewController: UIViewController {
} }
// No image found // No image found
print("[ShareTarget] processSharedImage failed: no image attachment found attachmentCount=\(attachmentCount)")
print("[ShareTarget] processSharedImage completed success=false")
completion(false) completion(false)
} }
@@ -146,48 +211,81 @@ class ShareViewController: UIViewController {
return "shared-image.\(newExtension)" return "shared-image.\(newExtension)"
} }
/// Extract file extension from original filename, defaulting to jpg when absent
private func fileExtension(from fileName: String) -> String {
let ext = (fileName as NSString).pathExtension
return ext.isEmpty ? "jpg" : ext.lowercased()
}
/// Build unique on-disk filename: <shareId>.<extension>
private func storedFileName(shareId: String, originalFileName: String) -> String {
return "\(shareId).\(fileExtension(from: originalFileName))"
}
/// Store image data as a file in the App Group container /// Store image data as a file in the App Group container
/// All images are stored as files regardless of size for consistency and simplicity /// All images are stored as files regardless of size for consistency and simplicity
/// Returns true if successful, false otherwise /// Returns true if successful, false otherwise
private func storeImageData(_ imageData: Data, fileName: String) -> Bool { private func storeImageData(_ imageData: Data, fileName: String, shareId: String) -> Bool {
print("[ShareTarget] storeImageData started shareId=\(shareId) bytes=\(imageData.count) filename=\(fileName)")
guard let containerURL = appGroupContainerURL else { guard let containerURL = appGroupContainerURL else {
print("[ShareTarget] storeImageData failed: app group container unavailable shareId=\(shareId)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
return false return false
} }
// Create file URL in the container using the actual filename let originalFileName = fileName.isEmpty ? "\(sharedImageFileName).jpg" : fileName
// Extract extension from fileName if present, otherwise use sharedImageFileName let storedFileName = storedFileName(shareId: shareId, originalFileName: originalFileName)
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName let fileURL = containerURL.appendingPathComponent(storedFileName)
let fileURL = containerURL.appendingPathComponent(actualFileName)
// Remove old file if it exists // Remove previously pending share file (metadata tracks one share at a time)
try? FileManager.default.removeItem(at: fileURL) if let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
let previousPath = userDefaults.string(forKey: sharedPhotoFilePathKey) {
let previousURL = containerURL.appendingPathComponent(previousPath)
if previousURL != fileURL {
try? FileManager.default.removeItem(at: previousURL)
}
}
// Write image data to file // Write image data to file
do { do {
try imageData.write(to: fileURL) try imageData.write(to: fileURL)
} catch { } catch {
print("[ShareTarget] storeImageData failed: file write error shareId=\(shareId) error=\(error.localizedDescription)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
return false return false
} }
print("[ShareTarget] file stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)")
// Store file path and filename in UserDefaults (small data, safe to store) // Store file path and filename in UserDefaults (small data, safe to store)
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("[ShareTarget] storeImageData failed: UserDefaults unavailable shareId=\(shareId)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
return false return false
} }
// Store relative path and filename // sharedPhotoFilePath = on-disk name; sharedPhotoFileName = original display name
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey) userDefaults.set(storedFileName, forKey: sharedPhotoFilePathKey)
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey) userDefaults.set(originalFileName, forKey: sharedPhotoFileNameKey)
userDefaults.set(shareId, forKey: sharedPhotoShareIdKey)
// Clean up any old base64 data that might exist // Clean up any old base64 data that might exist
userDefaults.removeObject(forKey: "sharedPhotoBase64") userDefaults.removeObject(forKey: "sharedPhotoBase64")
userDefaults.synchronize() userDefaults.synchronize()
print("[ShareTarget] metadata stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)")
print("[ShareTarget] storeImageData success shareId=\(shareId)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=true")
return true return true
} }
private func openMainApp() { private func openMainApp() {
print("[ShareTarget] openMainApp starting")
// Open the main app with minimal URL - app will detect shared data on activation // Open the main app with minimal URL - app will detect shared data on activation
guard let url = URL(string: "timesafari://") else { guard let url = URL(string: "timesafari://") else {
print("[ShareTarget] openMainApp failed: could not create timesafari:// URL")
print("[ShareTarget] openMainApp completed")
return return
} }
@@ -195,6 +293,7 @@ class ShareViewController: UIViewController {
while responder != nil { while responder != nil {
if let application = responder as? UIApplication { if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil) application.open(url, options: [:], completionHandler: nil)
print("[ShareTarget] openMainApp completed via UIApplication")
return return
} }
responder = responder?.next responder = responder?.next
@@ -202,6 +301,7 @@ class ShareViewController: UIViewController {
// Fallback: use extension context // Fallback: use extension context
extensionContext?.open(url, completionHandler: nil) extensionContext?.open(url, completionHandler: nil)
print("[ShareTarget] openMainApp completed via extensionContext fallback")
} }
} }

8692
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{ {
"name": "giftopia", "name": "giftopia",
"version": "1.3.13", "version": "1.4.3",
"description": "Giftopia App", "description": "Giftopia App",
"author": { "author": {
"name": "Gift Economies Team" "name": "Gift Economies Team"
@@ -13,6 +14,7 @@
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js", "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:prerequisites": "node scripts/check-prerequisites.js", "test:prerequisites": "node scripts/check-prerequisites.js",
"check:dependencies": "./scripts/check-dependencies.sh", "check:dependencies": "./scripts/check-dependencies.sh",
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"test:all": "npm run lint && tsc && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'", "test:all": "npm run lint && tsc && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on", "test:web": "npx playwright test -c playwright.config-local.ts --trace on",
"test:mobile": "./scripts/test-mobile.sh", "test:mobile": "./scripts/test-mobile.sh",
@@ -28,7 +30,7 @@
"auto-run:electron": "./scripts/auto-run.sh --platform=electron", "auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts", "build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js", "build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate", "build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
"assets:config": "npx tsx scripts/assets-config.ts", "assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts", "assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only", "assets:validate:android": "./scripts/build-android.sh --assets-only",
@@ -138,19 +140,19 @@
}, },
"dependencies": { "dependencies": {
"@capacitor-community/electron": "^5.0.1", "@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2", "@capacitor-community/sqlite": "^7.0.3",
"@capacitor-mlkit/barcode-scanning": "^6.0.0", "@capacitor-mlkit/barcode-scanning": "^7.5.0",
"@capacitor/android": "^6.2.0", "@capacitor/android": "^7.6.4",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^7.1.0",
"@capacitor/camera": "^6.0.0", "@capacitor/camera": "^7.0.5",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^7.6.4",
"@capacitor/clipboard": "^6.0.2", "@capacitor/clipboard": "^7.0.4",
"@capacitor/core": "^6.2.0", "@capacitor/core": "^7.6.4",
"@capacitor/filesystem": "^6.0.0", "@capacitor/filesystem": "^7.1.8",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^7.6.4",
"@capacitor/share": "^6.0.3", "@capacitor/share": "^7.0.4",
"@capacitor/status-bar": "^6.0.2", "@capacitor/status-bar": "^7.0.6",
"@capawesome/capacitor-file-picker": "^6.2.0", "@capawesome/capacitor-file-picker": "^7.2.0",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",

View File

@@ -222,8 +222,9 @@ build_ios_app() {
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
build_config="Debug" build_config="Debug"
# Any Simulator — avoids hardcoding a device name (e.g. iPhone 15 Pro) that may not exist in newer Xcode runtimes # Use device SDK — prebuilt MLKit frameworks (MLImage, MLKitBarcodeScanning) ship
destination="generic/platform=iOS Simulator" # iOS device slices only and cannot link against the iOS Simulator SDK.
destination="generic/platform=iOS"
else else
build_config="Release" build_config="Release"
destination="platform=iOS,id=auto" destination="platform=iOS,id=auto"
@@ -233,10 +234,16 @@ build_ios_app() {
cd ios/App cd ios/App
# Prevent pkgx-managed libs (e.g. zlib) from leaking into the iOS SDK linker.
unset LIBRARY_PATH
unset DYLD_LIBRARY_PATH
unset DYLD_FALLBACK_LIBRARY_PATH
# Build the app: # Build the app:
# -quiet: skip the huge export VAR dump (compiler warnings still show unless suppressed below). # -quiet: skip the huge export VAR dump (compiler warnings still show unless suppressed below).
# SWIFT_SUPPRESS_WARNINGS / GCC_WARN_INHIBIT_ALL_WARNINGS: quiet CLI output from Pods + plugins; # SWIFT_SUPPRESS_WARNINGS / GCC_WARN_INHIBIT_ALL_WARNINGS: quiet CLI output from Pods + plugins;
# build in Xcode for full diagnostics. Real errors still fail the build. # build in Xcode for full diagnostics. Real errors still fail the build.
local build_exit=0
xcodebuild -quiet \ xcodebuild -quiet \
-workspace App.xcworkspace \ -workspace App.xcworkspace \
-scheme "$scheme" \ -scheme "$scheme" \
@@ -247,10 +254,14 @@ build_ios_app() {
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
SWIFT_SUPPRESS_WARNINGS=YES \ SWIFT_SUPPRESS_WARNINGS=YES \
GCC_WARN_INHIBIT_ALL_WARNINGS=YES GCC_WARN_INHIBIT_ALL_WARNINGS=YES || build_exit=$?
cd ../.. cd ../..
if [ $build_exit -ne 0 ]; then
return $build_exit
fi
log_success "iOS app built successfully" log_success "iOS app built successfully"
} }
@@ -413,6 +424,7 @@ fi
# Handle assets-only mode # Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets" log_info "Assets-only mode: generating assets"
ensure_ios_capacitor_asset_directories
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
log_success "Assets generation completed successfully!" log_success "Assets generation completed successfully!"
exit 0 exit 0
@@ -562,6 +574,7 @@ safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaroun
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6 safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 7: Generate assets # Step 7: Generate assets
ensure_ios_capacitor_asset_directories
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 8: Build iOS app # Step 8: Build iOS app

View File

@@ -337,6 +337,27 @@ parse_args() {
fi fi
} }
# iOS: capacitor-assets writes into AppIcon.appiconset and Splash.imageset under
# Assets.xcassets. Those paths are gitignored (generated). On a fresh clone the
# folders and Contents.json are missing; the tool opens Contents.json before writing
# PNGs, so we create minimal asset-catalog stubs when absent.
ensure_ios_capacitor_asset_directories() {
local base="ios/App/App/Assets.xcassets"
if [ ! -d "$base" ]; then
log_warn "Missing $base — cannot prepare iOS asset directories"
return 0
fi
mkdir -p "$base/AppIcon.appiconset" "$base/Splash.imageset"
local minimal_contents='{"images":[],"info":{"author":"xcode","version":1}}'
if [ ! -f "$base/AppIcon.appiconset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/AppIcon.appiconset/Contents.json"
fi
if [ ! -f "$base/Splash.imageset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/Splash.imageset/Contents.json"
fi
log_debug "Ensured iOS capacitor-assets output directories exist"
}
# Export functions for use in child scripts # Export functions for use in child scripts
export -f log_info log_success log_warn log_error log_debug log_step export -f log_info log_success log_warn log_error log_debug log_step
export -f measure_time print_header print_footer export -f measure_time print_header print_footer
@@ -345,3 +366,4 @@ export -f safe_execute check_venv get_git_hash
export -f clean_build_artifacts validate_env_vars export -f clean_build_artifacts validate_env_vars
export -f setup_build_env setup_app_directories load_env_file print_env_vars export -f setup_build_env setup_app_directories load_env_file print_env_vars
export -f print_usage parse_args export -f print_usage parse_args
export -f ensure_ios_capacitor_asset_directories

View File

@@ -8,14 +8,14 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */ properties * * @author Matthew Raymer */
<template> <template>
<div id="sectionGiftedGiver"> <div id="sectionGiftedGiver">
<label class="block font-bold mb-1"> <label class="block font-semibold text-lg capitalize text-center">
{{ stepLabel }} {{ stepLabel }}
</label> </label>
<!-- Toggle link for entity type selection --> <!-- Toggle link for entity type selection -->
<div class="text-right mb-4"> <div class="text-center mb-4">
<button <button
type="button" type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium" class="text-xs text-blue-600 hover:underline uppercase"
@click="handleToggleEntityType" @click="handleToggleEntityType"
> >
{{ toggleLinkText }} {{ toggleLinkText }}

View File

@@ -129,7 +129,7 @@
@click="disabled ? notifyLocked() : addGroup()" @click="disabled ? notifyLocked() : addGroup()"
> >
<font-awesome icon="plus" class="text-sm" /> <font-awesome icon="plus" class="text-sm" />
New Group New Do-Not-Pair Group
</button> </button>
</div> </div>
</template> </template>

View File

@@ -6,8 +6,8 @@
export enum AppString { export enum AppString {
// This is used in titles and verbiage inside the app. // This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest. // There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari", APP_NAME = "Giftopia",
APP_NAME_NO_SPACES = "TimeSafari", APP_NAME_NO_SPACES = APP_NAME,
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch", PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch", TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",

View File

@@ -359,6 +359,23 @@ async function checkForSharedImageAndNavigate() {
} }
try { try {
// TEMPORARY SHARE TARGET DIAGNOSTICS
if (Capacitor.getPlatform() === "ios") {
try {
const diagnostics = await SharedImage.getShareExtensionDiagnostics();
logger.info(`[ShareTarget] Diagnostics ${JSON.stringify(diagnostics)}`);
} catch (diagnosticsError) {
logger.info(
`[ShareTarget] Diagnostics ${JSON.stringify({
error:
diagnosticsError instanceof Error
? diagnosticsError.message
: String(diagnosticsError),
})}`,
);
}
}
logger.debug("[Main] 🔍 Checking for shared image on app activation"); logger.debug("[Main] 🔍 Checking for shared image on app activation");
const imageResult = await checkAndStoreNativeSharedImage(); const imageResult = await checkAndStoreNativeSharedImage();

View File

@@ -4,7 +4,11 @@
*/ */
import { WebPlugin } from "@capacitor/core"; import { WebPlugin } from "@capacitor/core";
import type { SharedImagePlugin, SharedImageResult } from "./definitions"; import type {
SharedImagePlugin,
SharedImageResult,
ShareExtensionDiagnostics,
} from "./definitions";
export class SharedImagePluginWeb export class SharedImagePluginWeb
extends WebPlugin extends WebPlugin
@@ -18,4 +22,13 @@ export class SharedImagePluginWeb
async hasSharedImage(): Promise<{ hasImage: boolean }> { async hasSharedImage(): Promise<{ hasImage: boolean }> {
return { hasImage: false }; return { hasImage: false };
} }
async getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics> {
return {
shareExtensionLastStart: null,
sharedPhotoShareId: null,
sharedPhotoFilePath: null,
fileExists: false,
};
}
} }

View File

@@ -7,11 +7,18 @@ export interface SharedImageResult {
fileName: string; fileName: string;
} }
export interface ShareExtensionDiagnostics {
shareExtensionLastStart: string | null;
sharedPhotoShareId: string | null;
sharedPhotoFilePath: string | null;
fileExists: boolean;
}
export interface SharedImagePlugin { export interface SharedImagePlugin {
/** /**
* Get shared image data from native layer * Get shared image data from native layer
* Returns base64 string and fileName, or null if no image exists * Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading * Read-only on iOS: native metadata and file are left intact after retrieval (Phase 1C)
*/ */
getSharedImage(): Promise<SharedImageResult | null>; getSharedImage(): Promise<SharedImageResult | null>;
@@ -20,4 +27,9 @@ export interface SharedImagePlugin {
* Useful for quick checks before calling getSharedImage() * Useful for quick checks before calling getSharedImage()
*/ */
hasSharedImage(): Promise<{ hasImage: boolean }>; hasSharedImage(): Promise<{ hasImage: boolean }>;
/**
* Diagnostic snapshot of Share Extension startup and pending share state (iOS)
*/
getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics>;
} }

View File

@@ -123,10 +123,10 @@ export class CapacitorQRScanner implements QRScannerService {
// Add listener for barcode scans // Add listener for barcode scans
const handle = await BarcodeScanner.addListener( const handle = await BarcodeScanner.addListener(
"barcodeScanned", "barcodesScanned",
(result) => { (result) => {
if (this.scanListener && result.barcode?.rawValue) { if (this.scanListener && result.barcodes?.[0]?.rawValue) {
this.scanListener.onScan(result.barcode.rawValue); this.scanListener.onScan(result.barcodes[0].rawValue);
} }
}, },
); );

View File

@@ -62,10 +62,6 @@ export class NativeNotificationService implements NotificationServiceInterface {
return true; return true;
} }
/**
* Request notification permissions from the OS
* Shows native permission dialog on first call
*/
/** /**
* Request notification permissions from the OS * Request notification permissions from the OS
* Shows native permission dialog on first call * Shows native permission dialog on first call

View File

@@ -1402,54 +1402,6 @@ export default class AccountViewView extends Vue {
}, 150); }, 150);
} }
/**
* Toggle dev-only 10-minute rollover for daily reminder. Saves the setting and,
* if reminder is already on, reschedules so the plugin uses the new interval.
*/
async toggleReminderFastRollover(): Promise<void> {
const next = !this.reminderFastRolloverForTesting;
await this.$saveSettings({
reminderFastRolloverForTesting: next,
});
this.reminderFastRolloverForTesting = next;
if (this.notifyingReminder) {
try {
const service = NotificationService.getInstance();
if (Capacitor.getPlatform() !== "android") {
await service.cancelDailyNotification();
}
const time24h = this.parseTimeTo24Hour(this.notifyingReminderTime);
const title = "Daily Reminder";
const body =
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
await service.scheduleDailyNotification({
time: time24h,
title,
body,
priority: "normal",
...(next ? { rolloverIntervalMinutes: 10 } : {}),
});
this.notify.success(
next
? "Reminder will repeat every 10 minutes (testing)."
: "Reminder will repeat daily (24h).",
TIMEOUTS.STANDARD,
);
} catch (err) {
logger.error(
"[AccountViewView] Reschedule after fast-rollover toggle failed:",
err,
);
this.notify.error(
"Failed to update reminder interval. Please try again.",
TIMEOUTS.STANDARD,
);
}
}
}
/** /**
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22") * Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
*/ */
@@ -2116,6 +2068,10 @@ export default class AccountViewView extends Vue {
hasLocation: result.includeLocation, hasLocation: result.includeLocation,
}); });
if (response.data.userMessage) {
this.notify.info(response.data.userMessage);
}
return result; return result;
} else { } else {
logger.debug("[AccountViewView] No profile data found in response:", { logger.debug("[AccountViewView] No profile data found in response:", {
@@ -2123,6 +2079,10 @@ export default class AccountViewView extends Vue {
hasData: !!response.data, hasData: !!response.data,
hasDataData: !!(response.data && response.data.data), hasDataData: !!(response.data && response.data.data),
}); });
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
} }
return null; return null;
@@ -2229,6 +2189,10 @@ export default class AccountViewView extends Vue {
status: response.status, status: response.status,
}); });
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam // Handle specific HTTP status codes cleanly to suppress console spam
@@ -2345,6 +2309,10 @@ export default class AccountViewView extends Vue {
status: response.status, status: response.status,
}); });
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam // Handle specific HTTP status codes cleanly to suppress console spam

View File

@@ -1,4 +1,6 @@
<template> <template>
<QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<TopMessage /> <TopMessage />

View File

@@ -87,7 +87,7 @@
</li> </li>
</ul> </ul>
<p class="mt-4"> <div class="mt-4">
<!-- <!--
This section is for Twilio's A2P Campaign requirements. This section is for Twilio's A2P Campaign requirements.
They say: Ensure it includes the program name, description, message/data rates, message frequency, support contact info, and opt-out instructions (HELP and STOP in bold). They say: Ensure it includes the program name, description, message/data rates, message frequency, support contact info, and opt-out instructions (HELP and STOP in bold).
@@ -125,7 +125,7 @@
along with the explicit signed permission to use it for searches. along with the explicit signed permission to use it for searches.
</li> </li>
</ul> </ul>
</p> </div>
</div> </div>
<!-- eslint-enable --> <!-- eslint-enable -->
</section> </section>

View File

@@ -109,12 +109,14 @@ Raymer * @version 1.0.0 */
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven"> <div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
<!-- Record Quick-Action --> <!-- Record Quick-Action -->
<div class="mb-6"> <div
<div class="flex gap-2 items-center mb-2"> class="bg-slate-100 border border-slate-300 rounded-md px-4 py-2.5 mb-4"
>
<div class="flex gap-2 justify-between items-center ps-8">
<!-- Thank button - always visible and unchanged --> <!-- Thank button - always visible and unchanged -->
<button <button
type="button" type="button"
class="text-center text-base uppercase bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 px-4 py-3 rounded-full" class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg"
@click="openPersonDialog()" @click="openPersonDialog()"
> >
<font-awesome icon="plus" /> <font-awesome icon="plus" />
@@ -122,25 +124,29 @@ Raymer * @version 1.0.0 */
</button> </button>
<!-- Plus button - appears when scrolled, positioned over house-chimney icon --> <!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
<transition <transition
enter-active-class="transition-all duration-1000 ease-out" enter-active-class="transition-all duration-500 ease-out"
leave-active-class="transition-all duration-1000 ease-in" leave-active-class="transition-all duration-500 ease-in"
enter-from-class="scale-0" enter-from-class="opacity-0"
enter-to-class="scale-100" enter-to-class="opacity-100"
leave-from-class="scale-100" leave-from-class="opacity-100"
leave-to-class="scale-0" leave-to-class="opacity-0"
>
<div
v-if="isScrolled"
class="bg-gradient-to-t from-white to-transparent fixed inset-x-0 bottom-[calc(4.75rem+max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))] px-4 pb-3 w-full z-[49]"
> >
<button <button
v-if="isScrolled"
type="button" type="button"
class="fixed bottom-10 p-4 w-14 h-14 z-50 text-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-full flex items-center justify-center" class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg drop-shadow-[0_0_10px_rgba(255,255,255,1)]"
:style="getButtonPosition()"
@click="openPersonDialog()" @click="openPersonDialog()"
> >
<font-awesome icon="plus" /> <font-awesome icon="plus" />
<span>Thank</span>
</button> </button>
</div>
</transition> </transition>
<button <button
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full" class="block text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openGiftedPrompts()" @click="openGiftedPrompts()"
> >
<font-awesome <font-awesome
@@ -559,7 +565,7 @@ export default class HomeView extends Vue {
const scrollTop = appElement const scrollTop = appElement
? appElement.scrollTop ? appElement.scrollTop
: window.pageYOffset || document.documentElement.scrollTop || 0; : window.pageYOffset || document.documentElement.scrollTop || 0;
const shouldBeScrolled = scrollTop > 100; const shouldBeScrolled = scrollTop > 120;
if (this.isScrolled !== shouldBeScrolled) { if (this.isScrolled !== shouldBeScrolled) {
this.isScrolled = shouldBeScrolled; this.isScrolled = shouldBeScrolled;
} }

View File

@@ -366,7 +366,8 @@
Do Not Pair Together Do Not Pair Together
</h4> </h4>
<p class="text-xs text-gray-500 mb-2"> <p class="text-xs text-gray-500 mb-2">
People in the same group will not be matched with each other. People in the same do-not-pair group will not be matched with each
other.
</p> </p>
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2"> <p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
Erase matches to change restrictions. Erase matches to change restrictions.

View File

@@ -130,11 +130,13 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Refresh home view and check gift // Refresh home view and check gift
await page.goto('./'); await page.goto('./');
// Wait for async data (e.g. "new offers" banner) to finish loading so the layout
// is stable before clicking — otherwise a layout shift can redirect the click.
await page.waitForLoadState('networkidle');
// Firefox complains on load the initial feed here when we use the test server. const item = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
// It may be similar to the CORS problem below. await expect(item).toBeVisible();
const item = await page.locator('li:first-child').filter({ hasText: finalTitle }); await item.click();
await item.locator('[data-testid="circle-info-link"]').click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible(); await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();