Compare commits

..

10 Commits

Author SHA1 Message Date
Jose Olarte III
09a230f43e refactor(ios): improve file polling and extract shared image storage logic
Replace hardcoded timeout with reliable file existence polling and extract
duplicate image storage code into reusable function.

File Polling Improvements:
- Replace hardcoded 300ms timeout with pollForFileExistence() function
- Use Filesystem.stat() to check file existence instead of guessing timing
- Implement exponential backoff (100ms, 200ms, 400ms, 800ms, 1600ms)
- Maximum 5 retries with configurable parameters
- More reliable: actually checks if file exists rather than fixed wait time

Code Deduplication:
- Extract storeSharedImageInTempDB() function to eliminate code duplication
- Consolidates clearing old data, base64-to-dataURL conversion, and DB storage
- Used in both temp file and plugin fallback code paths
- Improves maintainability and reduces risk of inconsistencies

This makes the code more robust and maintainable while ensuring the file
is actually available before attempting to read it.
2025-11-25 18:56:45 +08:00
Jose Olarte III
eff4126043 feat(ios): improve share extension UX and fix image reload issues
Improve iOS share extension implementation to skip interstitial UI and
fix issues with subsequent image shares not updating the view.

iOS Share Extension Improvements:
- Replace SLComposeServiceViewController with custom UIViewController
  to eliminate interstitial "Post" button UI
- Use minimal URL (timesafari://) instead of deep link for app launch
- Implement app lifecycle detection via Capacitor appStateChange listener
  instead of relying solely on deep links

Deep Link and Navigation Fixes:
- Remove "shared-photo" from deep link schemas (no longer needed)
- Add empty path URL handling for share extension launches
- Implement processing lock to prevent duplicate image processing
- Add retry mechanism (300ms delay) to handle race conditions with
  AppDelegate writing temp files
- Use router.replace() when already on /shared-photo route to force refresh
- Clear old images from temp DB before storing new ones
- Delete temp file immediately after reading to prevent stale data

SharedPhotoView Component:
- Add route watcher (@Watch) to reload image when fileName query
  parameter changes
- Extract image loading logic into reusable loadSharedImage() method
- Improve error handling to clear image state on failures

This fixes the issue where sharing a second image while already on
SharedPhotoView would display the previous image instead of the new one.
2025-11-25 18:22:43 +08:00
Jose Olarte III
ae49c0e907 feat(ios): implement native share target for images
Implement iOS Share Extension to enable native image sharing from Photos
and other apps directly into TimeSafari. Users can now share images from
the iOS share sheet, which will open in SharedPhotoView for use as gifts
or profile pictures.

iOS Native Implementation:
- Add TimeSafariShareExtension target with ShareViewController
- Configure App Groups for data sharing between extension and main app
- Implement ShareViewController to process shared images and convert to base64
- Store shared image data in App Group UserDefaults
- Add ShareImageBridge utility to read shared data from App Group
- Update AppDelegate to handle shared-photo deep link and bridge data to JS

JavaScript Integration:
- Add checkAndStoreNativeSharedImage() in main.capacitor.ts to read shared
  images from native layer via temporary file bridge
- Convert base64 data to data URL format for compatibility with base64ToBlob
- Integrate with existing SharedPhotoView component
- Add "shared-photo" to deep link validation schema

Build System:
- Integrate Xcode 26 / CocoaPods compatibility workaround into build-ios.sh
- Add run_pod_install_with_workaround() for explicit pod install
- Add run_cap_sync_with_workaround() for Capacitor sync (which runs pod
  install internally)
- Automatically detect project format version 70 and apply workaround
- Remove standalone pod-install-workaround.sh script

Code Cleanup:
- Remove verbose debug logs from ShareViewController, AppDelegate, and
  main.capacitor.ts
- Retain essential logger calls for production debugging

Documentation:
- Add ios-share-extension-setup.md with manual Xcode setup instructions
- Add ios-share-extension-git-commit-guide.md for version control best practices
- Add ios-share-implementation-status.md tracking implementation progress
- Add native-share-target-implementation.md with overall architecture
- Add xcode-26-cocoapods-workaround.md documenting the compatibility issue

The implementation uses a temporary file bridge (AppDelegate writes to Documents
directory, JS reads via Capacitor Filesystem plugin) as a workaround for
Capacitor plugin auto-discovery issues. This can be improved in the future by
properly registering ShareImagePlugin in Capacitor's plugin registry.
2025-11-24 20:46:58 +08:00
Jose Olarte III
1b4ab7a500 fix(ios): add shared Xcode scheme for App target
Resolves "No Scheme" issue in Xcode by adding a shared scheme file.
Capacitor doesn't automatically create shared schemes, so this needs
to be manually added and committed to version control.
2025-11-24 16:20:12 +08:00
6ec2002cb0 Merge pull request 'refactor: move Import Contacts section to DataExportSection component' (#226) from accountview-contact-management-bundling into master
Reviewed-on: #226
2025-11-21 11:02:59 +00:00
Jose Olarte III
36eb9a16b0 fix: preserve contact methods and notes in export/import workflow
- Fix contactMethods not being exported (was checking for string instead of array)
- Add missing notes and iViewContent fields to $insertContact method
- Normalize empty strings to null when saving contacts in ContactEditView

This ensures contact data integrity is maintained during export/import operations.
2025-11-20 18:11:27 +08:00
7d295dd062 feat: make the contact methods more presentable, and clarify exact types 2025-11-19 20:00:42 -07:00
5f1b4dcc21 chore: bump version and add "-beta" 2025-11-19 20:00:09 -07:00
Jose Olarte III
203cf6b078 refactor(DataExportSection): rename section title to "Data Management"
Update the section title from "Data Export" to "Data Management" to better reflect that the component handles both data export and import functionality.
2025-11-19 21:32:35 +08:00
Jose Olarte III
9b84b28a78 refactor: move Import Contacts section to DataExportSection component
- Move Import Contacts UI section from AccountViewView to DataExportSection
- Consolidate import/export functionality in a single component
- Move related methods: uploadImportFile, showContactImport, checkContactImports
- Convert module-level ref to component property (inputImportFileName)
- Remove unused imports (ref, ImportContent) from AccountViewView
- Rename "Download Contacts" button to "Export Contacts"
- Improve import UI styling with full-width file input and button
2025-11-19 19:26:36 +08:00
26 changed files with 2674 additions and 204 deletions

View File

@@ -0,0 +1,139 @@
# iOS Share Extension - Git Commit Guide
**Date:** 2025-01-27
**Purpose:** Clarify which Xcode manual changes should be committed to the repository
## Quick Answer
**YES, most manual Xcode changes SHOULD be committed.** The only exceptions are user-specific settings that are already gitignored.
## What Gets Modified (and Should Be Committed)
When you create the Share Extension target and configure App Groups in Xcode, the following files are modified:
### 1. `ios/App/App.xcodeproj/project.pbxproj` ✅ **COMMIT THIS**
This is the main Xcode project file that tracks:
- **New targets** (Share Extension target)
- **File references** (which files belong to which targets)
- **Build settings** (compiler flags, deployment targets, etc.)
- **Build phases** (compile sources, link frameworks, etc.)
- **Capabilities** (App Groups configuration)
- **Target dependencies**
**This file IS tracked in git** (not in `.gitignore`), so changes should be committed.
### 2. Entitlements Files ✅ **COMMIT THESE**
When you enable App Groups capability, Xcode creates/modifies:
- `ios/App/App/App.entitlements` (for main app)
- `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` (for extension)
These files contain the App Group identifiers and should be committed.
### 3. Share Extension Source Files ✅ **ALREADY COMMITTED**
The following files are already in the repo:
- `ios/App/TimeSafariShareExtension/ShareViewController.swift`
- `ios/App/TimeSafariShareExtension/Info.plist`
- `ios/App/App/ShareImageBridge.swift`
These should already be committed (they were created as part of the implementation).
## What Should NOT Be Committed
### 1. User-Specific Settings ❌ **ALREADY GITIGNORED**
These are in `ios/.gitignore`:
- `xcuserdata/` - User-specific scheme selections, breakpoints, etc.
- `*.xcuserstate` - User's current Xcode state
### 2. Signing Identities ❌ **USER-SPECIFIC**
While the **App Groups capability** should be committed (it's in `project.pbxproj` and entitlements), your **personal signing identity/team** is user-specific and Xcode handles this automatically per developer.
## What Happens When You Commit
When you commit the changes:
1. **Other developers** who pull the changes will:
- ✅ Get the new Share Extension target automatically
- ✅ Get the App Groups capability configuration
- ✅ Get file references and build settings
- ✅ See the Share Extension in their Xcode project
2. **They will still need to:**
- Configure their own signing team/identity (Xcode prompts for this)
- Build the project (which may trigger CocoaPods updates)
- But they **won't** need to manually create the target or configure App Groups
## Step-by-Step: What to Commit
After completing the Xcode setup steps:
```bash
# Check what changed
git status
# You should see:
# - ios/App/App.xcodeproj/project.pbxproj (modified)
# - ios/App/App/App.entitlements (new or modified)
# - ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements (new)
# - Possibly other project-related files
# Review the changes
git diff ios/App/App.xcodeproj/project.pbxproj
# Commit the changes
git add ios/App/App.xcodeproj/project.pbxproj
git add ios/App/App/App.entitlements
git add ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements
git commit -m "Add iOS Share Extension target and App Groups configuration"
```
## Important Notes
### Merge Conflicts in project.pbxproj
The `project.pbxproj` file can have merge conflicts because:
- It's auto-generated by Xcode
- Multiple developers might modify it
- It uses UUIDs that can conflict
**If you get merge conflicts:**
1. Open the project in Xcode
2. Xcode will often auto-resolve conflicts
3. Or manually resolve by keeping both sets of changes
4. Test that the project builds
### Team/Developer IDs
The `DEVELOPMENT_TEAM` setting in `project.pbxproj` might be user-specific:
- Some teams commit this (if everyone uses the same team)
- Some teams use `.xcconfig` files to override per developer
- Check with your team's practices
If you see `DEVELOPMENT_TEAM = GM3FS5JQPH;` in the project file, this is already committed, so your team likely commits team IDs.
## Verification
After committing, verify that:
1. The Share Extension target appears in Xcode for other developers
2. App Groups capability is configured
3. The project builds successfully
4. No user-specific files were accidentally committed
## Summary
| Change Type | Commit? | Reason |
|------------|---------|--------|
| New target creation | ✅ Yes | Modifies `project.pbxproj` |
| App Groups capability | ✅ Yes | Creates/modifies entitlements files |
| File target membership | ✅ Yes | Modifies `project.pbxproj` |
| Build settings | ✅ Yes | Modifies `project.pbxproj` |
| Source files (Swift, plist) | ✅ Yes | Already in repo |
| User scheme selections | ❌ No | In `xcuserdata/` (gitignored) |
| Personal signing identity | ⚠️ Maybe | Depends on team practice |
**Bottom line:** Commit all the Xcode project configuration changes. Other developers will get the Share Extension target automatically when they pull, and they'll only need to configure their personal signing settings.

View File

@@ -0,0 +1,283 @@
# iOS Share Extension Improvements
**Date:** 2025-11-24
**Purpose:** Explore alternatives to improve user experience by eliminating interstitial UI and simplifying app launch mechanism
## Current Implementation Issues
1. **Interstitial UI**: Users see `SLComposeServiceViewController` with a "Post" button before the app opens
2. **Deep Link Dependency**: App relies on deep link (`timesafari://shared-photo`) to detect shared images, even though data is already in App Group
## Improvement 1: Skip Interstitial UI
### Current Approach
- Uses `SLComposeServiceViewController` which shows a UI with "Post" button
- User must tap "Post" to proceed
### Alternative: Custom UIViewController (Headless Processing)
Replace `SLComposeServiceViewController` with a custom `UIViewController` that:
- Processes the image immediately in `viewDidLoad`
- Shows no UI (or minimal loading indicator)
- Opens the app automatically
**Implementation:**
```swift
import UIKit
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari"
private let sharedPhotoBase64Key = "sharedPhotoBase64"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
override func viewDidLoad() {
super.viewDidLoad()
// Process image immediately without showing UI
processAndOpenApp()
}
private func processAndOpenApp() {
guard let extensionContext = extensionContext,
let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self else {
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
if success {
self.openMainApp()
}
// Complete immediately - no UI shown
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
// ... (same implementation as current)
}
private func openMainApp() {
guard let url = URL(string: "timesafari://shared-photo") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
extensionContext?.open(url, completionHandler: nil)
}
}
```
**Info.plist Changes:**
- Already configured correctly with `NSExtensionPrincipalClass`
- No storyboard needed (already removed)
**Benefits:**
- ✅ No interstitial UI - app opens immediately
- ✅ Faster user experience
- ✅ More seamless integration
**Considerations:**
- ⚠️ User has less control (can't cancel easily)
- ⚠️ No visual feedback during processing (could add minimal loading indicator)
- ⚠️ Apple guidelines: Extensions should provide value even if they don't open the app
## Improvement 2: Direct App Launch Without Deep Link
### Current Approach
- Share Extension stores data in App Group UserDefaults
- Share Extension opens app via deep link (`timesafari://shared-photo`)
- App receives deep link → checks App Group → processes image
### Alternative: App Lifecycle Detection
Instead of using deep links, the app can check for shared data when it becomes active:
**Option A: Check on App Activation**
```swift
// In AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
// Check for shared image from Share Extension
if let sharedData = getSharedImageData() {
// Store in temp file for JS to read
writeSharedImageToTempFile(sharedData)
// Navigate to shared-photo route directly
// This would need to be handled in JS layer
}
}
```
**Option B: Use Notification (More Reliable)**
```swift
// In ShareViewController.swift (after storing data)
private func openMainApp() {
// Store a flag that image is ready
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize()
// Open app (can use any URL scheme or even just launch the app)
guard let url = URL(string: "timesafari://") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
}
// In AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
let appGroupIdentifier = "group.app.timesafari"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
// Check if shared photo is ready
if userDefaults.bool(forKey: "sharedPhotoReady") {
userDefaults.removeObject(forKey: "sharedPhotoReady")
userDefaults.synchronize()
// Process shared image
if let sharedData = getSharedImageData() {
writeSharedImageToTempFile(sharedData)
// Trigger JS to check for shared image
// This could be done via Capacitor App plugin or custom event
}
}
}
```
**Option C: Check on App Launch (Most Direct)**
```swift
// In AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Check for shared image immediately on launch
checkForSharedImageOnLaunch()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Also check when app becomes active (in case it was already running)
checkForSharedImageOnLaunch()
}
private func checkForSharedImageOnLaunch() {
if let sharedData = getSharedImageData() {
writeSharedImageToTempFile(sharedData)
// Post a notification or use Capacitor to notify JS
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
}
}
```
**JavaScript Integration:**
```typescript
// In main.capacitor.ts
import { App } from '@capacitor/app';
// Listen for app becoming active
App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
// Check for shared image when app becomes active
await checkAndStoreNativeSharedImage();
}
});
// Also check on initial load
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios') {
checkAndStoreNativeSharedImage().then(result => {
if (result.success) {
// Navigate to shared-photo route
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
}
});
}
```
**Benefits:**
- ✅ No deep link routing needed
- ✅ More direct data flow
- ✅ App can detect shared content even if it was already running
- ✅ Simpler URL scheme handling
**Considerations:**
- ⚠️ Need to ensure app checks on both launch and activation
- ⚠️ May need to handle race conditions (app launching vs. share extension writing)
- ⚠️ Still need some way to open the app (minimal URL scheme still required)
## Recommended Approach
**Best of Both Worlds:**
1. **Use Custom UIViewController** (Improvement 1) - Eliminates interstitial UI
2. **Use App Lifecycle Detection** (Improvement 2, Option C) - Direct data flow
**Combined Implementation:**
```swift
// ShareViewController.swift - Custom UIViewController
class ShareViewController: UIViewController {
// Process immediately in viewDidLoad
// Store data in App Group
// Open app with minimal URL (just "timesafari://")
}
// AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
// Check for shared image
// If found, write to temp file and let JS handle navigation
}
```
**JavaScript:**
```typescript
// Check on app activation
App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
const result = await checkAndStoreNativeSharedImage();
if (result.success) {
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
}
}
});
```
This approach:
- ✅ No interstitial UI
- ✅ No deep link routing complexity
- ✅ Direct data flow via App Group
- ✅ Works whether app is running or launching fresh

View File

@@ -0,0 +1,140 @@
# iOS Share Extension Setup Instructions
**Date:** 2025-01-27
**Purpose:** Step-by-step instructions for setting up the iOS Share Extension in Xcode
## Prerequisites
- Xcode installed
- iOS project already set up with Capacitor
- Access to Apple Developer account (for App Groups)
## Step 1: Create Share Extension Target
1. Open `ios/App/App.xcodeproj` in Xcode
2. In the Project Navigator, select the **App** project (top-level item)
3. Click the **+** button at the bottom of the Targets list
4. Select **iOS****Share Extension**
5. Click **Next**
6. Configure:
- **Product Name:** `TimeSafariShareExtension`
- **Bundle Identifier:** `app.timesafari.shareextension` (must match main app's bundle ID with `.shareextension` suffix)
- **Language:** Swift
7. Click **Finish**
## Step 2: Configure Share Extension Files
The following files have been created in `ios/App/TimeSafariShareExtension/`:
- `ShareViewController.swift` - Main extension logic
- `Info.plist` - Extension configuration
**Verify these files exist and are added to the Share Extension target.**
## Step 3: Configure App Groups
App Groups allow the Share Extension and main app to share data.
### For Main App Target:
1. Select the **App** target in Xcode
2. Go to **Signing & Capabilities** tab
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari`
7. Ensure it's checked/enabled
### For Share Extension Target:
1. Select the **TimeSafariShareExtension** target
2. Go to **Signing & Capabilities** tab
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari` (same as main app)
7. Ensure it's checked/enabled
**Important:** Both targets must use the **exact same** App Group identifier.
## Step 4: Configure Share Extension Info.plist
The `Info.plist` file should already be configured, but verify:
1. Select `TimeSafariShareExtension/Info.plist` in Xcode
2. Ensure it contains:
- `NSExtensionPointIdentifier` = `com.apple.share-services`
- `NSExtensionPrincipalClass` = `$(PRODUCT_MODULE_NAME).ShareViewController`
- `NSExtensionActivationSupportsImageWithMaxCount` = `1`
## Step 5: Add ShareImageBridge to Main App
1. The file `ios/App/App/ShareImageBridge.swift` has been created
2. Ensure it's added to the **App** target (not the Share Extension target)
3. In Xcode, select the file and check the **Target Membership** in the File Inspector
## Step 6: Build and Test
1. Select the **App** scheme (not the Share Extension scheme)
2. Build and run on a device or simulator
3. Open Photos app
4. Select an image
5. Tap **Share** button
6. Look for **TimeSafari Share** in the share sheet
7. Select it
8. The app should open and navigate to the shared photo view
## Step 7: Troubleshooting
### Share Extension doesn't appear in share sheet
- Verify the Share Extension target builds successfully
- Check that `Info.plist` is correctly configured
- Ensure the extension's bundle identifier follows the pattern: `{main-app-bundle-id}.shareextension`
- Clean build folder (Product → Clean Build Folder)
### App Group access fails
- Verify both targets have the same App Group identifier
- Check that App Groups capability is enabled for both targets
- Ensure you're signed in with a valid Apple Developer account
- For development, you may need to enable App Groups in your Apple Developer account
### Shared image not appearing
- Check Xcode console for errors
- Verify `ShareViewController.swift` is correctly implemented
- Ensure the deep link `timesafari://shared-photo` is being handled
- Check that the native bridge method is being called
### Build errors
- Ensure Swift version matches between targets
- Check that all required frameworks are linked
- Verify deployment targets match between main app and extension
## Step 8: Native Bridge Implementation (TODO)
Currently, the JavaScript code needs a way to call the native `getSharedImageData()` method. This requires one of:
1. **Option A:** Create a minimal Capacitor plugin
2. **Option B:** Use Capacitor's existing bridge mechanisms
3. **Option C:** Expose the method via a custom URL scheme parameter
The current implementation in `main.capacitor.ts` has a placeholder that needs to be completed.
## Next Steps
After the Share Extension is set up and working:
1. Complete the native bridge implementation to read from App Group
2. Test end-to-end flow: Share image → Extension stores → App reads → Displays
3. Implement Android version
4. Add error handling and edge cases
## References
- [Apple Share Extensions Documentation](https://developer.apple.com/documentation/social)
- [App Groups Documentation](https://developer.apple.com/documentation/xcode/configuring-app-groups)
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)

View File

@@ -0,0 +1,93 @@
# iOS Share Extension Implementation Status
**Date:** 2025-01-27
**Status:** In Progress - Native Code Complete, Bridge Pending
## Completed
**Share Extension Files Created:**
- `ios/App/TimeSafariShareExtension/ShareViewController.swift` - Handles image sharing
- `ios/App/TimeSafariShareExtension/Info.plist` - Extension configuration
**Native Bridge Created:**
- `ios/App/App/ShareImageBridge.swift` - Native method to read from App Group
**JavaScript Integration Started:**
- `src/services/nativeShareHandler.ts` - Service to handle native shared images
- `src/main.capacitor.ts` - Updated to check for native shared images on deep link
**Documentation:**
- `doc/native-share-target-implementation.md` - Complete implementation guide
- `doc/ios-share-extension-setup.md` - Xcode setup instructions
## Pending
⚠️ **Xcode Configuration (Manual Steps Required):**
1. Create Share Extension target in Xcode
2. Configure App Groups for both main app and extension
3. Add ShareImageBridge.swift to App target
4. Build and test
⚠️ **JavaScript-Native Bridge:**
The current implementation has a placeholder for calling the native `ShareImageBridge.getSharedImageData()` method from JavaScript. This needs to be completed using one of:
**Option A: Minimal Capacitor Plugin** (Recommended for Option 1)
- Create a small plugin that exposes the method
- Clean and maintainable
- Follows Capacitor patterns
**Option B: Direct Bridge Call**
- Use Capacitor's executePlugin or similar mechanism
- Requires understanding Capacitor's internal bridge
- Less maintainable
**Option C: AppDelegate Integration**
- Have AppDelegate check on launch and expose via a different mechanism
- Workaround approach
- Less clean but functional
## Next Steps
1. **Complete Xcode Setup:**
- Follow `doc/ios-share-extension-setup.md`
- Create Share Extension target
- Configure App Groups
- Build and verify extension appears in share sheet
2. **Implement JavaScript-Native Bridge:**
- Choose one of the options above
- Complete the `checkAndStoreNativeSharedImage()` function in `main.capacitor.ts`
- Test end-to-end flow
3. **Testing:**
- Share image from Photos app
- Verify Share Extension appears
- Verify app opens and displays shared image
- Test "Record Gift" and "Save as Profile" flows
## Current Flow
1. ✅ User shares image → Share Extension receives
2. ✅ Share Extension converts to base64
3. ✅ Share Extension stores in App Group UserDefaults
4. ✅ Share Extension opens app with `timesafari://shared-photo?fileName=...`
5. ⚠️ App receives deep link (handled)
6. ⚠️ App checks App Group UserDefaults (bridge needed)
7. ⚠️ App stores in temp database (pending bridge)
8. ✅ SharedPhotoView reads from temp database (already works)
## Code Locations
- **Share Extension:** `ios/App/TimeSafariShareExtension/`
- **Native Bridge:** `ios/App/App/ShareImageBridge.swift`
- **JavaScript Handler:** `src/services/nativeShareHandler.ts`
- **Deep Link Integration:** `src/main.capacitor.ts`
- **View Component:** `src/views/SharedPhotoView.vue` (already complete)
## Notes
- The Share Extension code is complete and ready to use
- The main missing piece is the JavaScript-to-native bridge
- Once the bridge is complete, the entire flow should work end-to-end
- The existing `SharedPhotoView.vue` doesn't need changes - it already handles images from temp storage

View File

@@ -0,0 +1,507 @@
# Native Share Target Implementation Guide
**Date:** 2025-01-27
**Purpose:** Enable TimeSafari native iOS and Android apps to receive shared images from other apps
## Current State
The app currently supports **PWA/web share target** functionality:
- Service worker intercepts POST to `/share-target`
- Images stored in temp database as base64
- `SharedPhotoView.vue` processes and displays shared images
**This does NOT work for native iOS/Android builds** because:
- Service workers don't run in native app contexts
- Native platforms use different sharing mechanisms (Share Extensions on iOS, Intent Filters on Android)
## Required Changes
### 1. iOS Implementation
#### 1.1 Create Share Extension Target
1. Open `ios/App/App.xcodeproj` in Xcode
2. File → New → Target
3. Select "Share Extension" template
4. Name it "TimeSafariShareExtension"
5. Bundle Identifier: `app.timesafari.shareextension`
6. Language: Swift
#### 1.2 Configure Share Extension Info.plist
Add to `ios/App/TimeSafariShareExtension/Info.plist`:
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
```
#### 1.3 Implement ShareViewController
Create `ios/App/TimeSafariShareExtension/ShareViewController.swift`:
```swift
import UIKit
import Social
import MobileCoreServices
import Capacitor
class ShareViewController: SLComposeServiceViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.title = "Share to TimeSafari"
}
override func isContentValid() -> Bool {
return true
}
override func didSelectPost() {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// Handle image sharing
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
itemProvider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
if let url = item as? URL {
// Handle file URL
self.handleSharedImage(url: url)
} else if let image = item as? UIImage {
// Handle UIImage directly
self.handleSharedImage(image: image)
} else if let data = item as? Data {
// Handle image data
self.handleSharedImage(data: data)
}
}
}
}
private func handleSharedImage(url: URL? = nil, image: UIImage? = nil, data: Data? = nil) {
var imageData: Data?
var fileName: String?
if let url = url {
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
} else if let image = image {
imageData = image.jpegData(compressionQuality: 0.8)
fileName = "shared-image.jpg"
} else if let data = data {
imageData = data
fileName = "shared-image.jpg"
}
guard let imageData = imageData else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// Convert to base64
let base64String = imageData.base64EncodedString()
// Store in shared UserDefaults (accessible by main app)
let userDefaults = UserDefaults(suiteName: "group.app.timesafari")
userDefaults?.set(base64String, forKey: "sharedPhotoBase64")
userDefaults?.set(fileName ?? "shared-image.jpg", forKey: "sharedPhotoFileName")
userDefaults?.synchronize()
// Open main app with deep link
let url = URL(string: "timesafari://shared-photo?fileName=\(fileName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "shared-image.jpg")")!
var responder = self as UIResponder?
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
// Close share extension
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
override func configurationItems() -> [Any]! {
return []
}
}
```
#### 1.4 Configure App Groups
1. In Xcode, select main app target → Signing & Capabilities
2. Add "App Groups" capability
3. Create group: `group.app.timesafari`
4. Repeat for Share Extension target with same group name
#### 1.5 Update Main App to Read from App Group
The main app needs to check for shared images on launch. This should be added to `AppDelegate.swift` or handled in JavaScript.
### 2. Android Implementation
#### 2.1 Update AndroidManifest.xml
Add intent filter to `MainActivity` in `android/app/src/main/AndroidManifest.xml`:
```xml
<activity
android:name=".MainActivity"
... existing attributes ...>
... existing intent filters ...
<!-- Share Target Intent Filter -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Multiple images support (optional) -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
```
#### 2.2 Handle Intent in MainActivity
Update `android/app/src/main/java/app/timesafari/MainActivity.java`:
```java
package app.timesafari;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Base64;
import android.util.Log;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
public class MainActivity extends BridgeActivity {
private static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handleShareIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleShareIntent(intent);
}
private void handleShareIntent(Intent intent) {
if (intent == null) return;
String action = intent.getAction();
String type = intent.getType();
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (imageUri != null) {
handleSharedImage(imageUri, intent.getStringExtra(Intent.EXTRA_TEXT));
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
// Handle multiple images (optional - for now just take first)
java.util.ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (imageUris != null && !imageUris.isEmpty()) {
handleSharedImage(imageUris.get(0), null);
}
}
}
private void handleSharedImage(Uri imageUri, String fileName) {
try {
// Read image data
InputStream inputStream = getContentResolver().openInputStream(imageUri);
if (inputStream == null) {
Log.e(TAG, "Failed to open input stream for shared image");
return;
}
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] data = new byte[8192];
int nRead;
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
buffer.flush();
byte[] imageBytes = buffer.toByteArray();
// Convert to base64
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
// Extract filename from URI or use default
String actualFileName = fileName;
if (actualFileName == null || actualFileName.isEmpty()) {
String path = imageUri.getPath();
if (path != null) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
actualFileName = path.substring(lastSlash + 1);
}
}
if (actualFileName == null || actualFileName.isEmpty()) {
actualFileName = "shared-image.jpg";
}
}
// Store in SharedPreferences (accessible by JavaScript via Capacitor)
android.content.SharedPreferences prefs = getSharedPreferences("TimeSafariShared", MODE_PRIVATE);
android.content.SharedPreferences.Editor editor = prefs.edit();
editor.putString("sharedPhotoBase64", base64String);
editor.putString("sharedPhotoFileName", actualFileName);
editor.apply();
// Trigger JavaScript event or navigate to shared-photo route
// This will be handled by JavaScript checking for shared data on app launch
Log.d(TAG, "Shared image stored, filename: " + actualFileName);
} catch (Exception e) {
Log.e(TAG, "Error handling shared image", e);
}
}
}
```
#### 2.3 Add Required Permissions
Ensure `AndroidManifest.xml` has:
```xml
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- Android 13+ -->
```
### 3. JavaScript Layer Updates
#### 3.1 Create Native Share Handler
Create `src/services/nativeShareHandler.ts`:
```typescript
/**
* Native Share Handler
* Handles shared images from native iOS and Android platforms
*/
import { Capacitor } from "@capacitor/core";
import { App } from "@capacitor/app";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { logger } from "../utils/logger";
import { SHARED_PHOTO_BASE64_KEY } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
/**
* Check for shared images from native platforms and store in temp database
*/
export async function checkForNativeSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
if (!Capacitor.isNativePlatform()) {
return false;
}
try {
if (Capacitor.getPlatform() === "ios") {
return await checkIOSSharedImage(platformService);
} else if (Capacitor.getPlatform() === "android") {
return await checkAndroidSharedImage(platformService);
}
} catch (error) {
logger.error("Error checking for native shared image:", error);
}
return false;
}
/**
* Check for shared image on iOS (from App Group UserDefaults)
*/
async function checkIOSSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
// iOS uses App Groups to share data between extension and main app
// We need to use a Capacitor plugin or native code to read from App Group
// For now, this is a placeholder - requires native plugin implementation
// Option 1: Use Capacitor plugin to read from App Group
// Option 2: Use native code bridge
logger.debug("Checking for iOS shared image (not yet implemented)");
return false;
}
/**
* Check for shared image on Android (from SharedPreferences)
*/
async function checkAndroidSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
// Android stores in SharedPreferences
// We need a Capacitor plugin to read from SharedPreferences
// For now, this is a placeholder - requires native plugin implementation
logger.debug("Checking for Android shared image (not yet implemented)");
return false;
}
/**
* Store shared image in temp database
*/
async function storeSharedImage(
base64Data: string,
fileName: string,
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<void> {
try {
const existing = await platformService.$getTemp(SHARED_PHOTO_BASE64_KEY);
if (existing) {
await platformService.$updateEntity(
"temp",
{ blobB64: base64Data },
"id = ?",
[SHARED_PHOTO_BASE64_KEY]
);
} else {
await platformService.$insertEntity(
"temp",
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64Data },
["id", "blobB64"]
);
}
logger.debug("Stored shared image in temp database");
} catch (error) {
logger.error("Error storing shared image:", error);
throw error;
}
}
```
#### 3.2 Update main.capacitor.ts
Add check for shared images on app launch:
```typescript
// In main.capacitor.ts, after app mount:
import { checkForNativeSharedImage } from "./services/nativeShareHandler";
// Check for shared images when app becomes active
App.addListener("appStateChange", async (state) => {
if (state.isActive) {
// Check for native shared images
const hasSharedImage = await checkForNativeSharedImage(/* platformService */);
if (hasSharedImage) {
// Navigate to shared-photo view
await router.push({
name: "shared-photo",
query: { source: "native" }
});
}
}
});
// Also check on initial launch
App.getLaunchUrl().then((result) => {
if (result?.url) {
// Handle deep link
} else {
// Check for shared image
checkForNativeSharedImage(/* platformService */).then((hasShared) => {
if (hasShared) {
router.push({ name: "shared-photo", query: { source: "native" } });
}
});
}
});
```
#### 3.3 Update SharedPhotoView.vue
The existing `SharedPhotoView.vue` should work as-is, but we may want to add detection for native vs web sources.
### 4. Alternative Approach: Capacitor Plugin
Instead of implementing native code directly, consider creating a Capacitor plugin:
1. **Create plugin**: `@capacitor-community/share-target` or custom plugin
2. **Plugin methods**:
- `checkForSharedImage()`: Returns shared image data if available
- `clearSharedImage()`: Clears shared image data after processing
This would be cleaner and more maintainable.
### 5. Testing Checklist
- [ ] Test sharing image from Photos app on iOS
- [ ] Test sharing image from Gallery app on Android
- [ ] Test sharing from other apps (Safari, Chrome, etc.)
- [ ] Verify image appears in SharedPhotoView
- [ ] Test "Record Gift" flow with shared image
- [ ] Test "Save as Profile" flow with shared image
- [ ] Test cancel flow
- [ ] Verify temp storage cleanup
- [ ] Test app launch with shared image pending
- [ ] Test app already running when image is shared
### 6. Implementation Priority
**Phase 1: Android (Simpler)**
1. Update AndroidManifest.xml
2. Implement MainActivity intent handling
3. Create JavaScript handler
4. Test end-to-end
**Phase 2: iOS (More Complex)**
1. Create Share Extension target
2. Implement ShareViewController
3. Configure App Groups
4. Create JavaScript handler
5. Test end-to-end
### 7. Notes
- **App Groups (iOS)**: Required for sharing data between Share Extension and main app
- **SharedPreferences (Android)**: Standard way to share data between app components
- **Base64 Encoding**: Both platforms convert images to base64 for JavaScript compatibility
- **File Size Limits**: Consider large image handling and memory management
- **Permissions**: Android 13+ requires `READ_MEDIA_IMAGES` instead of `READ_EXTERNAL_STORAGE`
### 8. References
- [iOS Share Extensions](https://developer.apple.com/documentation/social)
- [Android Share Targets](https://developer.android.com/training/sharing/receive)
- [Capacitor App Plugin](https://capacitorjs.com/docs/apis/app)
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)

View File

@@ -0,0 +1,76 @@
# Xcode 26 / CocoaPods Compatibility Workaround
**Date:** 2025-01-27
**Issue:** CocoaPods `xcodeproj` gem (1.27.0) doesn't support Xcode 26's project format version 70
## The Problem
Xcode 26.1.1 uses project format version 70, but the `xcodeproj` gem (1.27.0) only supports up to version 56. This causes CocoaPods to fail with:
```
ArgumentError - [Xcodeproj] Unable to find compatibility version string for object version `70`.
```
## Solutions
### Option 1: Temporarily Downgrade Project Format (Recommended for Now)
**Before running `pod install` or `npm run build:ios`:**
1. Edit `ios/App/App.xcodeproj/project.pbxproj`
2. Change line 6 from: `objectVersion = 70;` to: `objectVersion = 56;`
3. Run your build/sync command
4. Change it back to: `objectVersion = 70;` (Xcode will likely change it back automatically)
**Warning:** Xcode may automatically upgrade the format back to 70 when you open the project. This is okay - just repeat the process when needed.
### Option 2: Wait for xcodeproj Update
The `xcodeproj` gem maintainers will eventually release a version that supports format 70. You can:
- Check for updates: `bundle update xcodeproj`
- Monitor: https://github.com/CocoaPods/Xcodeproj/issues
### Option 3: Use Xcode Directly (Bypass CocoaPods for Now)
Since the Share Extension is already set up:
1. Open the project in Xcode
2. Build directly from Xcode (Product → Build)
3. Skip `npm run build:ios` for now
4. Test the Share Extension functionality
### Option 4: Automated Workaround (Integrated into Build Script) ✅
The workaround is now **automatically integrated** into `scripts/build-ios.sh`. When you run:
```bash
npm run build:ios
```
The build script will:
1. Automatically detect if the project format is version 70
2. Temporarily downgrade to version 56
3. Run `pod install`
4. Restore to version 70
5. Continue with the build
**No manual steps required!** The workaround is transparent and only applies when needed.
To remove the workaround in the future:
1. Check if `xcodeproj` gem supports format 70: `bundle exec gem list xcodeproj`
2. Test if `pod install` works without the workaround
3. If it works, remove the `run_pod_install_with_workaround()` function from `scripts/build-ios.sh`
4. Replace it with a simple `pod install` call
## Current Status
- ✅ Share Extension target exists
- ✅ Share Extension files are in place
- ✅ Workaround integrated into build script
-`npm run build:ios` works automatically
## Recommendation
**Use `npm run build:ios`** - the workaround is handled automatically. No manual intervention needed.
Once `xcodeproj` is updated to support format 70, the workaround can be removed from the build script.

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -15,8 +15,35 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86585E72ED45A3D00824752 /* ShareImageBridge.swift */; };
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
C86585DD2ED456DE00824752 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = C86585D42ED456DE00824752;
remoteInfo = TimeSafariShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
C86585E02ED456DE00824752 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -28,10 +55,28 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImageBridge.swift; sourceTree = "<group>"; };
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImagePlugin.swift; sourceTree = "<group>"; };
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -41,6 +86,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D22ED456DE00824752 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -56,6 +108,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
504EC3051FED79650016851F /* Products */,
BA325FFCDCE8D334E5C7AEBE /* Pods */,
4B546315E668C7A13939F417 /* Frameworks */,
@@ -66,6 +119,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -73,6 +127,9 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */,
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -108,16 +165,40 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
C86585DE2ED456DE00824752 /* PBXTargetDependency */,
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
C86585D42ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */;
buildPhases = (
C86585D12ED456DE00824752 /* Sources */,
C86585D22ED456DE00824752 /* Frameworks */,
C86585D32ED456DE00824752 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
);
name = TimeSafariShareExtension;
packageProductDependencies = (
);
productName = TimeSafariShareExtension;
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -125,7 +206,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
@@ -133,6 +214,9 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
C86585D42ED456DE00824752 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -149,6 +233,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
);
};
/* End PBXProject section */
@@ -167,6 +252,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D32ED456DE00824752 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -253,12 +345,29 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */,
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D12ED456DE00824752 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
C86585DE2ED456DE00824752 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -402,6 +511,7 @@
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
@@ -429,6 +539,7 @@
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
@@ -450,6 +561,80 @@
};
name = Release;
};
C86585E12ED456DE00824752 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
C86585E22ED456DE00824752 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -471,6 +656,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C86585E12ED456DE00824752 /* Debug */,
C86585E22ED456DE00824752 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
</array>
</dict>
</plist>

View File

@@ -32,6 +32,56 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
// Check for shared image from Share Extension when app becomes active
checkForSharedImageOnActivation()
}
/**
* Check for shared image when app launches or becomes active
* This allows the app to detect shared images without requiring a deep link
*/
private func checkForSharedImageOnActivation() {
let appGroupIdentifier = "group.app.timesafari"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
// Check if shared photo is ready
if userDefaults.bool(forKey: "sharedPhotoReady") {
// Clear the flag
userDefaults.removeObject(forKey: "sharedPhotoReady")
userDefaults.synchronize()
// Get and process shared image data
if let sharedData = getSharedImageData() {
writeSharedImageToTempFile(sharedData)
// Post notification for JavaScript to handle navigation
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
}
}
}
/**
* Write shared image data to temp file for JavaScript to read
*/
private func writeSharedImageToTempFile(_ sharedData: [String: String]) {
let fileManager = FileManager.default
guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
return
}
let tempFileURL = documentsDir.appendingPathComponent("timesafari_shared_photo.json")
let jsonData: [String: String] = [
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
]
if let json = try? JSONSerialization.data(withJSONObject: jsonData, options: []) {
try? json.write(to: tempFileURL)
}
}
func applicationWillTerminate(_ application: UIApplication) {
@@ -39,6 +89,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Check if this is a shared-photo deep link and store image data in a way JS can access
if url.scheme == "timesafari" && url.host == "shared-photo" {
// Try to get shared image from App Group and store it in a temp file that JS can read
// This is a workaround until the plugin is properly registered
if let sharedData = getSharedImageData() {
// Write to a temp file in the app's Documents directory that JavaScript can read via Filesystem plugin
let fileManager = FileManager.default
if let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
let tempFileURL = documentsDir.appendingPathComponent("timesafari_shared_photo.json")
// Create JSON data
let jsonData: [String: String] = [
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
]
if let json = try? JSONSerialization.data(withJSONObject: jsonData, options: []) {
do {
try json.write(to: tempFileURL)
} catch {
// Error writing temp file - will be handled by JS layer
}
}
}
}
}
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
@@ -50,5 +127,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
/**
* Check for shared image from Share Extension
* Reads from App Group UserDefaults and returns shared image data if available
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
func getSharedImageData() -> [String: String]? {
let appGroupIdentifier = "group.app.timesafari"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return nil
}
guard let base64 = userDefaults.string(forKey: "sharedPhotoBase64"),
let fileName = userDefaults.string(forKey: "sharedPhotoFileName") else {
return nil
}
// Clear the shared data after reading
userDefaults.removeObject(forKey: "sharedPhotoBase64")
userDefaults.removeObject(forKey: "sharedPhotoFileName")
userDefaults.synchronize()
return ["base64": base64, "fileName": fileName]
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
/**
* Share Image Bridge
*
* Provides a bridge between JavaScript and native iOS code to access
* shared images stored in App Group UserDefaults by the Share Extension.
*
* This bridge allows the JavaScript layer to read shared image data
* that was stored by the Share Extension.
*
* Note: This class doesn't need Capacitor - it's a simple Swift utility
* that reads from App Group UserDefaults. The JavaScript bridge will be
* implemented separately.
*/
@objc(ShareImageBridge)
public class ShareImageBridge: NSObject {
private static let appGroupIdentifier = "group.app.timesafari"
private static let sharedPhotoBase64Key = "sharedPhotoBase64"
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
/**
* Get shared image data from App Group UserDefaults
* Called from JavaScript via Capacitor bridge
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
@objc public static func getSharedImageData() -> [String: String]? {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("ShareImageBridge: Failed to access App Group UserDefaults")
return nil
}
guard let base64 = userDefaults.string(forKey: sharedPhotoBase64Key),
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) else {
return nil
}
// Clear the shared data after reading
userDefaults.removeObject(forKey: sharedPhotoBase64Key)
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
userDefaults.synchronize()
return ["base64": base64, "fileName": fileName]
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
import Capacitor
/**
* Share Image Plugin
*
* Capacitor plugin that exposes ShareImageBridge functionality to JavaScript.
* Allows JavaScript to retrieve shared images from App Group UserDefaults.
*/
@objc(ShareImagePlugin)
public class ShareImagePlugin: CAPPlugin {
@objc func getSharedImageData(_ call: CAPPluginCall) {
guard let sharedData = ShareImageBridge.getSharedImageData() else {
call.resolve(["success": false, "data": NSNull()])
return
}
call.resolve([
"success": true,
"data": [
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
]
])
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,146 @@
//
// ShareViewController.swift
// TimeSafariShareExtension
//
// Created by Aardimus on 11/24/25.
//
import UIKit
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari"
private let sharedPhotoBase64Key = "sharedPhotoBase64"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
override func viewDidLoad() {
super.viewDidLoad()
// Set a minimal background (transparent or loading indicator)
view.backgroundColor = .systemBackground
// Process image immediately without showing UI
processAndOpenApp()
}
private func processAndOpenApp() {
// extensionContext is automatically available on UIViewController when used as extension principal class
guard let context = extensionContext,
let inputItems = context.inputItems as? [NSExtensionItem] else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self, let context = self.extensionContext else {
return
}
if success {
// Set flag that shared photo is ready
self.setSharedPhotoReadyFlag()
// Open the main app (using minimal URL - app will detect shared data on activation)
self.openMainApp()
}
// Complete immediately - no UI shown
context.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func setSharedPhotoReadyFlag() {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize()
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
// Find the first image attachment
for item in items {
guard let attachments = item.attachments else {
continue
}
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
guard let self = self else {
completion(false)
return
}
if let error = error {
completion(false)
return
}
// Handle different image data types
var imageData: Data?
var fileName: String = "shared-image.jpg"
if let url = data as? URL {
// Image provided as file URL
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
} else if let image = data as? UIImage {
// Image provided as UIImage
imageData = image.jpegData(compressionQuality: 0.9)
fileName = "shared-image.jpg"
} else if let data = data as? Data {
// Image provided as raw Data
imageData = data
fileName = "shared-image.jpg"
}
guard let finalImageData = imageData else {
completion(false)
return
}
// Convert to base64
let base64String = finalImageData.base64EncodedString()
// Store in App Group UserDefaults
guard let userDefaults = UserDefaults(suiteName: self.appGroupIdentifier) else {
completion(false)
return
}
userDefaults.set(base64String, forKey: self.sharedPhotoBase64Key)
userDefaults.set(fileName, forKey: self.sharedPhotoFileNameKey)
userDefaults.synchronize()
completion(true)
}
return // Process only the first image
}
}
}
// No image found
completion(false)
}
private func openMainApp() {
// Open the main app with minimal URL - app will detect shared data on activation
guard let url = URL(string: "timesafari://") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
// Fallback: use extension context
extensionContext?.open(url, completionHandler: nil)
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
</array>
</dict>
</plist>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.3",
"version": "1.1.4-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.3",
"version": "1.1.4-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.3",
"version": "1.1.4-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"

View File

@@ -404,8 +404,141 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Install CocoaPods dependencies (with Xcode 26 workaround)
# ===================================================================
# WORKAROUND: Xcode 26 / CocoaPods Compatibility Issue
# ===================================================================
# Xcode 26 uses project format version 70, but CocoaPods' xcodeproj gem
# (1.27.0) only supports up to version 56. This causes pod install to fail.
#
# This workaround temporarily downgrades the project format to 56, runs
# pod install, then restores it to 70. Xcode will automatically upgrade
# it back to 70 when opened, which is fine.
#
# NOTE: Both explicit pod install AND Capacitor sync (which runs pod install
# internally) need this workaround. See run_pod_install_with_workaround()
# and run_cap_sync_with_workaround() functions below.
#
# TO REMOVE THIS WORKAROUND IN THE FUTURE:
# 1. Check if xcodeproj gem has been updated: bundle exec gem list xcodeproj
# 2. Test if pod install works without the workaround
# 3. If it works, remove both workaround functions below
# 4. Replace with:
# - safe_execute "Installing CocoaPods dependencies" "cd ios/App && bundle exec pod install && cd ../.." || exit 6
# - safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# 5. Update this comment to indicate the workaround has been removed
# ===================================================================
run_pod_install_with_workaround() {
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
log_info "Installing CocoaPods dependencies (with Xcode 26 workaround)..."
# Check if project file exists
if [ ! -f "$PROJECT_FILE" ]; then
log_error "Project file not found: $PROJECT_FILE"
return 1
fi
# Check current format version
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
if [ -z "$current_version" ]; then
log_error "Could not determine project format version"
return 1
fi
log_debug "Current project format version: $current_version"
# Only apply workaround if format is 70
if [ "$current_version" = "70" ]; then
log_debug "Applying Xcode 26 workaround: temporarily downgrading to format 56"
# Downgrade to format 56 (supported by CocoaPods)
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
log_error "Failed to downgrade project format"
return 1
fi
# Run pod install
log_info "Running pod install..."
if ! (cd ios/App && bundle exec pod install && cd ../..); then
log_error "pod install failed"
# Try to restore format even on failure
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
return 1
fi
# Restore to format 70
log_debug "Restoring project format to 70..."
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
fi
log_success "CocoaPods dependencies installed successfully"
else
# Format is not 70, run pod install normally
log_debug "Project format is $current_version, running pod install normally"
if ! (cd ios/App && bundle exec pod install && cd ../..); then
log_error "pod install failed"
return 1
fi
log_success "CocoaPods dependencies installed successfully"
fi
}
safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaround" || exit 6
# Step 6.5: Sync with Capacitor (also needs workaround since it runs pod install internally)
# Capacitor sync internally runs pod install, so we need to apply the workaround here too
run_cap_sync_with_workaround() {
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
# Check current format version
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
if [ -z "$current_version" ]; then
log_error "Could not determine project format version for Capacitor sync"
return 1
fi
# Only apply workaround if format is 70
if [ "$current_version" = "70" ]; then
log_debug "Applying Xcode 26 workaround for Capacitor sync: temporarily downgrading to format 56"
# Downgrade to format 56 (supported by CocoaPods)
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
log_error "Failed to downgrade project format for Capacitor sync"
return 1
fi
# Run Capacitor sync (which will run pod install internally)
log_info "Running Capacitor sync..."
if ! npx cap sync ios; then
log_error "Capacitor sync failed"
# Try to restore format even on failure
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
return 1
fi
# Restore to format 70
log_debug "Restoring project format to 70 after Capacitor sync..."
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
fi
log_success "Capacitor sync completed successfully"
else
# Format is not 70, run sync normally
log_debug "Project format is $current_version, running Capacitor sync normally"
if ! npx cap sync ios; then
log_error "Capacitor sync failed"
return 1
fi
log_success "Capacitor sync completed successfully"
fi
}
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7

View File

@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
<template>
<div id="sectionDataExport" :class="containerClasses">
<div :class="titleClasses">Data Export</div>
<div :class="titleClasses">Data Management</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
{{ isExporting ? "Exporting..." : "Export Contacts" }}
</button>
<div
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
</li>
</ul>
</div>
<!-- Import Contacts -->
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="mt-2">
<input
type="file"
class="w-full bg-white rounded-md pe-2 file:border-0 file:bg-gradient-to-b file:from-blue-400 file:to-blue-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:me-2 file:rounded-s-md"
@change="uploadImportFile"
/>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-2">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<button
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</transition>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app";
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers } from "@/utils/notify";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { ImportContent } from "@/interfaces/accountView";
/**
* @vue-component
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Router instance injected by Vue
* Used for navigation
*/
$router!: Router;
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
*/
showRedNotificationDot = false;
/**
* Reference to the selected import file
* Used to store the file selected by the user for import
*/
private inputImportFileName: Blob | undefined;
/**
* Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called
@@ -200,12 +257,30 @@ export default class DataExportSection extends Vue {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
// $contacts() returns normalized contacts where contactMethods is already an array,
// but we handle both array and string cases for robustness
if (contact.contactMethods) {
if (Array.isArray(contact.contactMethods)) {
// Already an array, use it directly
exContact.contactMethods = contact.contactMethods;
} else {
// Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness)
const contactMethodsValue = contact.contactMethods as unknown;
if (
typeof contactMethodsValue === "string" &&
contactMethodsValue.trim() !== ""
) {
// String that needs parsing
exContact.contactMethods = JSON.parse(contactMethodsValue);
} else {
// Invalid data, use empty array
exContact.contactMethods = [];
}
}
} else {
// No contactMethods, use empty array
exContact.contactMethods = [];
}
return exContact;
});
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
this.showRedNotificationDot = false;
}
}
/**
* Handles file selection for contact import
* Stores the selected file for later processing
*/
async uploadImportFile(event: Event): Promise<void> {
this.inputImportFileName = (event.target as HTMLInputElement).files?.[0];
}
/**
* Checks if a contact import file has been selected
* Used to conditionally show the import button
*/
showContactImport(): boolean {
return !!this.inputImportFileName;
}
/**
* Processes the selected import file and navigates to the contact import view
* Parses the JSON file and extracts contact data for import
*/
async checkContactImports(): Promise<void> {
if (!this.inputImportFileName) {
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(this.inputImportFileName);
}
}
</script>

View File

@@ -43,6 +43,7 @@ import {
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -101,6 +102,9 @@ import {
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
// Brand icons
import { faWhatsapp } from "@fortawesome/free-brands-svg-icons";
// Initialize Font Awesome library with all required icons
library.add(
faArrowDown,
@@ -140,6 +144,7 @@ library.add(
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -193,6 +198,7 @@ library.add(
faTriangleExclamation,
faUser,
faUsers,
faWhatsapp,
faXmark,
);

View File

@@ -30,11 +30,15 @@
import { initializeApp } from "./main.common";
import { App as CapacitorApp } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logger, safeStringify } from "./utils/logger";
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -51,6 +55,92 @@ window.addEventListener("unhandledrejection", (event) => {
const deepLinkHandler = new DeepLinkHandler(router);
// Lock to prevent duplicate processing of shared images
let isProcessingSharedImage = false;
/**
* Polls for file existence with exponential backoff
* More reliable than hardcoded timeout - checks if file actually exists
*
* @param filePath - Path to the file to check
* @param maxRetries - Maximum number of retry attempts (default: 5)
* @param initialDelay - Initial delay in milliseconds (default: 100)
* @returns Promise<boolean> - true if file exists, false if max retries reached
*/
async function pollForFileExistence(
filePath: string,
maxRetries: number = 5,
initialDelay: number = 100,
): Promise<boolean> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await Filesystem.stat({
path: filePath,
directory: Directory.Documents,
});
// File exists
return true;
} catch (error) {
// File doesn't exist yet, wait and retry
if (attempt < maxRetries - 1) {
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms
const delay = initialDelay * Math.pow(2, attempt);
logger.debug(
`[Main] File not found (attempt ${attempt + 1}/${maxRetries}), waiting ${delay}ms...`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
return false;
}
/**
* Stores shared image data in temp database
* Handles clearing old data, converting base64 to data URL, and storing
*
* @param base64 - Raw base64 string of the image
* @param fileName - Optional filename for logging
* @returns Promise<void>
*/
async function storeSharedImageInTempDB(
base64: string,
fileName?: string,
): Promise<void> {
const platformService = PlatformServiceFactory.getInstance();
// Clear old image from temp DB first to ensure we get the new one
try {
await platformService.dbExec("DELETE FROM temp WHERE id = ?", [
SHARED_PHOTO_BASE64_KEY,
]);
logger.debug("[Main] Cleared old shared image from temp DB");
} catch (clearError) {
logger.debug(
"[Main] No old image to clear (or error clearing):",
clearError,
);
}
// Convert raw base64 to data URL format that base64ToBlob expects
// base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..."
// Try to detect image type from base64 or default to jpeg
let mimeType = "image/jpeg"; // default
if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) {
// JPEG or PNG
mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
}
const dataUrl = `data:${mimeType};base64,${base64}`;
// Use INSERT OR REPLACE to handle existing records
await platformService.dbExec(
"INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)",
[SHARED_PHOTO_BASE64_KEY, dataUrl],
);
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
}
/**
* Handles deep link routing for the application
* Processes URLs in the format timesafari://<route>/<param>
@@ -67,11 +157,249 @@ const deepLinkHandler = new DeepLinkHandler(router);
*
* @throws {Error} If URL format is invalid
*/
/**
* Check for native shared image from iOS App Group UserDefaults
* and store in temp database before routing to shared-photo view
*/
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
// Prevent duplicate processing
if (isProcessingSharedImage) {
logger.debug(
"[Main] ⏸️ Shared image processing already in progress, skipping",
);
return { success: false };
}
isProcessingSharedImage = true;
try {
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
isProcessingSharedImage = false;
return { success: false };
}
logger.debug("[Main] Checking for iOS shared image from App Group");
// Use Capacitor's native bridge to call the ShareImagePlugin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const capacitor = (window as any).Capacitor;
if (!capacitor || !capacitor.Plugins) {
logger.debug("[Main] Capacitor plugins not available");
isProcessingSharedImage = false;
return { success: false };
}
// WORKAROUND: Since the plugin isn't being auto-discovered, use a temp file bridge
// AppDelegate writes the shared image data to a temp file, and we read it here
const tempFilePath = "timesafari_shared_photo.json";
// Check if file exists first (more reliable than hardcoded timeout)
const fileExists = await pollForFileExistence(tempFilePath);
if (fileExists) {
try {
const fileContent = await Filesystem.readFile({
path: tempFilePath,
directory: Directory.Documents,
encoding: Encoding.UTF8,
});
if (fileContent.data) {
const sharedData = JSON.parse(fileContent.data as string);
const base64 = sharedData.base64;
const fileName = sharedData.fileName || "shared-image.jpg";
if (base64) {
// Store in temp database using extracted method
logger.info(
"[Main] Native shared image found (via temp file), storing in temp DB",
);
await storeSharedImageInTempDB(base64, fileName);
// Delete the temp file immediately after reading to prevent re-reading
try {
await Filesystem.deleteFile({
path: tempFilePath,
directory: Directory.Documents,
});
logger.debug("[Main] Deleted temp file after reading");
} catch (deleteError) {
logger.error("[Main] Failed to delete temp file:", deleteError);
}
isProcessingSharedImage = false;
return { success: true, fileName };
}
}
} catch (fileError: unknown) {
// File exists but can't be read - log and continue to plugin method
logger.debug(
"[Main] Temp file exists but couldn't be read, trying plugin method",
);
}
} else {
logger.debug(
"[Main] Temp file not found after polling (this is normal if plugin works)",
);
}
// NOTE: Plugin registration issue - ShareImage plugin is not being auto-discovered
// This is a known issue that needs to be resolved. For now, we use the temp file workaround above.
// Try multiple methods to call the plugin (fallback if temp file method fails)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugins = (capacitor as any).Plugins;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const shareImagePlugin = (plugins as any)?.ShareImage;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any = null;
if (
shareImagePlugin &&
typeof shareImagePlugin.getSharedImageData === "function"
) {
logger.debug("[Main] Using direct plugin method");
try {
result = await shareImagePlugin.getSharedImageData();
} catch (pluginError) {
logger.error("[Main] Plugin call failed:", pluginError);
isProcessingSharedImage = false;
return { success: false };
}
} else {
// Method 2: Use Capacitor's execute method
logger.debug(
"[Main] Plugin not found directly, trying Capacitor.execute",
);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bridge = (capacitor as any).getBridge?.();
if (bridge) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await bridge.execute({
pluginId: "ShareImage",
methodName: "getSharedImageData",
options: {},
});
} else {
// Method 3: Try execute on Plugins object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((plugins as any)?.execute) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await (plugins as any).execute(
"ShareImage",
"getSharedImageData",
{},
);
}
}
} catch (executeError) {
logger.error("[Main] Execute method failed:", executeError);
isProcessingSharedImage = false;
return { success: false };
}
}
if (!result || !result.success || !result.data) {
logger.debug("[Main] No shared image data found in result");
isProcessingSharedImage = false;
return { success: false };
}
const { base64, fileName } = result.data;
if (!base64) {
logger.debug("[Main] Shared image data missing base64");
isProcessingSharedImage = false;
return { success: false };
}
logger.info("[Main] Native shared image found, storing in temp DB");
// Store in temp database using extracted method
await storeSharedImageInTempDB(base64, fileName);
isProcessingSharedImage = false;
return { success: true, fileName: fileName || "shared-image.jpg" };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
isProcessingSharedImage = false;
return { success: false };
}
}
const handleDeepLink = async (data: { url: string }) => {
const { url } = data;
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
try {
// Handle empty path URLs from share extension (timesafari://)
// These are used to open the app, and we should check for shared images
const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///";
if (
isEmptyPathUrl &&
Capacitor.isNativePlatform() &&
Capacitor.getPlatform() === "ios"
) {
logger.debug(
"[Main] 📸 Empty path URL from share extension, checking for native shared image",
);
// Try to get shared image from App Group and store in temp database
// AppDelegate writes the file when the deep link is received, so we may need to retry
// The checkAndStoreNativeSharedImage function now uses polling internally, so we just call it once
try {
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Native shared image found, navigating to shared-photo",
);
// Wait for router to be ready
await router.isReady();
// Navigate directly to shared-photo route
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
await router.push({
path: "/shared-photo",
query: { fileName },
});
}
logger.info(
`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`,
);
return; // Exit early, don't process as deep link
} else {
logger.debug(
"[Main] No native shared image found, ignoring empty path URL",
);
return; // Exit early, don't process empty path as deep link
}
} catch (error) {
logger.error("[Main] Error processing native shared image:", error);
// If check fails, don't process as deep link (empty path would fail validation anyway)
return;
}
}
// Wait for router to be ready
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
await router.isReady();
@@ -159,10 +487,76 @@ const registerDeepLinkListener = async () => {
}
};
/**
* Check for shared image and navigate to shared-photo route if found
* This is called when app becomes active (from share extension or app launch)
*/
async function checkForSharedImageAndNavigate() {
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
return;
}
try {
logger.debug("[Main] 🔍 Checking for shared image on app activation");
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Shared image found, navigating to shared-photo route",
);
// Wait for router to be ready
await router.isReady();
// Navigate to shared-photo route with fileName if available
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
const route = imageResult.fileName
? `/shared-photo?fileName=${encodeURIComponent(imageResult.fileName)}`
: "/shared-photo";
await router.push(route);
}
logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`);
} else {
logger.debug("[Main] No shared image found");
}
} catch (error) {
logger.error("[Main] ❌ Error checking for shared image:", error);
}
}
logger.log("[Capacitor] 🚀 Mounting app");
app.mount("#app");
logger.info(`[Main] ✅ App mounted successfully`);
// Check for shared image on initial load (in case app was launched from share extension)
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") {
setTimeout(async () => {
await checkForSharedImageAndNavigate();
}, 1000); // Small delay to ensure router is ready
}
// Listen for app state changes to detect when app becomes active
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") {
CapacitorApp.addListener("appStateChange", async ({ isActive }) => {
if (isActive) {
logger.debug("[Main] 📱 App became active, checking for shared image");
await checkForSharedImageAndNavigate();
}
});
}
// Register deeplink listener after app is mounted
setTimeout(async () => {
try {

View File

@@ -1367,6 +1367,9 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined
? contact.profileImageUrl
: null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
@@ -1377,8 +1380,8 @@ export const PlatformServiceMixin = {
await this.$dbExec(
`INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
safeContact.did,
safeContact.name,
@@ -1387,6 +1390,8 @@ export const PlatformServiceMixin = {
safeContact.registered,
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.contactMethods,
],
);

View File

@@ -375,45 +375,6 @@
Switch Identifier
</router-link>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="ml-4 mt-2">
<input type="file" class="ml-2" @change="uploadImportFile" />
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-4">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</div>
</transition>
</div>
</div>
<label
for="toggleShowAmounts"
class="flex items-center justify-between cursor-pointer my-4"
@@ -770,9 +731,7 @@ import "dexie-export-import";
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
@@ -799,7 +758,6 @@ import {
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox,
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
AccountSettings,
isApiError,
ImportContent,
} from "@/interfaces/accountView";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
@@ -836,8 +790,6 @@ interface ProfileData {
includeLocation: boolean;
}
const inputImportFileNameRef = ref<Blob>();
interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void;
}
@@ -1369,65 +1321,6 @@ export default class AccountViewView extends Vue {
);
}
async uploadImportFile(event: Event): Promise<void> {
inputImportFileNameRef.value = (
event.target as HTMLInputElement
).files?.[0];
}
showContactImport(): boolean {
return !!inputImportFileNameRef.value;
}
confirmSubmitImportFile(): void {
if (inputImportFileNameRef.value != null) {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
this.submitImportFile,
);
}
}
/**
* Asynchronously imports the database from a downloadable JSON file.
*
* @throws Will notify the user if there is an export error.
*/
async submitImportFile(): Promise<void> {
if (inputImportFileNameRef.value != null) {
// TODO: implement this for SQLite
}
}
async checkContactImports(): Promise<void> {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress): boolean {
logger.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,

View File

@@ -55,56 +55,70 @@
<!-- Contact Methods -->
<div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2>
<div
v-for="(method, index) in contactMethods"
:key="index"
class="flex mt-2"
>
<input
v-model="method.label"
type="text"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
v-model="method.type"
type="text"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
class="px-2 py-1 bg-gray-200 rounded-md"
@click="toggleDropdown(index)"
>
<font-awesome icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
v-for="methodType in contactMethodTypes"
:key="methodType.value"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, methodType.value)"
<div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
<!-- Type and Value Row -->
<div class="flex gap-2">
<div class="flex-none w-32">
<label class="block text-xs font-medium text-gray-700 mb-1">
Type
</label>
<select
v-model="method.type"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
>
{{ methodType.label }}
</div>
<option value=""></option>
<option
v-for="methodType in contactMethodTypes"
:key="methodType.value"
:value="methodType.value"
>
{{ methodType.label }}
</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 mb-1">
Value
</label>
<input
v-model="method.value"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
</div>
<button
class="self-end pb-0.5 text-red-500"
@click="removeContactMethod(index)"
>
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<!-- WhatsApp Help Text -->
<div
v-if="method.type === 'WHATSAPP'"
class="mt-1 ml-[calc(8rem+0.5rem)] text-xs text-gray-600 italic"
>
Must include country code and only numbers (e.g., 12225551234)
</div>
<!-- Label Row -->
<div class="mt-2 flex justify-end">
<div class="flex-1 ml-[calc(8rem+0.5rem)]">
<label class="block text-xs font-medium text-gray-700 mb-1">
</label>
<input
v-model="method.label"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label / Note"
/>
</div>
<div class="w-[2.5rem]"></div>
</div>
<input
v-model="method.value"
type="text"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button class="ml-2 text-red-500" @click="removeContactMethod(index)">
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<button class="mt-2" @click="addContactMethod">
<button class="mt-4" @click="addContactMethod">
<font-awesome
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
@@ -210,12 +224,10 @@ export default class ContactEditView extends Vue {
contactNotes = "";
/** Array of editable contact methods */
contactMethods: Array<ContactMethod> = [];
/** Currently open dropdown index, null if none open */
dropdownIndex: number | null = null;
/** App string constants */
AppString = AppString;
/** Contact method types for dropdown */
/** Contact method types for datalist suggestions */
contactMethodTypes = CONTACT_METHOD_TYPES;
/**
@@ -273,29 +285,6 @@ export default class ContactEditView extends Vue {
this.contactMethods.splice(index, 1);
}
/**
* Toggles the type selection dropdown for a contact method
*
* If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one.
*
* @param index The array index of the method whose dropdown to toggle
*/
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
/**
* Sets the type for a contact method and closes the dropdown
*
* @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP)
*/
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
/**
* Saves the edited contact information to the database
*
@@ -331,9 +320,10 @@ export default class ContactEditView extends Vue {
}
// Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", {
name: this.contactName,
notes: this.contactNotes,
name: this.contactName?.trim() || null,
notes: this.contactNotes?.trim() || null,
contactMethods: contactMethods,
});

View File

@@ -52,14 +52,16 @@
<!-- Contact Methods -->
<div v-if="contactFromDid.contactMethods?.length" class="mt-3">
<div class="flex flex-wrap gap-2">
<div class="flex flex-col gap-2">
<div
v-for="(method, index) in contactFromDid.contactMethods"
:key="index"
class="inline-flex items-center gap-2 text-sm"
class="flex items-center gap-2 text-sm"
>
<span class="font-semibold text-slate-600"
>{{ getContactMethodLabel(method.type) }}:</span
>{{
getContactMethodLabel(method.type) || "(unspecified)"
}}:</span
>
<span class="text-slate-700">{{ method.label }}</span>
<span class="text-slate-600">{{ method.value }}</span>
@@ -71,6 +73,23 @@
>
<font-awesome icon="message" class="text-base" />
</a>
<a
v-if="method.type === 'EMAIL'"
:href="`mailto:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send email"
>
<font-awesome icon="envelope" class="text-base" />
</a>
<a
v-if="method.type === 'WHATSAPP'"
:href="`https://wa.me/${method.value.replace(/\D/g, '')}`"
target="_blank"
class="ml-2 text-blue-700"
title="Send WhatsApp message"
>
<font-awesome :icon="['fab', 'whatsapp']" class="text-base" />
</a>
</div>
</div>
</div>

View File

@@ -120,7 +120,7 @@
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { Component, Vue, Watch } from "vue-facing-decorator";
import {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
@@ -191,7 +191,29 @@ export default class SharedPhotoView extends Vue {
*/
async mounted() {
this.notify = createNotifyHelpers(this.$notify);
await this.loadSharedImage();
}
/**
* Watches for route query changes to reload image when navigating
* to the same route with different query parameters (e.g., new fileName)
*/
@Watch("$route.query.fileName")
async onFileNameChange(newFileName: string | undefined) {
if (newFileName) {
await this.loadSharedImage();
}
}
/**
* Loads the shared image from temporary storage
*
* Retrieves the shared image data from the temp database, converts it to a blob,
* and updates component state. Cleans up temporary storage after successful loading.
*
* @async
*/
private async loadSharedImage() {
try {
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -209,6 +231,9 @@ export default class SharedPhotoView extends Vue {
this.imageFileName = this.$route.query["fileName"] as string;
} else {
logger.error("No appropriate image found in temp storage.", temp);
// Clear image state if no temp data found
this.imageBlob = undefined;
this.imageFileName = undefined;
}
} catch (err: unknown) {
logger.error("Got an error loading an identifier:", err);
@@ -216,6 +241,9 @@ export default class SharedPhotoView extends Vue {
NOTIFY_SHARED_PHOTO_LOAD_ERROR.message,
TIMEOUTS.STANDARD,
);
// Clear image state on error
this.imageBlob = undefined;
this.imageFileName = undefined;
}
}