Compare commits
20 Commits
16kb-pages
...
402bd2681f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402bd2681f | ||
|
|
498a4926bf | ||
|
|
f0ca49b5dc | ||
|
|
07463246f0 | ||
|
|
79ceebbd1d | ||
|
|
ddbd07f315 | ||
|
|
35a6a6bfb3 | ||
|
|
08a55202f5 | ||
| ec41dd52d5 | |||
| 463db39a6b | |||
| fe97dff752 | |||
|
|
903047f13b | ||
|
|
48be234af4 | ||
| 6c0907d905 | |||
|
|
8d8bcf2a7e | ||
| a4b47904c8 | |||
|
|
bb890baacf | ||
| dae23300fe | |||
| 9e401febea | |||
| cd4b279703 |
@@ -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:
|
||||
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 68 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.2;/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.
|
||||
#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:
|
||||
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 68/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.4.2"/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.4.3"/g' android/app/build.gradle
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
@@ -6,6 +6,13 @@ 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).
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
@@ -37,8 +37,8 @@ android {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 68
|
||||
versionName "1.4.2"
|
||||
versionCode 69
|
||||
versionName "1.4.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.timesafari.dailynotification.FetchContext;
|
||||
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||
import org.timesafari.dailynotification.NotificationContent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -47,22 +46,11 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
|
||||
// This should query the TimeSafari API for notification content
|
||||
// using the configured apiBaseUrl, activeDid, and jwtToken
|
||||
|
||||
// For now, return a placeholder notification
|
||||
long scheduledTime = fetchContext.scheduledTime != null
|
||||
? fetchContext.scheduledTime
|
||||
: System.currentTimeMillis() + 60000; // 1 minute from now
|
||||
|
||||
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;
|
||||
// Not implemented yet: return no content rather than fabricating a
|
||||
// placeholder notification (previously hardcoded "Check your starred
|
||||
// projects for updates!", which showed on every app startup).
|
||||
Log.d(TAG, "Content fetching not yet implemented; returning no notifications");
|
||||
return Collections.<NotificationContent>emptyList();
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Fetch failed", e);
|
||||
|
||||
449
doc/share-target-ios-audit.md
Normal file
449
doc/share-target-ios-audit.md
Normal 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 1–4 can all fire for a single share event. Only the JS paths (#2–4) 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` |
|
||||
@@ -524,7 +524,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -535,7 +535,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -553,7 +553,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -564,7 +564,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -582,7 +582,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -596,7 +596,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
@@ -620,7 +620,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -634,7 +634,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -24,7 +24,8 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public var pluginMethods: [CAPPluginMethod] {
|
||||
return [
|
||||
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
|
||||
* 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) {
|
||||
guard let sharedData = SharedImageUtility.getSharedImageData() else {
|
||||
@@ -62,5 +63,12 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"hasImage": hasImage
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic snapshot of Share Extension startup and pending share state
|
||||
*/
|
||||
@objc public func getShareExtensionDiagnostics(_ call: CAPPluginCall) {
|
||||
call.resolve(SharedImageUtility.getShareExtensionDiagnostics())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ public class SharedImageUtility {
|
||||
private static let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
private static let sharedPhotoShareIdKey = "sharedPhotoShareId"
|
||||
private static let shareExtensionLastStartKey = "shareExtensionLastStart"
|
||||
private static let sharedPhotoReadyKey = "sharedPhotoReady"
|
||||
|
||||
/// Get the App Group container URL for accessing shared files
|
||||
@@ -20,15 +22,35 @@ public class SharedImageUtility {
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
@@ -39,6 +61,7 @@ public class SharedImageUtility {
|
||||
}
|
||||
|
||||
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
|
||||
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
|
||||
// Read image data from file
|
||||
@@ -46,18 +69,13 @@ public class SharedImageUtility {
|
||||
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
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -67,7 +85,10 @@ public class SharedImageUtility {
|
||||
* @returns true if shared image file exists, false otherwise
|
||||
*/
|
||||
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 containerURL = appGroupContainerURL else {
|
||||
return false
|
||||
@@ -77,6 +98,41 @@ public class SharedImageUtility {
|
||||
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
|
||||
* This flag is set by the Share Extension when image is ready
|
||||
|
||||
@@ -76,7 +76,7 @@ PODS:
|
||||
- SQLCipher/common (4.10.0)
|
||||
- SQLCipher/standard (4.10.0):
|
||||
- SQLCipher/common
|
||||
- TimesafariDailyNotificationPlugin (2.0.0):
|
||||
- TimesafariDailyNotificationPlugin (4.0.1):
|
||||
- Capacitor
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
@@ -162,7 +162,7 @@ SPEC CHECKSUMS:
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
|
||||
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 87c07d03f36ef38ab0c873802aee1ce9b5d34448
|
||||
|
||||
@@ -13,6 +13,8 @@ class ShareViewController: UIViewController {
|
||||
private let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
private let sharedPhotoShareIdKey = "sharedPhotoShareId"
|
||||
private let shareExtensionLastStartKey = "shareExtensionLastStart"
|
||||
private let sharedImageFileName = "shared-image"
|
||||
|
||||
/// Get the App Group container URL for storing shared files
|
||||
@@ -21,6 +23,14 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// Set a minimal background (transparent or loading indicator)
|
||||
@@ -28,18 +38,32 @@ class ShareViewController: UIViewController {
|
||||
|
||||
// Process image immediately without showing UI
|
||||
processAndOpenApp()
|
||||
print("[ShareTarget] viewDidLoad completed")
|
||||
}
|
||||
|
||||
private func processAndOpenApp() {
|
||||
print("[ShareTarget] processAndOpenApp started")
|
||||
|
||||
// extensionContext is automatically available on UIViewController when used as extension principal class
|
||||
guard let context = extensionContext,
|
||||
let inputItems = context.inputItems as? [NSExtensionItem] else {
|
||||
print("[ShareTarget] processAndOpenApp failed: missing extensionContext or inputItems")
|
||||
print("[ShareTarget] completeRequest starting")
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
print("[ShareTarget] completeRequest completed")
|
||||
print("[ShareTarget] processAndOpenApp completed")
|
||||
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
|
||||
guard let self = self, let context = self.extensionContext else {
|
||||
print("[ShareTarget] processAndOpenApp failed: self or extensionContext unavailable in completion")
|
||||
print("[ShareTarget] processAndOpenApp completed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -48,93 +72,134 @@ class ShareViewController: UIViewController {
|
||||
self.setSharedPhotoReadyFlag()
|
||||
// Open the main app (using minimal URL - app will detect shared data on activation)
|
||||
self.openMainApp()
|
||||
} else {
|
||||
print("[ShareTarget] processAndOpenApp failed: processSharedImage returned false")
|
||||
}
|
||||
|
||||
// Complete immediately - no UI shown
|
||||
print("[ShareTarget] completeRequest starting")
|
||||
context.completeRequest(returningItems: [], completionHandler: nil)
|
||||
print("[ShareTarget] completeRequest completed")
|
||||
print("[ShareTarget] processAndOpenApp completed")
|
||||
}
|
||||
}
|
||||
|
||||
private func setSharedPhotoReadyFlag() {
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag started")
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag failed: UserDefaults unavailable for app group")
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag completed")
|
||||
return
|
||||
}
|
||||
userDefaults.set(true, forKey: "sharedPhotoReady")
|
||||
userDefaults.synchronize()
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag success")
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag completed")
|
||||
}
|
||||
|
||||
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
|
||||
for item in items {
|
||||
guard let attachments = item.attachments else {
|
||||
print("[ShareTarget] processSharedImage skipping item with no attachments")
|
||||
continue
|
||||
}
|
||||
|
||||
for attachment in attachments {
|
||||
// Skip non-image attachments
|
||||
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
|
||||
print("[ShareTarget] processSharedImage skipping non-image attachment")
|
||||
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
|
||||
// This preserves the original image format without conversion
|
||||
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
|
||||
guard let self = self else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if error != nil {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image"
|
||||
|
||||
if let url = data as? URL {
|
||||
// Most common case: Image provided as file URL - read raw data to preserve format
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw data directly to preserve original format
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
|
||||
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
|
||||
imageData = image.pngData()
|
||||
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
|
||||
}
|
||||
} else if let data = data as? Data {
|
||||
// Less common: Image provided as raw Data - use directly to preserve format
|
||||
imageData = data
|
||||
fileName = attachment.suggestedName ?? "shared-image"
|
||||
}
|
||||
|
||||
guard let finalImageData = imageData else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Store image as file in App Group container
|
||||
if self.storeImageData(finalImageData, fileName: fileName) {
|
||||
completion(true)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
return // Process only the first image
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image"
|
||||
|
||||
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
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw data directly to preserve original format
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
|
||||
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
|
||||
print("[ShareTarget] processSharedImage using UIImage PNG fallback shareId=\(shareId)")
|
||||
imageData = image.pngData()
|
||||
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 {
|
||||
print("[ShareTarget] processSharedImage loadItem returned Data shareId=\(shareId)")
|
||||
// Less common: Image provided as raw Data - use directly to preserve format
|
||||
imageData = data
|
||||
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 {
|
||||
print("[ShareTarget] processSharedImage failed: no image data shareId=\(shareId)")
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
print("[ShareTarget] image loaded bytes=\(finalImageData.count) filename=\(fileName) shareId=\(shareId)")
|
||||
|
||||
// Store image as file in App Group container
|
||||
if self.storeImageData(finalImageData, fileName: fileName, shareId: shareId) {
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=true")
|
||||
completion(true)
|
||||
} else {
|
||||
print("[ShareTarget] processSharedImage failed: storeImageData returned false shareId=\(shareId)")
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
return // Process only the first image
|
||||
}
|
||||
}
|
||||
|
||||
// No image found
|
||||
print("[ShareTarget] processSharedImage failed: no image attachment found attachmentCount=\(attachmentCount)")
|
||||
print("[ShareTarget] processSharedImage completed success=false")
|
||||
completion(false)
|
||||
}
|
||||
|
||||
@@ -146,48 +211,81 @@ class ShareViewController: UIViewController {
|
||||
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
|
||||
/// All images are stored as files regardless of size for consistency and simplicity
|
||||
/// 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 {
|
||||
print("[ShareTarget] storeImageData failed: app group container unavailable shareId=\(shareId)")
|
||||
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
|
||||
return false
|
||||
}
|
||||
|
||||
// Create file URL in the container using the actual filename
|
||||
// Extract extension from fileName if present, otherwise use sharedImageFileName
|
||||
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName
|
||||
let fileURL = containerURL.appendingPathComponent(actualFileName)
|
||||
let originalFileName = fileName.isEmpty ? "\(sharedImageFileName).jpg" : fileName
|
||||
let storedFileName = storedFileName(shareId: shareId, originalFileName: originalFileName)
|
||||
let fileURL = containerURL.appendingPathComponent(storedFileName)
|
||||
|
||||
// Remove old file if it exists
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
// Remove previously pending share file (metadata tracks one share at a time)
|
||||
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
|
||||
do {
|
||||
try imageData.write(to: fileURL)
|
||||
} catch {
|
||||
print("[ShareTarget] storeImageData failed: file write error shareId=\(shareId) error=\(error.localizedDescription)")
|
||||
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=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)
|
||||
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
|
||||
}
|
||||
|
||||
// Store relative path and filename
|
||||
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey)
|
||||
// sharedPhotoFilePath = on-disk name; sharedPhotoFileName = original display name
|
||||
userDefaults.set(storedFileName, forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.set(originalFileName, forKey: sharedPhotoFileNameKey)
|
||||
userDefaults.set(shareId, forKey: sharedPhotoShareIdKey)
|
||||
|
||||
// Clean up any old base64 data that might exist
|
||||
userDefaults.removeObject(forKey: "sharedPhotoBase64")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func openMainApp() {
|
||||
print("[ShareTarget] openMainApp starting")
|
||||
|
||||
// Open the main app with minimal URL - app will detect shared data on activation
|
||||
guard let url = URL(string: "timesafari://") else {
|
||||
print("[ShareTarget] openMainApp failed: could not create timesafari:// URL")
|
||||
print("[ShareTarget] openMainApp completed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,6 +293,7 @@ class ShareViewController: UIViewController {
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
print("[ShareTarget] openMainApp completed via UIApplication")
|
||||
return
|
||||
}
|
||||
responder = responder?.next
|
||||
@@ -202,6 +301,7 @@ class ShareViewController: UIViewController {
|
||||
|
||||
// Fallback: use extension context
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
print("[ShareTarget] openMainApp completed via extensionContext fallback")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
1611
package-lock.json
generated
1611
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
|
||||
{
|
||||
"name": "giftopia",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"description": "Giftopia App",
|
||||
"author": {
|
||||
"name": "Gift Economies Team"
|
||||
@@ -14,6 +14,7 @@
|
||||
"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",
|
||||
"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:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
"test:mobile": "./scripts/test-mobile.sh",
|
||||
|
||||
@@ -8,14 +8,14 @@ notifications for conflicted entities * - Template streamlined with computed CSS
|
||||
properties * * @author Matthew Raymer */
|
||||
<template>
|
||||
<div id="sectionGiftedGiver">
|
||||
<label class="block font-bold mb-1">
|
||||
<label class="block font-semibold text-lg capitalize text-center">
|
||||
{{ stepLabel }}
|
||||
</label>
|
||||
<!-- Toggle link for entity type selection -->
|
||||
<div class="text-right mb-4">
|
||||
<div class="text-center mb-4">
|
||||
<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"
|
||||
>
|
||||
{{ toggleLinkText }}
|
||||
|
||||
@@ -359,6 +359,23 @@ async function checkForSharedImageAndNavigate() {
|
||||
}
|
||||
|
||||
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");
|
||||
const imageResult = await checkAndStoreNativeSharedImage();
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import { WebPlugin } from "@capacitor/core";
|
||||
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
|
||||
import type {
|
||||
SharedImagePlugin,
|
||||
SharedImageResult,
|
||||
ShareExtensionDiagnostics,
|
||||
} from "./definitions";
|
||||
|
||||
export class SharedImagePluginWeb
|
||||
extends WebPlugin
|
||||
@@ -18,4 +22,13 @@ export class SharedImagePluginWeb
|
||||
async hasSharedImage(): Promise<{ hasImage: boolean }> {
|
||||
return { hasImage: false };
|
||||
}
|
||||
|
||||
async getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics> {
|
||||
return {
|
||||
shareExtensionLastStart: null,
|
||||
sharedPhotoShareId: null,
|
||||
sharedPhotoFilePath: null,
|
||||
fileExists: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,18 @@ export interface SharedImageResult {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface ShareExtensionDiagnostics {
|
||||
shareExtensionLastStart: string | null;
|
||||
sharedPhotoShareId: string | null;
|
||||
sharedPhotoFilePath: string | null;
|
||||
fileExists: boolean;
|
||||
}
|
||||
|
||||
export interface SharedImagePlugin {
|
||||
/**
|
||||
* Get shared image data from native layer
|
||||
* 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>;
|
||||
|
||||
@@ -20,4 +27,9 @@ export interface SharedImagePlugin {
|
||||
* Useful for quick checks before calling getSharedImage()
|
||||
*/
|
||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||
|
||||
/**
|
||||
* Diagnostic snapshot of Share Extension startup and pending share state (iOS)
|
||||
*/
|
||||
getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics>;
|
||||
}
|
||||
|
||||
@@ -62,10 +62,6 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions from the OS
|
||||
* Shows native permission dialog on first call
|
||||
*/
|
||||
/**
|
||||
* Request notification permissions from the OS
|
||||
* Shows native permission dialog on first call
|
||||
|
||||
@@ -1402,54 +1402,6 @@ export default class AccountViewView extends Vue {
|
||||
}, 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")
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<TopMessage />
|
||||
|
||||
@@ -109,12 +109,14 @@ Raymer * @version 1.0.0 */
|
||||
|
||||
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
|
||||
<!-- Record Quick-Action -->
|
||||
<div class="mb-6">
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<div
|
||||
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 -->
|
||||
<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()"
|
||||
>
|
||||
<font-awesome icon="plus" />
|
||||
@@ -122,25 +124,29 @@ Raymer * @version 1.0.0 */
|
||||
</button>
|
||||
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
|
||||
<transition
|
||||
enter-active-class="transition-all duration-1000 ease-out"
|
||||
leave-active-class="transition-all duration-1000 ease-in"
|
||||
enter-from-class="scale-0"
|
||||
enter-to-class="scale-100"
|
||||
leave-from-class="scale-100"
|
||||
leave-to-class="scale-0"
|
||||
enter-active-class="transition-all duration-500 ease-out"
|
||||
leave-active-class="transition-all duration-500 ease-in"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<button
|
||||
<div
|
||||
v-if="isScrolled"
|
||||
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"
|
||||
:style="getButtonPosition()"
|
||||
@click="openPersonDialog()"
|
||||
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]"
|
||||
>
|
||||
<font-awesome icon="plus" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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)]"
|
||||
@click="openPersonDialog()"
|
||||
>
|
||||
<font-awesome icon="plus" />
|
||||
<span>Thank</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
<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()"
|
||||
>
|
||||
<font-awesome
|
||||
@@ -559,7 +565,7 @@ export default class HomeView extends Vue {
|
||||
const scrollTop = appElement
|
||||
? appElement.scrollTop
|
||||
: window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||
const shouldBeScrolled = scrollTop > 100;
|
||||
const shouldBeScrolled = scrollTop > 120;
|
||||
if (this.isScrolled !== shouldBeScrolled) {
|
||||
this.isScrolled = shouldBeScrolled;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user