Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 15 additions & 7 deletions apps/mobile/AGENTS.md

Large diffs are not rendered by default.

65 changes: 61 additions & 4 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import path from 'node:path';
import type { ConfigContext, ExpoConfig } from 'expo/config';
import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins';

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


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

const build = process.env.DEBATELAB_BUILD;
const appGroup = process.env.DEBATELAB_APP_GROUP;
// DEBATELAB_APPLE_ACCOUNT=1: the build is signed by DebateOwl's own Apple
// Developer team, whose App IDs carry the capabilities in docs/apple-platform.md
// (Sign in with Apple, Push, Time Sensitive Notifications, Associated Domains,
// In-App Purchase). Without it a development build keeps to what the borrowed
// signing profile allows.
const appleAccount = process.env.DEBATELAB_APPLE_ACCOUNT;
if (build !== undefined && build !== '' && build !== 'dev') {
throw new Error(`DEBATELAB_BUILD must be "dev" or unset, not "${build}".`);
}
if (appleAccount !== undefined && appleAccount !== '' && appleAccount !== '1') {
throw new Error(`DEBATELAB_APPLE_ACCOUNT must be "1" or unset, not "${appleAccount}".`);
}
if (appleAccount === '1' && build !== 'dev') {
throw new Error('DEBATELAB_APPLE_ACCOUNT only applies to a development build (DEBATELAB_BUILD=dev).');
}
if (appGroup && build !== 'dev') {
throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).');
}
Expand Down Expand Up @@ -54,11 +69,14 @@ function iosLocale(strings: PermissionStrings) {

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

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

// Round 6 (docs/apple-platform.md): Sign in with Apple and Google, the
// time-sensitive timer alert, Handoff and push, for development builds only,
// so the Expo Go config never changes. What needs DebateOwl's own Apple
// account waits for DEBATELAB_APPLE_ACCOUNT.
export function withApplePlatform(config: ExpoConfig, opts: { appleAccount: boolean }): ExpoConfig {
// Handoff (src/lib/handoff.ts), next to Expo Router's own activity type.
config.ios = {
...config.ios,
infoPlist: {
...config.ios?.infoPlist,
NSUserActivityTypes: [...((config.ios?.infoPlist?.NSUserActivityTypes as string[] | undefined) ?? []), 'com.pistack.debateowl.browsing'],
},
};
config.extra = {
...config.extra,
// Google sign-in runs in any development build: the redirect comes back on the app's own scheme.
googleSignIn: true,
signInWithApple: opts.appleAccount,
};
if (!opts.appleAccount) return config;
config.ios = { ...config.ios, usesAppleSignIn: true };
config.ios.associatedDomains = [
...(config.ios.associatedDomains ?? []),
// Handoff from debateowl.com in Safari to the app.
'activitycontinuation:debateowl.com',
];
config.ios.entitlements = {
...config.ios.entitlements,
// The timer's time-up alert breaks through a Focus (src/lib/timer-alerts.ts).
'com.apple.developer.usernotifications.time-sensitive': true,
};
return config;
}

export default ({ projectRoot }: ConfigContext): ExpoConfig => {
const config: ExpoConfig = {
name: 'DebateOwl',
Expand Down Expand Up @@ -278,7 +330,12 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => {
}
// The Current Debate widget (with the App Group), quick actions and Spotlight; see withRound4 above.
withRound4(config, { appGroup, projectRoot });
withApplePlatform(config, { appleAccount: appleAccount === '1' });
// Siri and Shortcuts: the intents compile into the app target (plugins/with-app-intents.js).
const withIntents = withAppIntents(config);
// DebateOwl's own account has Push and Sign in with Apple on its App ID, so its builds keep both.
if (appleAccount === '1') return withIntents;
// Registered before Expo applies the plugins list. Mods registered later act
// first, so this one runs after the plugins have written their entitlements.
return withoutPushEntitlement(config);
return withoutAccountEntitlements(withIntents);
};
7 changes: 7 additions & 0 deletions apps/mobile/modules/handoff/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["HandoffModule"],
"appDelegateSubscribers": ["HandoffAppDelegateSubscriber"]
}
}
19 changes: 19 additions & 0 deletions apps/mobile/modules/handoff/ios/Handoff.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Pod::Spec.new do |s|
s.name = 'Handoff'
s.version = '1.0.0'
s.summary = "DebateOwl's Handoff activity"
s.description = 'Advertises the screen open in DebateOwl (NSUserActivity) so a Mac continues on debateowl.com and an iPad in the app.'
s.author = ''
s.homepage = 'https://debateowl.com'
s.platforms = { :ios => '16.4' }
s.source = { git: '' }
s.static_framework = true

s.dependency 'ExpoModulesCore'

s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
}

s.source_files = "**/*.{h,m,mm,swift}"
end
161 changes: 161 additions & 0 deletions apps/mobile/modules/handoff/ios/HandoffModule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import ExpoModulesCore
import UIKit

// Handoff for src/lib/handoff.ts. JavaScript says which screen is open (a title,
// the debateowl.com page that matches it and the app route); this advertises it
// as the current NSUserActivity, so a Mac signed in to the same Apple Account
// offers to continue in Safari, and an iPad with DebateOwl continues in the app.
//
// App to web needs no entitlement: the activity carries webpageURL. App to app
// needs the activity type in Info.plist's NSUserActivityTypes. Web to app (Safari
// on a Mac handing debateowl.com to the phone) would need the Associated Domains
// entitlement `activitycontinuation:debateowl.com`; it is not set up.
//
// The activity is made current with becomeCurrent() and kept here, not attached
// to a view controller's userActivity: UIKit empties a responder-managed
// activity's userInfo before it asks the responder to refill it, which would
// drop the route.

public class HandoffModule: Module {
public func definition() -> ModuleDefinition {
Name("DebateOwlHandoff")

// Another device handed a screen over; JavaScript takes it with takeContinuedPath.
Events("onContinue")

OnCreate {
HandoffCenter.shared.onContinue = { [weak self] in
self?.sendEvent("onContinue", [:])
}
}

OnDestroy {
HandoffCenter.shared.onContinue = nil
}

Function("setActivity") { (activity: [String: Any]) in
DispatchQueue.main.async { HandoffCenter.shared.set(activity) }
}

Function("clearActivity") {
DispatchQueue.main.async { HandoffCenter.shared.clear() }
}

// The route (or, failing that, the web address) another device handed over,
// once. Kept until asked, so a handover that launched the app is not lost
// while JavaScript starts.
Function("takeContinuedPath") { () -> String? in
HandoffCenter.shared.takePending()
}
}
}

/// Receives activities handed over from another device. Expo's linking
/// subscriber only takes web-browsing activities, so ours would otherwise
/// open the app on its first screen.
public class HandoffAppDelegateSubscriber: ExpoAppDelegateSubscriber {
public func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([any UIUserActivityRestoring]?) -> Void
) -> Bool {
return HandoffCenter.shared.receive(userActivity)
}
}

// Unchecked: the activity is touched on the main thread only, `pending` behind
// the lock, and `onContinue` is set once when the module is created.
final class HandoffCenter: @unchecked Sendable {
static let shared = HandoffCenter()
/// Also listed in Info.plist's NSUserActivityTypes (app.config.ts).
static let activityType = "com.pistack.debateowl.browsing"
private static let hosts: Set<String> = ["debateowl.com", "www.debateowl.com"]

var onContinue: (() -> Void)?

// Main thread only.
private var current: NSUserActivity?
private var activation: NSObjectProtocol?

// Written on the main thread, read from JavaScript's.
private let lock = NSLock()
private var pending: String?

/// Makes `entry` the current activity. Anything but our type with an https
/// debateowl.com address clears it instead.
func set(_ entry: [String: Any]) {
guard entry["type"] as? String == Self.activityType,
let address = entry["webpageURL"] as? String,
let url = URL(string: address),
url.scheme == "https",
let host = url.host?.lowercased(),
Self.hosts.contains(host)
else {
clear()
return
}
let title = entry["title"] as? String
let path = entry["appPath"] as? String

// The same screen again (a re-render, a language change): keep the activity.
if let current, current.webpageURL == url, current.userInfo?["path"] as? String == path {
current.title = title
current.becomeCurrent()
return
}

let activity = NSUserActivity(activityType: Self.activityType)
activity.title = title
activity.webpageURL = url
if let path {
activity.addUserInfoEntries(from: ["path": path])
}
activity.isEligibleForHandoff = true
// Spotlight and Siri suggestions belong to their own work.
activity.isEligibleForSearch = false
activity.isEligibleForPublicIndexing = false
activity.isEligibleForPrediction = false

current?.invalidate()
current = activity
activity.becomeCurrent()
observeActivation()
}

func clear() {
current?.invalidate()
current = nil
}

/// Advertises the activity again when the app returns to the foreground.
private func observeActivation() {
guard activation == nil else { return }
activation = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.current?.becomeCurrent()
}
}

/// Keeps a handed-over screen for JavaScript; false for activities that are not ours.
func receive(_ activity: NSUserActivity) -> Bool {
guard activity.activityType == Self.activityType else { return false }
let value = (activity.userInfo?["path"] as? String) ?? activity.webpageURL?.absoluteString
guard let value else { return true }
lock.lock()
pending = value
lock.unlock()
onContinue?()
return true
}

func takePending() -> String? {
lock.lock()
defer { lock.unlock() }
let value = pending
pending = nil
return value
}
}
6 changes: 6 additions & 0 deletions apps/mobile/modules/store-kit/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["StoreKitModule"]
}
}
22 changes: 22 additions & 0 deletions apps/mobile/modules/store-kit/ios/StoreKitBridge.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Pod::Spec.new do |s|
s.name = 'StoreKitBridge'
s.version = '1.0.0'
s.summary = "DebateOwl's in-app subscriptions"
s.description = 'StoreKit 2 products, purchases, transactions and Apple subscription sheets for the DebateOwl app, called from JavaScript.'
s.author = ''
s.homepage = 'https://debateowl.com'
s.platforms = { :ios => '16.4' }
s.source = { git: '' }
s.static_framework = true
# Swift 5 mode, as expo-store-review, so StoreKit's non-Sendable types cross the async functions without strict checks.
s.swift_version = '5.9'

s.dependency 'ExpoModulesCore'
s.frameworks = 'StoreKit'

s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
}

s.source_files = "**/*.{h,m,mm,swift}"
end
Loading
Loading