From dce46bdba07eed2d721fe4544ceb69659d2cb30f Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Mon, 2 Mar 2026 17:37:48 +0100 Subject: [PATCH 1/2] add auth check and not-signed-in screen for iOS and Android --- assets/lang/strings.ts | 16 ++ ios/Internxt.xcodeproj/project.pbxproj | 28 +-- ios/Internxt/AppDelegate.swift | 92 +++++++ ios/Internxt/Info.plist | 2 + ios/Internxt/Internxt.entitlements | 4 + ios/InternxtShareExtension/Info.plist | 2 + .../InternxtShareExtension.entitlements | 4 + .../ShareExtensionViewController.swift | 232 +++++++++--------- src/shareExtension/AndroidShareScreen.tsx | 148 ++++++----- src/shareExtension/ShareExtensionApp.tsx | 199 ++++++++------- .../hooks/useShareAuth.android.ts | 18 ++ .../screens/NotSignedInScreen.tsx | 136 ++++++++++ src/shareExtension/useAndroidShareIntent.ts | 4 - 13 files changed, 610 insertions(+), 275 deletions(-) create mode 100644 src/shareExtension/hooks/useShareAuth.android.ts create mode 100644 src/shareExtension/screens/NotSignedInScreen.tsx diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts index 7a5d88978..6962db664 100644 --- a/assets/lang/strings.ts +++ b/assets/lang/strings.ts @@ -322,6 +322,14 @@ const translations = { action: 'Export backup key', }, }, + ShareExtension: { + title: 'Save to Internxt', + notSignedIn: { + title: "You're not signed in", + subtitle: 'Log in to upload files securely', + openLogin: 'Open Internxt login', + }, + }, }, buttons: { fixPhoto: 'Repair photo', @@ -1126,6 +1134,14 @@ const translations = { action: 'Exportar la clave de seguridad', }, }, + ShareExtension: { + title: 'Guardar en Internxt', + notSignedIn: { + title: 'No has iniciado sesión', + subtitle: 'Inicia sesión para subir archivos de forma segura', + openLogin: 'Abrir login de Internxt', + }, + }, }, buttons: { fixPhoto: 'Reparar foto', diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index 02945904f..8ae710daa 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -270,13 +270,13 @@ LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = JR4S3SY396; LastSwiftMigration = 1250; - DevelopmentTeam = "JR4S3SY396"; ProvisioningStyle = Automatic; }; 573EC8782D084646801534FA = { + DevelopmentTeam = JR4S3SY396; LastSwiftMigration = 1250; - DevelopmentTeam = "JR4S3SY396"; ProvisioningStyle = Automatic; }; }; @@ -607,7 +607,10 @@ buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = InternxtShareExtension/InternxtShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JR4S3SY396; ENABLE_ON_DEMAND_RESOURCES = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = InternxtShareExtension/Info.plist; @@ -624,9 +627,6 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; - DEVELOPMENT_TEAM = "JR4S3SY396"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; }; name = Debug; }; @@ -637,7 +637,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Internxt/Internxt.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JR4S3SY396; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -663,9 +666,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; - DEVELOPMENT_TEAM = "JR4S3SY396"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; }; name = Debug; }; @@ -676,7 +676,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Internxt/Internxt.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JR4S3SY396; INFOPLIST_FILE = Internxt/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -696,9 +699,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; - DEVELOPMENT_TEAM = "JR4S3SY396"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; }; name = Release; }; @@ -826,7 +826,10 @@ buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = InternxtShareExtension/InternxtShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JR4S3SY396; ENABLE_ON_DEMAND_RESOURCES = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = InternxtShareExtension/Info.plist; @@ -842,9 +845,6 @@ PRODUCT_NAME = InternxtShareExtension; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; - DEVELOPMENT_TEAM = "JR4S3SY396"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; }; name = Release; }; diff --git a/ios/Internxt/AppDelegate.swift b/ios/Internxt/AppDelegate.swift index a7887e1e5..1b19be3c1 100644 --- a/ios/Internxt/AppDelegate.swift +++ b/ios/Internxt/AppDelegate.swift @@ -1,6 +1,7 @@ import Expo import React import ReactAppDependencyProvider +import Security @UIApplicationMain public class AppDelegate: ExpoAppDelegate { @@ -29,9 +30,100 @@ public class AppDelegate: ExpoAppDelegate { launchOptions: launchOptions) #endif + syncAuthStatusToAppGroup() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + // Sync auth status whenever the app moves to background so the share + // extension always reads an up-to-date value from the shared UserDefaults. + public override func applicationDidEnterBackground(_ application: UIApplication) { + syncAuthStatusToAppGroup() + super.applicationDidEnterBackground(application) + } + + // MARK: - App Group auth sync + + private func syncAuthStatusToAppGroup() { + guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String, + let defaults = UserDefaults(suiteName: appGroup), + let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String + else { return } + + let isAuthenticated = privateKeychainItemExists(key: "photosToken") + defaults.set(isAuthenticated, forKey: "isAuthenticated") + + if isAuthenticated { + copyToSharedKeychain(privateKey: "photosToken", sharedKey: "shared_photosToken", accessGroup: sharedGroup) + copyToSharedKeychain(privateKey: "xUser_mnemonic", sharedKey: "shared_mnemonic", accessGroup: sharedGroup) + defaults.set(readEmailFromKeychain(), forKey: "userEmail") + } else { + deleteFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup) + deleteFromSharedKeychain(key: "shared_mnemonic", accessGroup: sharedGroup) + defaults.removeObject(forKey: "userEmail") + } + } + + private func readEmailFromKeychain() -> String? { + guard let data = readFromPrivateKeychain(key: "xUser_data"), + var raw = String(data: data, encoding: .utf8) else { return nil } + if raw.hasPrefix("\"") && raw.hasSuffix("\"") { + raw = String(raw.dropFirst().dropLast()) + } + guard let jsonData = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let email = json["email"] as? String else { return nil } + return email + } + + private func privateKeychainItemExists(key: String) -> Bool { + return readFromPrivateKeychain(key: key) != nil + } + + private func copyToSharedKeychain(privateKey: String, sharedKey: String, accessGroup: String) { + guard let data = readFromPrivateKeychain(key: privateKey) else { return } + writeToSharedKeychain(data: data, key: sharedKey, accessGroup: accessGroup) + } + + private func readFromPrivateKeychain(key: String) -> Data? { + var result: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "app:no-auth", + kSecAttrGeneric as String: Data(key.utf8), + kSecAttrAccount as String: Data(key.utf8), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return data + } + + private func writeToSharedKeychain(data: Data, key: String, accessGroup: String) { + deleteFromSharedKeychain(key: key, accessGroup: accessGroup) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "app:no-auth", + kSecAttrGeneric as String: Data(key.utf8), + kSecAttrAccount as String: Data(key.utf8), + kSecAttrAccessGroup as String: accessGroup, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData as String: data, + ] + SecItemAdd(query as CFDictionary, nil) + } + + private func deleteFromSharedKeychain(key: String, accessGroup: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "app:no-auth", + kSecAttrGeneric as String: Data(key.utf8), + kSecAttrAccount as String: Data(key.utf8), + kSecAttrAccessGroup as String: accessGroup, + ] + SecItemDelete(query as CFDictionary) + } + // Linking API public override func application( _ app: UIApplication, diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist index a25689bfd..b4f15379e 100644 --- a/ios/Internxt/Info.plist +++ b/ios/Internxt/Info.plist @@ -6,6 +6,8 @@ group.com.internxt.snacks AppGroupIdentifier group.com.internxt.snacks + SharedKeychainGroup + $(AppIdentifierPrefix)group.com.internxt.snacks CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/ios/Internxt/Internxt.entitlements b/ios/Internxt/Internxt.entitlements index 7ab3cacb8..c8c92a781 100644 --- a/ios/Internxt/Internxt.entitlements +++ b/ios/Internxt/Internxt.entitlements @@ -24,5 +24,9 @@ group.com.internxt.snacks + keychain-access-groups + + $(AppIdentifierPrefix)group.com.internxt.snacks + \ No newline at end of file diff --git a/ios/InternxtShareExtension/Info.plist b/ios/InternxtShareExtension/Info.plist index 40835fcdd..3d5f291ca 100644 --- a/ios/InternxtShareExtension/Info.plist +++ b/ios/InternxtShareExtension/Info.plist @@ -6,6 +6,8 @@ group.com.internxt.snacks AppGroupIdentifier group.com.internxt.snacks + SharedKeychainGroup + $(AppIdentifierPrefix)group.com.internxt.snacks CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/ios/InternxtShareExtension/InternxtShareExtension.entitlements b/ios/InternxtShareExtension/InternxtShareExtension.entitlements index 31b97d4d2..4ede752b5 100644 --- a/ios/InternxtShareExtension/InternxtShareExtension.entitlements +++ b/ios/InternxtShareExtension/InternxtShareExtension.entitlements @@ -6,5 +6,9 @@ group.com.internxt.snacks + keychain-access-groups + + $(AppIdentifierPrefix)group.com.internxt.snacks + \ No newline at end of file diff --git a/ios/InternxtShareExtension/ShareExtensionViewController.swift b/ios/InternxtShareExtension/ShareExtensionViewController.swift index cd0a5f803..4958ed645 100644 --- a/ios/InternxtShareExtension/ShareExtensionViewController.swift +++ b/ios/InternxtShareExtension/ShareExtensionViewController.swift @@ -1,22 +1,26 @@ +// ─── From expo-share-extension library ─────────────────────────────────────── import UIKit import React import React_RCTAppDelegate import ReactAppDependencyProvider import AVFoundation import UniformTypeIdentifiers -// if react native firebase is installed, we import and configure it +// ─── Internxt additions ─────────────────────────────────────────────────────── +import Security + #if canImport(FirebaseCore) import FirebaseCore #endif #if canImport(FirebaseAuth) import FirebaseAuth #endif +// ───────────────────────────────────────────────────────────────────────────── class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { override func sourceURL(for _: RCTBridge) -> URL? { self.bundleURL() } - + override func bundleURL() -> URL? { #if DEBUG let settings = RCTBundleURLProvider.sharedSettings() @@ -46,103 +50,96 @@ class ShareExtensionViewController: UIViewController { private var isCleanedUp = false deinit { - print("🧹 ShareExtensionViewController deinit") cleanupAfterClose() } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Start cleanup earlier to ensure proper surface teardown if isBeingDismissed { cleanupAfterClose() } } - + override func viewDidLoad() { super.viewDidLoad() setupLoadingIndicator() isCleanedUp = false - - // Set the contentScaleFactor for the main view of this view controller self.view.contentScaleFactor = UIScreen.main.scale - + #if canImport(FirebaseCore) if Bundle.main.object(forInfoDictionaryKey: "WithFirebase") as? Bool ?? false { FirebaseApp.configure() } #endif - + loadReactNativeContent() setupNotificationCenterObserver() } - + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - // we need to clean up when the view is closed via a swipe cleanupAfterClose() } - + func close() { self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - // we need to clean up when the view is closed via the close() method in react native cleanupAfterClose() } - + private func loadReactNativeContent() { getShareData { [weak self] sharedData in - guard let self = self else { - print("❌ Self was deallocated") - return - } - + guard let self = self else { return } + + // ── From expo-share-extension library ────────────────────────────────── reactNativeFactoryDelegate = ReactNativeDelegate() reactNativeFactoryDelegate!.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = RCTReactNativeFactory(delegate: reactNativeFactoryDelegate!) - + var initialProps = sharedData ?? [:] - - // Capture current view's properties before replacing it + // ── Internxt: inject auth state from UserDefaults and Keychain ────────── + if let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String, + let defaults = UserDefaults(suiteName: appGroup) { + initialProps["isAuthenticated"] = defaults.bool(forKey: "isAuthenticated") + initialProps["userEmail"] = defaults.string(forKey: "userEmail") + } + + if let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String { + initialProps["photosToken"] = readFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup) + if let raw = readFromSharedKeychain(key: "shared_mnemonic", accessGroup: sharedGroup) { + // Strip JSON string encoding added by expo-secure-store: '"words"' → 'words' + initialProps["mnemonic"] = (raw.hasPrefix("\"") && raw.hasSuffix("\"")) + ? String(raw.dropFirst().dropLast()) + : raw + } + } + // ── From expo-share-extension library ────────────────────────────────── let currentBounds = self.view.bounds - let currentScale = UIScreen.main.scale - - // Log the scale of the parent view - print("[ShareExtension] self.view.contentScaleFactor before adding subview: \(self.view.contentScaleFactor)") - print("[ShareExtension] UIScreen.main.scale: \(currentScale)") - - // Add screen metrics to initial properties for React Native - // These can be used by the JS side to understand its container size and scale initialProps["initialViewWidth"] = currentBounds.width initialProps["initialViewHeight"] = currentBounds.height - initialProps["pixelRatio"] = currentScale - // It's also good practice to pass the font scale for accessibility - // Default body size on iOS is 17pt, used as a reference for calculating fontScale. + initialProps["pixelRatio"] = UIScreen.main.scale initialProps["fontScale"] = UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0 - - // Create the React Native root view + let reactNativeRootView = reactNativeFactory!.rootViewFactory.view( withModuleName: "shareExtension", initialProperties: initialProps ) - + let backgroundFromInfoPlist = Bundle.main.object(forInfoDictionaryKey: "ShareExtensionBackgroundColor") as? [String: CGFloat] let heightFromInfoPlist = Bundle.main.object(forInfoDictionaryKey: "ShareExtensionHeight") as? CGFloat - + configureRootView(reactNativeRootView, withBackgroundColorDict: backgroundFromInfoPlist, withHeight: heightFromInfoPlist) view.addSubview(reactNativeRootView) - // Hide loading indicator once React content is ready self.loadingIndicator.stopAnimating() self.loadingIndicator.removeFromSuperview() + // ─────────────────────────────────────────────────────────────────────── } } - + private func configureRootView(_ rootView: UIView, withBackgroundColorDict dict: [String: CGFloat]?, withHeight: CGFloat?) { rootView.backgroundColor = backgroundColor(from: dict) - // Get the screen bounds let screenBounds = UIScreen.main.bounds - - // Calculate proper frame let frame: CGRect if let withHeight = withHeight { rootView.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] @@ -158,7 +155,7 @@ class ShareExtensionViewController: UIViewController { } rootView.frame = frame } - + private func setupLoadingIndicator() { view.addSubview(loadingIndicator) loadingIndicator.translatesAutoresizingMaskIntoConstraints = false @@ -168,19 +165,18 @@ class ShareExtensionViewController: UIViewController { ]) loadingIndicator.startAnimating() } - + private func openHostApp(path: String?) { guard let scheme = Bundle.main.object(forInfoDictionaryKey: "HostAppScheme") as? String else { return } var urlComponents = URLComponents() urlComponents.scheme = scheme urlComponents.host = "" - + if let path = path { let pathComponents = path.split(separator: "?", maxSplits: 1) let pathWithoutQuery = String(pathComponents[0]) let queryString = pathComponents.count > 1 ? String(pathComponents[1]) : nil - - // Parse and set query items + if let queryString = queryString { let queryItems = queryString.split(separator: "&").map { queryParam -> URLQueryItem in let paramComponents = queryParam.split(separator: "=", maxSplits: 1) @@ -190,7 +186,7 @@ class ShareExtensionViewController: UIViewController { } urlComponents.queryItems = queryItems } - + var baseComponents = URLComponents() baseComponents.scheme = scheme baseComponents.host = "" @@ -199,12 +195,12 @@ class ShareExtensionViewController: UIViewController { urlComponents.path = baseURL.appendingPathComponent(strippedPath).path } } - + guard let url = urlComponents.url else { return } openURL(url) self.close() } - + @objc @discardableResult private func openURL(_ url: URL) -> Bool { // Method 1: Try responder chain to find UIApplication var responder: UIResponder? = self @@ -216,7 +212,7 @@ class ShareExtensionViewController: UIViewController { responder = responder?.next } - // Method 2: Try selector-based approach as fallback + // Method 2: Selector-based fallback let selector = NSSelectorFromString("openURL:") var responder2: UIResponder? = self while responder2 != nil { @@ -229,14 +225,14 @@ class ShareExtensionViewController: UIViewController { return false } - + private func setupNotificationCenterObserver() { NotificationCenter.default.addObserver(forName: NSNotification.Name("close"), object: nil, queue: nil) { [weak self] _ in DispatchQueue.main.async { self?.close() } } - + NotificationCenter.default.addObserver(forName: NSNotification.Name("openHostApp"), object: nil, queue: nil) { [weak self] notification in DispatchQueue.main.async { if let userInfo = notification.userInfo, let path = userInfo["path"] as? String { @@ -245,26 +241,43 @@ class ShareExtensionViewController: UIViewController { } } } - + private func cleanupAfterClose() { if isCleanedUp { return } isCleanedUp = true - + NotificationCenter.default.removeObserver(self) - - // Remove React Native view and deallocate resources + view.subviews.forEach { subview in - if subview is RCTRootView { - subview.removeFromSuperview() - } + if subview is RCTRootView { + subview.removeFromSuperview() + } } - + reactNativeFactory = nil reactNativeFactoryDelegate = nil - - print("🧹 ShareExtensionViewController cleaned up") } - + + // ── Internxt: read a value from the shared Keychain access group ──────────── + private func readFromSharedKeychain(key: String, accessGroup: String) -> String? { + var result: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "app:no-auth", + kSecAttrGeneric as String: Data(key.utf8), + kSecAttrAccount as String: Data(key.utf8), + kSecAttrAccessGroup as String: accessGroup, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { return nil } + return value + } + // ───────────────────────────────────────────────────────────────────────── + + // ── From expo-share-extension library ───────────────────────────────────── private func backgroundColor(from dict: [String: CGFloat]?) -> UIColor { guard let dict = dict else { return .systemBackground } let red = dict["red"] ?? 255.0 @@ -273,19 +286,21 @@ class ShareExtensionViewController: UIViewController { let alpha = dict["alpha"] ?? 1 return UIColor(red: red / 255.0, green: green / 255.0, blue: blue / 255.0, alpha: alpha) } - + + // ─── From expo-share-extension library ─────────────────────────────────────── + // Reads the NSExtensionItem attachments from the share context and classifies + // each one by UTType into: images / videos / files / url / text. + // The result is passed as initialProperties to the React Native root view. private func getShareData(completion: @escaping ([String: Any]?) -> Void) { guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { completion(nil) return } - + var sharedItems: [String: Any] = [:] - let group = DispatchGroup() - let fileManager = FileManager.default - + for item in extensionItems { for provider in item.attachments ?? [] { if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { @@ -298,29 +313,28 @@ class ShareExtensionViewController: UIViewController { let fileExtension = sharedURL.pathExtension.lowercased() let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "heic", "heif", "webp"] var isImage = imageExtensions.contains(fileExtension) - + if !isImage, let resourceValues = try? sharedURL.resourceValues(forKeys: [.typeIdentifierKey]), let typeIdentifier = resourceValues.typeIdentifier { isImage = UTType(typeIdentifier)?.conforms(to: .image) ?? false } - + guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String else { print("Could not find AppGroup in info.plist") group.leave() return } - + guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { print("Could not set up file manager container URL for app group") group.leave() return } - + let tempFilePath = sharedURL.path let fileName = sharedURL.lastPathComponent - let sharedDataUrl = containerUrl.appendingPathComponent("sharedData") - + if !fileManager.fileExists(atPath: sharedDataUrl.path) { do { try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true) @@ -328,9 +342,9 @@ class ShareExtensionViewController: UIViewController { print("Failed to create sharedData directory: \(error)") } } - + let persistentURL = sharedDataUrl.appendingPathComponent(fileName) - + do { try fileManager.copyItem(atPath: tempFilePath, toPath: persistentURL.path) let key = isImage ? "images" : "files" @@ -383,29 +397,28 @@ class ShareExtensionViewController: UIViewController { group.enter() provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (imageItem, error) in DispatchQueue.main.async { - + // Ensure the array exists if sharedItems["images"] == nil { sharedItems["images"] = [String]() } - + guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String else { print("Could not find AppGroup in info.plist") return } - + guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { print("Could not set up file manager container URL for app group") return } - + if let imageUri = imageItem as? NSURL { if let tempFilePath = imageUri.path { let fileExtension = imageUri.pathExtension ?? "jpg" let fileName = UUID().uuidString + "." + fileExtension - let sharedDataUrl = containerUrl.appendingPathComponent("sharedData") - + if !fileManager.fileExists(atPath: sharedDataUrl.path) { do { try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true) @@ -413,14 +426,14 @@ class ShareExtensionViewController: UIViewController { print("Failed to create sharedData directory: \(error)") } } - + let persistentURL = sharedDataUrl.appendingPathComponent(fileName) - + do { try fileManager.copyItem(atPath: tempFilePath, toPath: persistentURL.path) - if var videoArray = sharedItems["images"] as? [String] { - videoArray.append(persistentURL.absoluteString) - sharedItems["images"] = videoArray + if var imageArray = sharedItems["images"] as? [String] { + imageArray.append(persistentURL.absoluteString) + sharedItems["images"] = imageArray } } catch { print("Failed to copy image: \(error)") @@ -430,9 +443,8 @@ class ShareExtensionViewController: UIViewController { // Handle UIImage if needed (e.g., save to disk and get the file path) if let imageData = image.jpegData(compressionQuality: 1.0) { let fileName = UUID().uuidString + ".jpg" - let sharedDataUrl = containerUrl.appendingPathComponent("sharedData") - + if !fileManager.fileExists(atPath: sharedDataUrl.path) { do { try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true) @@ -440,9 +452,9 @@ class ShareExtensionViewController: UIViewController { print("Failed to create sharedData directory: \(error)") } } - + let persistentURL = sharedDataUrl.appendingPathComponent(fileName) - + do { try imageData.write(to: persistentURL) if var imageArray = sharedItems["images"] as? [String] { @@ -489,30 +501,29 @@ class ShareExtensionViewController: UIViewController { provider.loadItem(forTypeIdentifier: UTType.movie.identifier, options: nil) { (videoItem, error) in DispatchQueue.main.async { print("videoItem type: \(type(of: videoItem))") - + // Ensure the array exists if sharedItems["videos"] == nil { sharedItems["videos"] = [String]() } - + guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String else { print("Could not find AppGroup in info.plist") return } - + guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { print("Could not set up file manager container URL for app group") return } - + // Check if videoItem is NSURL if let videoUri = videoItem as? NSURL { if let tempFilePath = videoUri.path { let fileExtension = videoUri.pathExtension ?? "mov" let fileName = UUID().uuidString + "." + fileExtension - let sharedDataUrl = containerUrl.appendingPathComponent("sharedData") - + if !fileManager.fileExists(atPath: sharedDataUrl.path) { do { try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true) @@ -520,9 +531,9 @@ class ShareExtensionViewController: UIViewController { print("Failed to create sharedData directory: \(error)") } } - + let persistentURL = sharedDataUrl.appendingPathComponent(fileName) - + do { try fileManager.copyItem(atPath: tempFilePath, toPath: persistentURL.path) if var videoArray = sharedItems["videos"] as? [String] { @@ -538,9 +549,8 @@ class ShareExtensionViewController: UIViewController { else if let videoData = videoItem as? NSData { let fileExtension = "mov" // Using mov as default type extension let fileName = UUID().uuidString + "." + fileExtension - let sharedDataUrl = containerUrl.appendingPathComponent("sharedData") - + if !fileManager.fileExists(atPath: sharedDataUrl.path) { do { try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true) @@ -548,9 +558,9 @@ class ShareExtensionViewController: UIViewController { print("Failed to create sharedData directory: \(error)") } } - + let persistentURL = sharedDataUrl.appendingPathComponent(fileName) - + do { try videoData.write(to: persistentURL) if var videoArray = sharedItems["videos"] as? [String] { @@ -564,12 +574,12 @@ class ShareExtensionViewController: UIViewController { // Check if videoItem is AVAsset else if let asset = videoItem as? AVAsset { let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) - + let fileExtension = "mov" // Using mov as default type extension let fileName = UUID().uuidString + "." + fileExtension - + let sharedDataUrl = containerUrl.appendingPathComponent("sharedData") - + if !fileManager.fileExists(atPath: sharedDataUrl.path) { do { try fileManager.createDirectory(at: sharedDataUrl, withIntermediateDirectories: true) @@ -577,9 +587,9 @@ class ShareExtensionViewController: UIViewController { print("Failed to create sharedData directory: \(error)") } } - + let persistentURL = sharedDataUrl.appendingPathComponent(fileName) - + exportSession?.outputURL = persistentURL exportSession?.outputFileType = .mov func onExportComplete() { @@ -605,7 +615,7 @@ class ShareExtensionViewController: UIViewController { } } } - + group.notify(queue: .main) { completion(sharedItems.isEmpty ? nil : sharedItems) } diff --git a/src/shareExtension/AndroidShareScreen.tsx b/src/shareExtension/AndroidShareScreen.tsx index 523b6bff4..117c30863 100644 --- a/src/shareExtension/AndroidShareScreen.tsx +++ b/src/shareExtension/AndroidShareScreen.tsx @@ -1,73 +1,103 @@ -import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; +import strings from '../../assets/lang/strings'; +import asyncStorageService from '../services/AsyncStorageService'; +import { AsyncStorageKey } from '../types'; import { RootStackScreenProps } from '../types/navigation'; +import { useShareAuth } from './hooks/useShareAuth.android'; +import { NotSignedInScreen } from './screens/NotSignedInScreen'; -const AndroidShareScreen = ({ route }: RootStackScreenProps<'AndroidShare'>) => { +interface DebugInfo { + userEmail: string | null; + photosToken: string | null; + mnemonic: string | null; +} + +const AndroidShareScreen = ({ navigation, route }: RootStackScreenProps<'AndroidShare'>) => { + const tailwind = useTailwind(); + const authStatus = useShareAuth(); + const [debug, setDebug] = useState({ userEmail: null, photosToken: null, mnemonic: null }); + + useEffect(() => { + if (authStatus !== 'authenticated') return; + Promise.all([asyncStorageService.getUser(), asyncStorageService.getItem(AsyncStorageKey.PhotosToken)]).then( + ([user, photosToken]) => { + setDebug({ + userEmail: user?.email ?? null, + photosToken, + mnemonic: user?.mnemonic ?? null, + }); + }, + ); + }, [authStatus]); + + if (authStatus === 'loading') { + return ( + + + + ); + } + + if (authStatus === 'unauthenticated') { + return navigation.goBack()} onOpenLogin={() => navigation.navigate('SignIn')} />; + } + + const translations = strings.screens.ShareExtension; const files = route.params?.files ?? []; return ( - - Save to Internxt - - {files.length} {files.length === 1 ? 'file' : 'files'} - + + + navigation.goBack()} style={tailwind('w-8 h-8 items-center justify-center')}> + + + {translations.title} + + - {files.map((file) => ( - - - + {debug.userEmail ? ( + + Signed in as + {debug.userEmail} + + ) : null} + {debug.photosToken ? ( + + Signed new token + {debug.photosToken} - ))} - + ) : null} + {debug.mnemonic ? ( + + Signed in with mnemonic + {debug.mnemonic} + + ) : null} + + + {files.map((file) => { + const name = file.fileName ?? file.uri.split('/').pop() ?? file.uri; + return ( + + + {name} + + + ); + })} + + ); }; -const Row = ({ label, value }: { label: string; value: string }) => ( - - {label} - - {value} - - -); - const styles = StyleSheet.create({ - container: { - padding: 24, - paddingTop: 60, - }, - title: { - fontSize: 20, - fontWeight: '700', - color: '#000000', - marginBottom: 4, - }, - count: { - fontSize: 14, - color: '#6b7280', - marginBottom: 20, - }, - card: { - backgroundColor: '#f9fafb', - borderRadius: 10, - padding: 14, - marginBottom: 12, - gap: 6, - }, - row: { - flexDirection: 'row', - gap: 8, - }, - label: { - fontSize: 12, - fontWeight: '600', - color: '#6b7280', - width: 44, - paddingTop: 1, - }, - value: { - fontSize: 13, - color: '#111827', - flex: 1, + semibold: { fontWeight: '600' }, + blueSection: { + backgroundColor: 'rgba(0,102,255,0.05)', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,102,255,0.2)', }, }); diff --git a/src/shareExtension/ShareExtensionApp.tsx b/src/shareExtension/ShareExtensionApp.tsx index 32e2a2de5..f8c6c0c74 100644 --- a/src/shareExtension/ShareExtensionApp.tsx +++ b/src/shareExtension/ShareExtensionApp.tsx @@ -1,122 +1,147 @@ -import { File } from 'expo-file-system'; -import { close, type InitialProps } from 'expo-share-extension'; -import prettysize from 'prettysize'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { close, openHostApp } from 'expo-share-extension'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import strings from '../../assets/lang/strings'; +import { NotSignedInScreen } from './screens/NotSignedInScreen'; -interface FileRow { - path: string; - name: string; - size: string | null; +interface ShareExtensionProps { + isAuthenticated?: boolean; + userEmail?: string; + photosToken?: string; + mnemonic?: string; + files?: string[]; + images?: string[]; + videos?: string[]; + url?: string; + text?: string; } -function buildRow(path: string): FileRow { - const name = path.split('/').pop() ?? path; - try { - const file = new File(path); - const size = file.exists && file.size > 0 ? prettysize(file.size) : null; - return { path, name, size }; - } catch { - return { path, name, size: null }; +const ShareExtensionApp = (props: ShareExtensionProps) => { + const translations = strings.screens.ShareExtension; + + if (!props.isAuthenticated) { + return openHostApp('sign-in')} />; } -} -const ShareExtensionApp = (props: InitialProps) => { - const rows = [...(props.files ?? []), ...(props.images ?? []), ...(props.videos ?? [])].map(buildRow); + const allFiles = [...(props.files ?? []), ...(props.images ?? []), ...(props.videos ?? [])]; return ( - - - - - Save to Internxt - - {rows.length} {rows.length === 1 ? 'file' : 'files'} - - - {rows.map((file) => ( - - - - {file.size && } + + + + + + {translations.title} + + + {props.userEmail ? ( + + Signed in as + {props.userEmail} - ))} - - {props.text && ( - - + ) : null} + {props.photosToken ? ( + + Signed new token + {props.photosToken} - )} - {props.url && ( - - + ) : null} + {props.mnemonic ? ( + + Signed in with mnemonic + {props.mnemonic} - )} - + ) : null} + + {allFiles.map((filePath) => { + const name = filePath.split('/').pop() ?? filePath; + return ( + + + {name} + + + ); + })} + {props.url ? ( + + + {props.url} + + + ) : null} + {props.text ? ( + + + {props.text} + + + ) : null} + + ); }; -const Row = ({ label, value }: { label: string; value: string }) => ( - - {label} - - {value} - - -); - const styles = StyleSheet.create({ container: { - padding: 24, - paddingTop: 60, + flex: 1, + backgroundColor: '#ffffff', }, - closeButton: { - position: 'absolute', - top: 16, - right: 16, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e5e7eb', + }, + closeBtn: { width: 32, height: 32, - borderRadius: 16, - backgroundColor: '#e5e7eb', alignItems: 'center', justifyContent: 'center', }, closeText: { - fontSize: 14, - color: '#374151', + fontSize: 18, + color: '#6b7280', }, title: { - fontSize: 20, - fontWeight: '700', - color: '#000000', - marginBottom: 4, + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', }, - count: { - fontSize: 14, - color: '#6b7280', - marginBottom: 20, + content: { + padding: 16, + gap: 8, }, - card: { + fileRow: { backgroundColor: '#f9fafb', - borderRadius: 10, - padding: 14, - marginBottom: 12, - gap: 6, + borderRadius: 8, + padding: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#e5e7eb', }, - row: { - flexDirection: 'row', - gap: 8, + fileName: { + fontSize: 14, + color: '#374151', }, - label: { - fontSize: 12, - fontWeight: '600', + sessionBanner: { + backgroundColor: '#f0f7ff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#bfdbfe', + paddingHorizontal: 16, + paddingVertical: 10, + }, + sessionLabel: { + fontSize: 11, color: '#6b7280', - width: 44, - paddingTop: 1, + marginBottom: 2, }, - value: { + sessionEmail: { fontSize: 13, - color: '#111827', - flex: 1, + fontWeight: '600', + color: '#0066FF', }, }); diff --git a/src/shareExtension/hooks/useShareAuth.android.ts b/src/shareExtension/hooks/useShareAuth.android.ts new file mode 100644 index 000000000..faffed471 --- /dev/null +++ b/src/shareExtension/hooks/useShareAuth.android.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; +import asyncStorageService from '../../services/AsyncStorageService'; +import { AsyncStorageKey } from '../../types'; + +export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; + +export const useShareAuth = (): AuthStatus => { + const [status, setStatus] = useState('loading'); + + useEffect(() => { + asyncStorageService + .getItem(AsyncStorageKey.PhotosToken) + .then((token) => setStatus(token ? 'authenticated' : 'unauthenticated')) + .catch(() => setStatus('unauthenticated')); + }, []); + + return status; +}; diff --git a/src/shareExtension/screens/NotSignedInScreen.tsx b/src/shareExtension/screens/NotSignedInScreen.tsx new file mode 100644 index 000000000..008bec8fa --- /dev/null +++ b/src/shareExtension/screens/NotSignedInScreen.tsx @@ -0,0 +1,136 @@ +import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import Svg, { Path } from 'react-native-svg'; +import strings from '../../../assets/lang/strings'; + +interface NotSignedInScreenProps { + onClose: () => void; + onOpenLogin: () => void; +} + +function LoginIcon() { + return ( + + + + ); +} + +export function NotSignedInScreen({ onClose, onOpenLogin }: NotSignedInScreenProps) { + const translations = strings.screens.ShareExtension; + + return ( + + + + + + + + {translations.title} + + + + + + {translations.notSignedIn.title} + {translations.notSignedIn.subtitle} + + {translations.notSignedIn.openLogin} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#d1d5db', + alignSelf: 'center', + marginTop: 8, + marginBottom: 4, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e5e7eb', + }, + closeButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + closeText: { + fontSize: 18, + color: '#6b7280', + }, + headerTitle: { + flex: 1, + textAlign: 'center', + fontSize: 16, + fontWeight: '600', + color: '#111827', + }, + headerSpacer: { + width: 32, + }, + body: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 24, + paddingBottom: 36, + gap: 12, + }, + title: { + fontFamily: Platform.select({ android: 'InstrumentSans-SemiBold' }), + fontWeight: '600', + fontSize: Platform.select({ ios: 36, android: 30 }), + lineHeight: Platform.select({ ios: 44, android: 36 }), + color: '#1C1C1C', + textAlign: 'center', + marginTop: 8, + }, + subtitle: { + fontFamily: Platform.select({ android: 'InstrumentSans-Regular' }), + fontWeight: '400', + fontSize: Platform.select({ ios: 20, android: 18 }), + lineHeight: Platform.select({ ios: 24, android: 22 }), + color: '#737373', + textAlign: 'center', + }, + loginButton: { + backgroundColor: '#0066FF', + borderRadius: 12, + paddingVertical: 16, + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + marginTop: 12, + }, + loginButtonText: { + fontFamily: Platform.select({ android: 'InstrumentSans-SemiBold' }), + fontWeight: '600', + fontSize: 16, + lineHeight: 20, + textAlign: 'center', + color: '#ffffff', + }, +}); diff --git a/src/shareExtension/useAndroidShareIntent.ts b/src/shareExtension/useAndroidShareIntent.ts index 423671878..70e6ee049 100644 --- a/src/shareExtension/useAndroidShareIntent.ts +++ b/src/shareExtension/useAndroidShareIntent.ts @@ -24,10 +24,6 @@ export function useAndroidShareIntent( useEffect(() => { if (!pendingFiles?.length || isLoggedIn == null) return; - if (!isLoggedIn) { - setPendingFiles(null); - return; - } navigationContainerRef?.navigate('AndroidShare', { files: pendingFiles }); setPendingFiles(null); }, [pendingFiles, isLoggedIn, navigationContainerRef]); From 727d388b9c8522c71b118999cecf3f565a3d2783 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Mon, 2 Mar 2026 18:05:42 +0100 Subject: [PATCH 2/2] fix: update keychain accessibility to 'When Unlocked' for improved security --- ios/Internxt/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Internxt/AppDelegate.swift b/ios/Internxt/AppDelegate.swift index 1b19be3c1..0ec1e60c0 100644 --- a/ios/Internxt/AppDelegate.swift +++ b/ios/Internxt/AppDelegate.swift @@ -107,7 +107,7 @@ public class AppDelegate: ExpoAppDelegate { kSecAttrGeneric as String: Data(key.utf8), kSecAttrAccount as String: Data(key.utf8), kSecAttrAccessGroup as String: accessGroup, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecValueData as String: data, ] SecItemAdd(query as CFDictionary, nil)