Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/modules/photo-masonry/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["AfilmorySessionModule", "PhotoMasonryModule", "NativePagesModule"],
"modules": ["AfilmorySessionModule", "PhotoMasonryModule", "NativePagesModule", "StoreKitBillingModule"],
"appDelegateSubscribers": ["PushAppDelegateSubscriber", "UploadAppDelegateSubscriber"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import ExpoModulesCore
import StoreKit
import UIKit

public final class StoreKitBillingModule: Module {
private var transactionUpdatesTask: Task<Void, Never>?

public func definition() -> ModuleDefinition {
Name("StoreKitBilling")
Events("onTransaction")

OnCreate {
self.transactionUpdatesTask = Task { [weak self] in
for await verification in Transaction.updates {
guard !Task.isCancelled else { return }
guard case .verified(let transaction) = verification else { continue }
let payload = Self.transactionPayload(transaction, signedTransactionInfo: verification.jwsRepresentation)
await MainActor.run {
self?.sendEvent("onTransaction", payload)
}
}
}
}

OnDestroy {
self.transactionUpdatesTask?.cancel()
self.transactionUpdatesTask = nil
}

AsyncFunction("loadProducts") { (productIds: [String]) in
let requestedIds = Self.uniqueProductIds(productIds)
let products = try await Product.products(for: requestedIds)
let productsById = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
return requestedIds.compactMap { productId in
productsById[productId].map(Self.productPayload)
}
}

AsyncFunction("purchase") { (productId: String, appAccountToken: String) in
guard let token = UUID(uuidString: appAccountToken) else {
throw StoreKitBillingError.invalidAppAccountToken
}
guard let product = try await Product.products(for: [productId]).first(where: { $0.id == productId }) else {
throw StoreKitBillingError.productUnavailable
}

switch try await product.purchase(options: [.appAccountToken(token)]) {
case .success(let verification):
switch verification {
case .verified(let transaction):
return Self.transactionPayload(transaction, signedTransactionInfo: verification.jwsRepresentation)
.merging(["status": "success"]) { _, next in next }
case .unverified:
throw StoreKitBillingError.unverifiedTransaction
}
case .pending:
return ["status": "pending"]
case .userCancelled:
return ["status": "cancelled"]
@unknown default:
throw StoreKitBillingError.unknownPurchaseResult
}
}

AsyncFunction("unfinishedTransactions") {
var transactions: [[String: Any]] = []
for await verification in Transaction.unfinished {
guard case .verified(let transaction) = verification else { continue }
transactions.append(
Self.transactionPayload(transaction, signedTransactionInfo: verification.jwsRepresentation)
)
}
return transactions
}

AsyncFunction("restoreTransactions") { (productIds: [String]) in
try await AppStore.sync()
let allowedProductIds = Set(Self.uniqueProductIds(productIds))
var transactions: [[String: Any]] = []
for await verification in Transaction.currentEntitlements {
guard case .verified(let transaction) = verification else { continue }
guard allowedProductIds.contains(transaction.productID) else { continue }
transactions.append(
Self.transactionPayload(transaction, signedTransactionInfo: verification.jwsRepresentation)
)
}
return transactions
}

AsyncFunction("finishTransaction") { (transactionId: String) in
guard let expectedId = UInt64(transactionId) else {
throw StoreKitBillingError.invalidTransactionIdentifier
}
for await verification in Transaction.unfinished {
guard case .verified(let transaction) = verification else { continue }
guard transaction.id == expectedId else { continue }
guard StoreKitBillingFinishGate.allowsFinish(isVerified: true, serverAcknowledged: true) else {
return false
}
await transaction.finish()
return true
}
return false
}

AsyncFunction("manageSubscriptions") {
let windowScene = await MainActor.run {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
}
guard let windowScene else {
throw StoreKitBillingError.windowSceneUnavailable
}
try await AppStore.showManageSubscriptions(in: windowScene)
}
}

private static func uniqueProductIds(_ values: [String]) -> [String] {
var seen = Set<String>()
return values.compactMap { value in
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty, seen.insert(normalized).inserted else { return nil }
return normalized
}
}

private static func productPayload(_ product: Product) -> [String: Any] {
var payload: [String: Any] = [
"displayName": product.displayName,
"displayPrice": product.displayPrice,
"id": product.id,
]
if let period = product.subscription?.subscriptionPeriod {
payload["subscriptionPeriod"] = [
"unit": subscriptionPeriodUnit(period.unit),
"value": period.value,
]
}
return payload
}

private static func subscriptionPeriodUnit(_ unit: Product.SubscriptionPeriod.Unit) -> String {
switch unit {
case .day: "day"
case .week: "week"
case .month: "month"
case .year: "year"
@unknown default: "unknown"
}
}

private static func transactionPayload(
_ transaction: StoreKit.Transaction,
signedTransactionInfo: String
) -> [String: Any] {
[
"productId": transaction.productID,
"signedTransactionInfo": signedTransactionInfo,
"transactionId": String(transaction.id),
]
}
}

private enum StoreKitBillingError: Error {
case invalidAppAccountToken
case invalidTransactionIdentifier
case productUnavailable
case unknownPurchaseResult
case unverifiedTransaction
case windowSceneUnavailable
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
enum StoreKitBillingFinishGate {
static func allowsFinish(isVerified: Bool, serverAcknowledged: Bool) -> Bool {
isVerified && serverAcknowledged
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import XCTest

@testable import PhotoMasonry

final class StoreKitBillingPolicyTests: XCTestCase {
func testVerifiedTransactionFinishesOnlyAfterServerAcknowledgement() {
XCTAssertFalse(StoreKitBillingFinishGate.allowsFinish(isVerified: true, serverAcknowledged: false))
XCTAssertTrue(StoreKitBillingFinishGate.allowsFinish(isVerified: true, serverAcknowledged: true))
}

func testUnverifiedTransactionNeverFinishes() {
XCTAssertFalse(StoreKitBillingFinishGate.allowsFinish(isVerified: false, serverAcknowledged: true))
}
}
9 changes: 8 additions & 1 deletion apps/mobile/src/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useRef } from 'react'

import { useTranslation } from '@/i18n'
import { useAuth } from '@/modules/auth/sessionStore'
import { isMobileWorkspaceReady, useMobileOnboarding } from '@/modules/onboarding/onboardingStore'
import { createDevLabShortcutState, registerDevLabTabPress } from '@/modules/shell/devLabShortcut'
import { getAvailableTabNames, shouldShowTabBar } from '@/modules/shell/tabAccess'
import { useTheme } from '@/theme/useTheme'
Expand All @@ -12,11 +13,13 @@ export default function TabLayout() {
const { palette } = useTheme()
const { t } = useTranslation()
const auth = useAuth()
const onboarding = useMobileOnboarding()
const router = useRouter()
const segments = useSegments() as string[]
const isStudio = segments.includes('studio')
const isExplore = segments.includes('explore')
const availableTabs = getAvailableTabNames(auth.status)
const workspaceReady = isMobileWorkspaceReady(onboarding)
const availableTabs = getAvailableTabNames(auth.status, workspaceReady)
const isSignedOut = auth.status === 'signedOut'
const devLabShortcutStateRef = useRef(createDevLabShortcutState())
const handleTabPress = useCallback(
Expand All @@ -43,6 +46,10 @@ export default function TabLayout() {
return <Redirect href="/explore" />
}

if (auth.status === 'signedIn' && !workspaceReady && !isExplore) {
return <Redirect href="/explore" />
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer redirects until onboarding readiness loads

On a cold start or deep link to Photos/Map/Studio, useMobileOnboarding() is still idle/loading, so isMobileWorkspaceReady() returns false even for an already configured workspace. This branch immediately redirects signed-in users to Explore, and once readiness later becomes ready there is no navigation back to the original/default tab, so valid signed-in sessions lose their intended route.

Useful? React with 👍 / 👎.

}

return (
<NativeTabs
hidden={!shouldShowTabBar(auth.status)}
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/app/(tabs)/explore/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useLocalSearchParams } from 'expo-router'
import { useMemo } from 'react'
import { StyleSheet, View } from 'react-native'

import { OnboardingResumeBanner } from '@/modules/onboarding/OnboardingResumeBanner'
import { NativePageView } from '@/native/NativePageView'

export default function ExplorePage() {
Expand All @@ -23,9 +25,18 @@ export default function ExplorePage() {
})
}, [eventId, galleryName, gallerySlug])

return <NativePageView galleryRoute={galleryRoute} page="explore" />
return (
<View style={styles.root}>
<NativePageView galleryRoute={galleryRoute} page="explore" />
<OnboardingResumeBanner />
</View>
)
}

const styles = StyleSheet.create({
root: { flex: 1 },
})

function firstValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value
}
2 changes: 2 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { i18n } from '@/i18n'
import { addAppleCredentialRevokeListener } from '@/modules/auth/appleAuthentication'
import { waitForAuthStorage } from '@/modules/auth/authStorage'
import { clearLocalAuthentication, hydrateAuth } from '@/modules/auth/sessionStore'
import { MobileOnboardingCoordinator } from '@/modules/onboarding/MobileOnboardingCoordinator'
import { PresentationHost } from '@/presentation'
import { useTheme as useAppTheme } from '@/theme/useTheme'

Expand Down Expand Up @@ -65,6 +66,7 @@ export default function RootLayout() {
{__DEV__ ? <Stack.Screen name="dev" /> : null}
</Stack>
) : null}
<MobileOnboardingCoordinator />
<PresentationHost />
</ThemeProvider>
</SafeAreaProvider>
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Redirect } from 'expo-router'

import { useAuth } from '@/modules/auth/sessionStore'
import { isMobileWorkspaceReady, useMobileOnboarding } from '@/modules/onboarding/onboardingStore'
import { getDefaultTabPath } from '@/modules/shell/tabAccess'

export default function IndexRoute() {
const auth = useAuth()
const href = getDefaultTabPath(auth.status)
const onboarding = useMobileOnboarding()
const href = getDefaultTabPath(auth.status, isMobileWorkspaceReady(onboarding))

return href ? <Redirect href={href} /> : null
}
12 changes: 12 additions & 0 deletions apps/mobile/src/app/onboarding/storage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Redirect } from 'expo-router'
import { useEffect } from 'react'

import { refreshMobileOnboarding } from '@/modules/onboarding/onboardingStore'

export default function StorageHandoffReturnRoute() {
useEffect(() => {
void refreshMobileOnboarding()
}, [])

return <Redirect href="/explore" />
}
Loading
Loading