Skip to content

Commit ebbc75b

Browse files
authored
Merge pull request #270 from Programcode/native-ios/r6-apple-platform
iOS round 6: Live Activities, Sign in with Apple, in-app purchase, Siri and Handoff
2 parents 5b6c4f9 + a415c2d commit ebbc75b

79 files changed

Lines changed: 4312 additions & 127 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/AGENTS.md‎

Lines changed: 15 additions & 7 deletions
Large diffs are not rendered by default.

‎apps/mobile/app.config.ts‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import path from 'node:path';
55
import type { ConfigContext, ExpoConfig } from 'expo/config';
66
import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins';
77

8+
// CommonJS, because Node loads what app.config.ts imports as it is.
9+
import { withAppIntents } from './plugins/with-app-intents';
10+
811

912
// One config for Expo Go, the web export and the development build.
1013
// DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the
@@ -22,9 +25,21 @@ type PermissionStrings = { speechRecognition: string; microphone: string };
2225

2326
const build = process.env.DEBATELAB_BUILD;
2427
const appGroup = process.env.DEBATELAB_APP_GROUP;
28+
// DEBATELAB_APPLE_ACCOUNT=1: the build is signed by DebateOwl's own Apple
29+
// Developer team, whose App IDs carry the capabilities in docs/apple-platform.md
30+
// (Sign in with Apple, Push, Time Sensitive Notifications, Associated Domains,
31+
// In-App Purchase). Without it a development build keeps to what the borrowed
32+
// signing profile allows.
33+
const appleAccount = process.env.DEBATELAB_APPLE_ACCOUNT;
2534
if (build !== undefined && build !== '' && build !== 'dev') {
2635
throw new Error(`DEBATELAB_BUILD must be "dev" or unset, not "${build}".`);
2736
}
37+
if (appleAccount !== undefined && appleAccount !== '' && appleAccount !== '1') {
38+
throw new Error(`DEBATELAB_APPLE_ACCOUNT must be "1" or unset, not "${appleAccount}".`);
39+
}
40+
if (appleAccount === '1' && build !== 'dev') {
41+
throw new Error('DEBATELAB_APPLE_ACCOUNT only applies to a development build (DEBATELAB_BUILD=dev).');
42+
}
2843
if (appGroup && build !== 'dev') {
2944
throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).');
3045
}
@@ -54,11 +69,14 @@ function iosLocale(strings: PermissionStrings) {
5469

5570
// expo-widgets adds the push entitlement even with its push option off, and
5671
// prebuild applies expo-notifications' plugin on its own once the package is
57-
// installed, which adds it too. The signing profile has no push, so every
58-
// development build drops it until a profile with push exists.
59-
const withoutPushEntitlement: ConfigPlugin = (config) =>
72+
// installed, which adds it too; expo-apple-authentication's plugin, also
73+
// applied on its own, adds Sign in with Apple. The borrowed signing profile
74+
// has neither, so a development build drops both unless it is signed by
75+
// DebateOwl's own account (DEBATELAB_APPLE_ACCOUNT).
76+
const withoutAccountEntitlements: ConfigPlugin = (config) =>
6077
withEntitlementsPlist(config, (mod) => {
6178
delete mod.modResults['aps-environment'];
79+
delete mod.modResults['com.apple.developer.applesignin'];
6280
return mod;
6381
});
6482

@@ -163,6 +181,40 @@ export function withRound4(config: ExpoConfig, opts: { appGroup?: string; projec
163181
return config;
164182
}
165183

184+
// Round 6 (docs/apple-platform.md): Sign in with Apple and Google, the
185+
// time-sensitive timer alert, Handoff and push, for development builds only,
186+
// so the Expo Go config never changes. What needs DebateOwl's own Apple
187+
// account waits for DEBATELAB_APPLE_ACCOUNT.
188+
export function withApplePlatform(config: ExpoConfig, opts: { appleAccount: boolean }): ExpoConfig {
189+
// Handoff (src/lib/handoff.ts), next to Expo Router's own activity type.
190+
config.ios = {
191+
...config.ios,
192+
infoPlist: {
193+
...config.ios?.infoPlist,
194+
NSUserActivityTypes: [...((config.ios?.infoPlist?.NSUserActivityTypes as string[] | undefined) ?? []), 'com.pistack.debateowl.browsing'],
195+
},
196+
};
197+
config.extra = {
198+
...config.extra,
199+
// Google sign-in runs in any development build: the redirect comes back on the app's own scheme.
200+
googleSignIn: true,
201+
signInWithApple: opts.appleAccount,
202+
};
203+
if (!opts.appleAccount) return config;
204+
config.ios = { ...config.ios, usesAppleSignIn: true };
205+
config.ios.associatedDomains = [
206+
...(config.ios.associatedDomains ?? []),
207+
// Handoff from debateowl.com in Safari to the app.
208+
'activitycontinuation:debateowl.com',
209+
];
210+
config.ios.entitlements = {
211+
...config.ios.entitlements,
212+
// The timer's time-up alert breaks through a Focus (src/lib/timer-alerts.ts).
213+
'com.apple.developer.usernotifications.time-sensitive': true,
214+
};
215+
return config;
216+
}
217+
166218
export default ({ projectRoot }: ConfigContext): ExpoConfig => {
167219
const config: ExpoConfig = {
168220
name: 'DebateOwl',
@@ -278,7 +330,12 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => {
278330
}
279331
// The Current Debate widget (with the App Group), quick actions and Spotlight; see withRound4 above.
280332
withRound4(config, { appGroup, projectRoot });
333+
withApplePlatform(config, { appleAccount: appleAccount === '1' });
334+
// Siri and Shortcuts: the intents compile into the app target (plugins/with-app-intents.js).
335+
const withIntents = withAppIntents(config);
336+
// DebateOwl's own account has Push and Sign in with Apple on its App ID, so its builds keep both.
337+
if (appleAccount === '1') return withIntents;
281338
// Registered before Expo applies the plugins list. Mods registered later act
282339
// first, so this one runs after the plugins have written their entitlements.
283-
return withoutPushEntitlement(config);
340+
return withoutAccountEntitlements(withIntents);
284341
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"platforms": ["apple"],
3+
"apple": {
4+
"modules": ["HandoffModule"],
5+
"appDelegateSubscribers": ["HandoffAppDelegateSubscriber"]
6+
}
7+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Pod::Spec.new do |s|
2+
s.name = 'Handoff'
3+
s.version = '1.0.0'
4+
s.summary = "DebateOwl's Handoff activity"
5+
s.description = 'Advertises the screen open in DebateOwl (NSUserActivity) so a Mac continues on debateowl.com and an iPad in the app.'
6+
s.author = ''
7+
s.homepage = 'https://debateowl.com'
8+
s.platforms = { :ios => '16.4' }
9+
s.source = { git: '' }
10+
s.static_framework = true
11+
12+
s.dependency 'ExpoModulesCore'
13+
14+
s.pod_target_xcconfig = {
15+
'DEFINES_MODULE' => 'YES',
16+
}
17+
18+
s.source_files = "**/*.{h,m,mm,swift}"
19+
end
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import ExpoModulesCore
2+
import UIKit
3+
4+
// Handoff for src/lib/handoff.ts. JavaScript says which screen is open (a title,
5+
// the debateowl.com page that matches it and the app route); this advertises it
6+
// as the current NSUserActivity, so a Mac signed in to the same Apple Account
7+
// offers to continue in Safari, and an iPad with DebateOwl continues in the app.
8+
//
9+
// App to web needs no entitlement: the activity carries webpageURL. App to app
10+
// needs the activity type in Info.plist's NSUserActivityTypes. Web to app (Safari
11+
// on a Mac handing debateowl.com to the phone) would need the Associated Domains
12+
// entitlement `activitycontinuation:debateowl.com`; it is not set up.
13+
//
14+
// The activity is made current with becomeCurrent() and kept here, not attached
15+
// to a view controller's userActivity: UIKit empties a responder-managed
16+
// activity's userInfo before it asks the responder to refill it, which would
17+
// drop the route.
18+
19+
public class HandoffModule: Module {
20+
public func definition() -> ModuleDefinition {
21+
Name("DebateOwlHandoff")
22+
23+
// Another device handed a screen over; JavaScript takes it with takeContinuedPath.
24+
Events("onContinue")
25+
26+
OnCreate {
27+
HandoffCenter.shared.onContinue = { [weak self] in
28+
self?.sendEvent("onContinue", [:])
29+
}
30+
}
31+
32+
OnDestroy {
33+
HandoffCenter.shared.onContinue = nil
34+
}
35+
36+
Function("setActivity") { (activity: [String: Any]) in
37+
DispatchQueue.main.async { HandoffCenter.shared.set(activity) }
38+
}
39+
40+
Function("clearActivity") {
41+
DispatchQueue.main.async { HandoffCenter.shared.clear() }
42+
}
43+
44+
// The route (or, failing that, the web address) another device handed over,
45+
// once. Kept until asked, so a handover that launched the app is not lost
46+
// while JavaScript starts.
47+
Function("takeContinuedPath") { () -> String? in
48+
HandoffCenter.shared.takePending()
49+
}
50+
}
51+
}
52+
53+
/// Receives activities handed over from another device. Expo's linking
54+
/// subscriber only takes web-browsing activities, so ours would otherwise
55+
/// open the app on its first screen.
56+
public class HandoffAppDelegateSubscriber: ExpoAppDelegateSubscriber {
57+
public func application(
58+
_ application: UIApplication,
59+
continue userActivity: NSUserActivity,
60+
restorationHandler: @escaping ([any UIUserActivityRestoring]?) -> Void
61+
) -> Bool {
62+
return HandoffCenter.shared.receive(userActivity)
63+
}
64+
}
65+
66+
// Unchecked: the activity is touched on the main thread only, `pending` behind
67+
// the lock, and `onContinue` is set once when the module is created.
68+
final class HandoffCenter: @unchecked Sendable {
69+
static let shared = HandoffCenter()
70+
/// Also listed in Info.plist's NSUserActivityTypes (app.config.ts).
71+
static let activityType = "com.pistack.debateowl.browsing"
72+
private static let hosts: Set<String> = ["debateowl.com", "www.debateowl.com"]
73+
74+
var onContinue: (() -> Void)?
75+
76+
// Main thread only.
77+
private var current: NSUserActivity?
78+
private var activation: NSObjectProtocol?
79+
80+
// Written on the main thread, read from JavaScript's.
81+
private let lock = NSLock()
82+
private var pending: String?
83+
84+
/// Makes `entry` the current activity. Anything but our type with an https
85+
/// debateowl.com address clears it instead.
86+
func set(_ entry: [String: Any]) {
87+
guard entry["type"] as? String == Self.activityType,
88+
let address = entry["webpageURL"] as? String,
89+
let url = URL(string: address),
90+
url.scheme == "https",
91+
let host = url.host?.lowercased(),
92+
Self.hosts.contains(host)
93+
else {
94+
clear()
95+
return
96+
}
97+
let title = entry["title"] as? String
98+
let path = entry["appPath"] as? String
99+
100+
// The same screen again (a re-render, a language change): keep the activity.
101+
if let current, current.webpageURL == url, current.userInfo?["path"] as? String == path {
102+
current.title = title
103+
current.becomeCurrent()
104+
return
105+
}
106+
107+
let activity = NSUserActivity(activityType: Self.activityType)
108+
activity.title = title
109+
activity.webpageURL = url
110+
if let path {
111+
activity.addUserInfoEntries(from: ["path": path])
112+
}
113+
activity.isEligibleForHandoff = true
114+
// Spotlight and Siri suggestions belong to their own work.
115+
activity.isEligibleForSearch = false
116+
activity.isEligibleForPublicIndexing = false
117+
activity.isEligibleForPrediction = false
118+
119+
current?.invalidate()
120+
current = activity
121+
activity.becomeCurrent()
122+
observeActivation()
123+
}
124+
125+
func clear() {
126+
current?.invalidate()
127+
current = nil
128+
}
129+
130+
/// Advertises the activity again when the app returns to the foreground.
131+
private func observeActivation() {
132+
guard activation == nil else { return }
133+
activation = NotificationCenter.default.addObserver(
134+
forName: UIApplication.didBecomeActiveNotification,
135+
object: nil,
136+
queue: .main
137+
) { [weak self] _ in
138+
self?.current?.becomeCurrent()
139+
}
140+
}
141+
142+
/// Keeps a handed-over screen for JavaScript; false for activities that are not ours.
143+
func receive(_ activity: NSUserActivity) -> Bool {
144+
guard activity.activityType == Self.activityType else { return false }
145+
let value = (activity.userInfo?["path"] as? String) ?? activity.webpageURL?.absoluteString
146+
guard let value else { return true }
147+
lock.lock()
148+
pending = value
149+
lock.unlock()
150+
onContinue?()
151+
return true
152+
}
153+
154+
func takePending() -> String? {
155+
lock.lock()
156+
defer { lock.unlock() }
157+
let value = pending
158+
pending = nil
159+
return value
160+
}
161+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"platforms": ["apple"],
3+
"apple": {
4+
"modules": ["StoreKitModule"]
5+
}
6+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
Pod::Spec.new do |s|
2+
s.name = 'StoreKitBridge'
3+
s.version = '1.0.0'
4+
s.summary = "DebateOwl's in-app subscriptions"
5+
s.description = 'StoreKit 2 products, purchases, transactions and Apple subscription sheets for the DebateOwl app, called from JavaScript.'
6+
s.author = ''
7+
s.homepage = 'https://debateowl.com'
8+
s.platforms = { :ios => '16.4' }
9+
s.source = { git: '' }
10+
s.static_framework = true
11+
# Swift 5 mode, as expo-store-review, so StoreKit's non-Sendable types cross the async functions without strict checks.
12+
s.swift_version = '5.9'
13+
14+
s.dependency 'ExpoModulesCore'
15+
s.frameworks = 'StoreKit'
16+
17+
s.pod_target_xcconfig = {
18+
'DEFINES_MODULE' => 'YES',
19+
}
20+
21+
s.source_files = "**/*.{h,m,mm,swift}"
22+
end

0 commit comments

Comments
 (0)