508 lines
16 KiB
Markdown
508 lines
16 KiB
Markdown
# 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.share")
|
|
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.share`
|
|
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)
|
|
|