mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3424bcf2e9 | ||
|
|
d13769a9f2 | ||
|
|
0c8b8179b2 |
@@ -441,7 +441,6 @@ jobs:
|
||||
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
||||
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
||||
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -451,11 +450,11 @@ jobs:
|
||||
${{ steps.artifact.outputs.mapping }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
|
||||
# uploads after the first bundle was uploaded by hand in the Console).
|
||||
# Every release goes out to the internal and closed (beta) testers right
|
||||
# away; closed testing may wait for Google's review first.
|
||||
- name: Upload to Google Play (internal and closed testing)
|
||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set. Play only accepts API
|
||||
# uploads after the first bundle was uploaded by hand in the Console, and
|
||||
# releases of an app that was never published must be drafts; switch
|
||||
# `status` to `completed` after the first production release.
|
||||
- name: Upload to Google Play (internal testing, draft)
|
||||
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
@@ -463,6 +462,5 @@ jobs:
|
||||
packageName: org.bulwarkmail.mobile
|
||||
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||
releaseName: ${{ steps.artifact.outputs.version }}
|
||||
tracks: internal,beta
|
||||
status: completed
|
||||
track: internal
|
||||
status: draft
|
||||
|
||||
@@ -59,6 +59,9 @@ jobs:
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
@@ -66,69 +69,14 @@ jobs:
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||
if [ -z "$NSE_PROFILE" ]; then
|
||||
# The notification service extension (plugins/ios-push) is its
|
||||
# own App ID and needs its own App Store profile.
|
||||
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
|
||||
exit 1
|
||||
fi
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Install the provisioning profiles
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profiles
|
||||
env:
|
||||
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
|
||||
# One App Store profile per target: the app and its notification
|
||||
# service extension (plugins/ios-push).
|
||||
for side in app nse; do
|
||||
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
|
||||
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
|
||||
echo "$DATA" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
|
||||
# ID from the profile keeps ExportOptions honest if app.config.js
|
||||
# changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
done
|
||||
|
||||
- name: Generate the iOS project
|
||||
env:
|
||||
# plugins/ios-push names each target's profile: the command line
|
||||
# can't, it would give every target the same one.
|
||||
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
|
||||
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
@@ -193,6 +141,38 @@ jobs:
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Install the provisioning profile
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
|
||||
- name: Archive
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
@@ -212,7 +192,8 @@ jobs:
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution"
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
||||
|
||||
# Signing failures report a symptom ("no profiles found") rather than the
|
||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||
@@ -234,10 +215,8 @@ jobs:
|
||||
id: export
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
|
||||
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
|
||||
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
|
||||
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
|
||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -251,7 +230,6 @@ jobs:
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
|
||||
@@ -43,20 +43,14 @@ import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||
// Pause between a mute change and the push-filter update it sends (#78).
|
||||
const MUTES_RESYNC_DELAY_MS = 1_000;
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
import ComposeScreen from './src/screens/ComposeScreen';
|
||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||
import LoginScreen from './src/screens/LoginScreen';
|
||||
import MailTab from './src/screens/MailTab';
|
||||
import FilesScreen from './src/screens/FilesScreen';
|
||||
import CalendarScreen from './src/screens/CalendarScreen';
|
||||
import ContactsTab from './src/screens/ContactsTab';
|
||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ScheduledScreen from './src/screens/ScheduledScreen';
|
||||
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
|
||||
// Screens that aren't on the way to the inbox load on first visit
|
||||
// (getComponent below), so their modules stay out of the startup work.
|
||||
import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
@@ -323,7 +317,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
getComponent={() => require('./src/screens/CalendarScreen').default}
|
||||
options={{
|
||||
title: tabLabels.calendar,
|
||||
tabBarButtonTestID: 'tab-calendar',
|
||||
@@ -339,7 +333,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsTab}
|
||||
getComponent={() => require('./src/screens/ContactsTab').default}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarButtonTestID: 'tab-contacts',
|
||||
@@ -355,7 +349,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Files"
|
||||
component={FilesScreen}
|
||||
getComponent={() => require('./src/screens/FilesScreen').default}
|
||||
options={{
|
||||
title: tabLabels.files,
|
||||
tabBarButtonTestID: 'tab-files',
|
||||
@@ -873,10 +867,10 @@ export default function App() {
|
||||
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
||||
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
|
||||
<Stack.Screen
|
||||
name="Compose"
|
||||
component={ComposeScreen}
|
||||
getComponent={() => require('./src/screens/ComposeScreen').default}
|
||||
// A tablet shows the composer as a panel over the screen it came
|
||||
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||
options={isSplit
|
||||
@@ -886,15 +880,15 @@ export default function App() {
|
||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||
<Stack.Screen
|
||||
name="ContactForm"
|
||||
component={ContactFormScreen}
|
||||
getComponent={() => require('./src/screens/ContactFormScreen').default}
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
||||
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
||||
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
|
||||
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
|
||||
<Stack.Screen
|
||||
name="AddAccount"
|
||||
options={{
|
||||
|
||||
@@ -22,13 +22,6 @@ COMMIT = COMMIT.slice(0, 7);
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
// Apple team: prefixes the keychain groups the app shares with its
|
||||
// notification service extension (plugins/ios-push). Not a secret.
|
||||
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39';
|
||||
// Which APNs host the device tokens belong to: TestFlight and App Store builds
|
||||
// use production, simulators and development builds the sandbox.
|
||||
const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
@@ -103,17 +96,12 @@ module.exports = {
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
|
||||
// to where its own source is (AGPL §13); the default is upstream.
|
||||
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
|
||||
// src/lib/ios-push-shared.ts
|
||||
iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`,
|
||||
iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`,
|
||||
apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -228,11 +228,9 @@ The `play-bundle` job builds `bundlePlayRelease` for arm64-v8a, armeabi-v7a and
|
||||
x86_64 (Play splits the bundle per ABI), checks the signer, the package name and
|
||||
the permissions, and keeps the AAB and its R8 mapping as a workflow artifact.
|
||||
With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service
|
||||
account bulwark-google-play-api") it also uploads the bundle and rolls it out
|
||||
on the internal and closed (beta) testing tracks, so testers get every release
|
||||
without a step in the Play Console; closed testing may wait for Google's
|
||||
review. Play only accepts API uploads once a first bundle has been uploaded by
|
||||
hand in the Play Console.
|
||||
account bulwark-google-play-api") it also uploads the bundle to the internal
|
||||
testing track as a draft. Play only accepts API uploads once a first bundle has
|
||||
been uploaded by hand in the Play Console.
|
||||
|
||||
The `github` flavor stays on the old Firebase project until the app registers
|
||||
its new FCM token with the relay when the token changes; moving it before that
|
||||
|
||||
@@ -639,12 +639,6 @@
|
||||
"up_to_date": "Already up to date"
|
||||
}
|
||||
},
|
||||
"push": {
|
||||
"ios": {
|
||||
"more_messages": "and {count} more",
|
||||
"no_subject": "(no subject)"
|
||||
}
|
||||
},
|
||||
"push_prompt": {
|
||||
"body": "Turn on background notifications so new messages reach you while the app is closed.",
|
||||
"dismiss": "Not now",
|
||||
@@ -1014,8 +1008,6 @@
|
||||
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
||||
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"err_android_only": "Push notifications are only available on Android.",
|
||||
"err_apns_empty": "iOS returned an empty push token.",
|
||||
"err_apns_token": "iOS could not issue a push token ({detail}).",
|
||||
"err_fcm_empty": "Firebase returned an empty device token.",
|
||||
"err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.",
|
||||
"err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.",
|
||||
@@ -1023,7 +1015,6 @@
|
||||
"err_no_account": "No account loaded - cannot set up push.",
|
||||
"err_permission": "Notification permission was not granted.",
|
||||
"err_relay_https": "The relay URL must use https://.",
|
||||
"err_relay_no_apns": "The push relay at {url} does not deliver to iPhones and iPads yet.",
|
||||
"err_relay_rejected": "The push relay rejected the registration: {detail}",
|
||||
"err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).",
|
||||
"err_up_android_only": "UnifiedPush is only available on Android.",
|
||||
|
||||
@@ -33,4 +33,14 @@ if (process.env.BULWARK_MODULE_TIMINGS) {
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate a module when it is first used instead of all of them before the
|
||||
// first render. Expo's default leaves this off; React Native's own template
|
||||
// turns it on. Side-effect-only imports (`import './x'`) still run in place.
|
||||
config.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: true,
|
||||
inlineRequires: true,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.83",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.83",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.83",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// The little JMAP the extension needs: who sent the new messages and what
|
||||
/// they are about, plus the inbox's unread count for the badge.
|
||||
struct PushedEmail {
|
||||
let id: String
|
||||
let threadId: String
|
||||
let sender: String
|
||||
let subject: String
|
||||
let preview: String
|
||||
}
|
||||
|
||||
struct JmapFetchResult {
|
||||
let emails: [PushedEmail]
|
||||
let inboxUnread: Int?
|
||||
}
|
||||
|
||||
enum JmapError: Error {
|
||||
case noCredentials
|
||||
case http(Int)
|
||||
case badResponse
|
||||
}
|
||||
|
||||
/// Credentials as the app stores them (src/api/jmap-client.ts StoredCredentials).
|
||||
struct StoredCredentials {
|
||||
var json: [String: Any]
|
||||
|
||||
var username: String { json["username"] as? String ?? "" }
|
||||
var password: String { json["password"] as? String ?? "" }
|
||||
var accessToken: String? { json["accessToken"] as? String }
|
||||
var refreshToken: String? { json["refreshToken"] as? String }
|
||||
var tokenEndpoint: String? { json["tokenEndpoint"] as? String }
|
||||
var clientId: String? { json["clientId"] as? String }
|
||||
/// Milliseconds since 1970, like Date.now() in the app.
|
||||
var expiresAt: Double? { (json["expiresAt"] as? NSNumber)?.doubleValue }
|
||||
}
|
||||
|
||||
enum Jmap {
|
||||
static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 12
|
||||
config.timeoutIntervalForResource = 20
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
/// The Authorization header for these credentials, refreshing an OAuth
|
||||
/// access token that is about to expire. A refreshed bundle is written back
|
||||
/// to the shared keychain item so the app picks it up (and doesn't refresh
|
||||
/// again with a refresh token the server has already replaced).
|
||||
static func authorization(credentialsKey: String) async throws -> String {
|
||||
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
|
||||
var creds = StoredCredentials(json: json)
|
||||
if let token = creds.accessToken {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
|
||||
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
||||
let url = URL(string: endpoint) {
|
||||
if let fresh = try? await refreshToken(url: url, refreshToken: refresh, clientId: clientId) {
|
||||
creds.json["accessToken"] = fresh.accessToken
|
||||
if let newRefresh = fresh.refreshToken { creds.json["refreshToken"] = newRefresh }
|
||||
if let expiresIn = fresh.expiresIn { creds.json["expiresAt"] = nowMs + expiresIn * 1000 }
|
||||
SharedKeychain.update(credentialsKey, json: creds.json)
|
||||
return "Bearer \(fresh.accessToken)"
|
||||
}
|
||||
}
|
||||
return "Bearer \(token)"
|
||||
}
|
||||
let basic = Data("\(creds.username):\(creds.password)".utf8).base64EncodedString()
|
||||
return "Basic \(basic)"
|
||||
}
|
||||
|
||||
struct FreshTokens {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Double?
|
||||
}
|
||||
|
||||
static func refreshToken(url: URL, refreshToken: String, clientId: String) async throws -> FreshTokens {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var form = URLComponents()
|
||||
form.queryItems = [
|
||||
URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
]
|
||||
request.httpBody = form.percentEncodedQuery?.data(using: .utf8)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let access = body["access_token"] as? String else { throw JmapError.badResponse }
|
||||
return FreshTokens(
|
||||
accessToken: access,
|
||||
refreshToken: body["refresh_token"] as? String,
|
||||
expiresIn: (body["expires_in"] as? NSNumber)?.doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
|
||||
var request = URLRequest(url: apiUrl)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(authorization, forHTTPHeaderField: "Authorization")
|
||||
let body: [String: Any] = [
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
"methodCalls": [
|
||||
["Email/get", [
|
||||
"accountId": accountId,
|
||||
"ids": Array(emailIds.prefix(5)),
|
||||
"properties": ["threadId", "from", "subject", "preview"],
|
||||
], "0"],
|
||||
["Mailbox/query", ["accountId": accountId, "filter": ["role": "inbox"]], "1"],
|
||||
["Mailbox/get", [
|
||||
"accountId": accountId,
|
||||
"#ids": ["resultOf": "1", "name": "Mailbox/query", "path": "/ids"],
|
||||
"properties": ["unreadEmails"],
|
||||
], "2"],
|
||||
],
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
|
||||
|
||||
var emails: [PushedEmail] = []
|
||||
var inboxUnread: Int?
|
||||
for response in responses where response.count >= 2 {
|
||||
guard let name = response[0] as? String, let args = response[1] as? [String: Any] else { continue }
|
||||
if name == "Email/get", let list = args["list"] as? [[String: Any]] {
|
||||
// Keep the order the push gave (newest first from the server's filter).
|
||||
// Stalwart can list an id twice; keep the first.
|
||||
let byId = Dictionary(list.compactMap { item -> (String, [String: Any])? in
|
||||
guard let id = item["id"] as? String else { return nil }
|
||||
return (id, item)
|
||||
}, uniquingKeysWith: { first, _ in first })
|
||||
for id in emailIds {
|
||||
guard let item = byId[id] else { continue }
|
||||
let from = (item["from"] as? [[String: Any]])?.first
|
||||
let name = (from?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let sender = name ?? (from?["email"] as? String) ?? ""
|
||||
emails.append(PushedEmail(
|
||||
id: id,
|
||||
threadId: item["threadId"] as? String ?? "",
|
||||
sender: sender,
|
||||
subject: item["subject"] as? String ?? "",
|
||||
preview: item["preview"] as? String ?? ""
|
||||
))
|
||||
}
|
||||
} else if name == "Mailbox/get", let list = args["list"] as? [[String: Any]] {
|
||||
inboxUnread = (list.first?["unreadEmails"] as? NSNumber)?.intValue
|
||||
}
|
||||
}
|
||||
return JmapFetchResult(emails: emails, inboxUnread: inboxUnread)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import Foundation
|
||||
import os
|
||||
import UserNotifications
|
||||
|
||||
/// Ids and steps only, never credentials or message content.
|
||||
let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push")
|
||||
|
||||
/// Turns the relay's content-blind push ("New message" plus ids) into a real
|
||||
/// notification: sender, subject and preview, fetched over JMAP on this
|
||||
/// device with the account's own credentials. Nothing about the message ever
|
||||
/// passes through the relay or Apple.
|
||||
///
|
||||
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
||||
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl}
|
||||
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
||||
/// <credentialsKey> the account's stored credentials
|
||||
/// If anything is missing or the server can't be reached, the relay's
|
||||
/// placeholder text is shown, so the user still learns there is new mail.
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var placeholder: UNMutableNotificationContent?
|
||||
private var work: Task<Void, Never>?
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
placeholder = content
|
||||
let push = request.content.userInfo["body"] as? [String: Any]
|
||||
work = Task { [weak self] in
|
||||
let enriched = await NotificationService.enrich(content, push: push)
|
||||
self?.finish(enriched)
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
work?.cancel()
|
||||
finish(placeholder)
|
||||
}
|
||||
|
||||
private func finish(_ content: UNNotificationContent?) {
|
||||
guard let handler = contentHandler, let content else { return }
|
||||
contentHandler = nil
|
||||
handler(content)
|
||||
}
|
||||
|
||||
static func sanitize(_ id: String) -> String {
|
||||
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
|
||||
}
|
||||
|
||||
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
|
||||
guard let push, let accountId = push["accountId"] as? String, !accountId.isEmpty else {
|
||||
pushLog.error("push without an account id")
|
||||
return content
|
||||
}
|
||||
pushLog.info("push for account \(accountId, privacy: .public), group \(SharedKeychain.group ?? "none", privacy: .public)")
|
||||
guard let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"),
|
||||
let credentialsKey = account["credentialsKey"] as? String,
|
||||
let apiString = account["apiUrl"] as? String,
|
||||
let apiUrl = URL(string: apiString) else {
|
||||
pushLog.error("no shared account entry for \(accountId, privacy: .public)")
|
||||
return content
|
||||
}
|
||||
let emailIds = push["emailIds"] as? [String] ?? []
|
||||
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
|
||||
let strings = settings["strings"] as? [String: String] ?? [:]
|
||||
|
||||
content.threadIdentifier = accountId
|
||||
// The app reads userInfo.body when the notification is tapped.
|
||||
var body = push
|
||||
|
||||
do {
|
||||
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey)
|
||||
let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization)
|
||||
pushLog.info("fetched \(result.emails.count, privacy: .public) of \(emailIds.count, privacy: .public) messages")
|
||||
|
||||
if let first = result.emails.first {
|
||||
content.title = first.sender
|
||||
content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject
|
||||
content.body = first.preview
|
||||
if result.emails.count > 1 {
|
||||
let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1))
|
||||
content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)"
|
||||
}
|
||||
body["emailId"] = first.id
|
||||
body["threadId"] = first.threadId
|
||||
}
|
||||
|
||||
// Badge: like the app, the active account's inbox unread count. A push
|
||||
// for another account leaves the badge as it is.
|
||||
if settings["badge"] as? Bool == true,
|
||||
settings["activeAccountId"] as? String == accountId,
|
||||
let unread = result.inboxUnread {
|
||||
content.badge = NSNumber(value: unread)
|
||||
}
|
||||
} catch {
|
||||
// Keep the placeholder.
|
||||
pushLog.error("fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
var userInfo = content.userInfo
|
||||
userInfo["body"] = body
|
||||
content.userInfo = userInfo
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Items the app shares with this extension through a keychain access group
|
||||
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
||||
/// expo-secure-store: generic passwords whose account and generic attributes
|
||||
/// are the UTF-8 key and whose service is "app:no-auth".
|
||||
enum SharedKeychain {
|
||||
static let service = "app:no-auth"
|
||||
|
||||
static var group: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "BulwarkKeychainGroup") as? String
|
||||
}
|
||||
|
||||
static func read(_ key: String) -> Data? {
|
||||
guard let group else { return nil }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess else {
|
||||
pushLog.error("keychain read of \(key, privacy: .public) failed: \(status, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
return item as? Data
|
||||
}
|
||||
|
||||
static func readJSON(_ key: String) -> [String: Any]? {
|
||||
guard let data = read(key) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
|
||||
/// Replaces the value of an existing item (refreshed OAuth tokens). Never
|
||||
/// creates one: the app owns which items exist.
|
||||
@discardableResult
|
||||
static func update(_ key: String, json: [String: Any]) -> Bool {
|
||||
guard let group, let data = try? JSONSerialization.data(withJSONObject: json) else { return false }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
]
|
||||
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||
return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// Config plugin: push notifications on iOS.
|
||||
//
|
||||
// - The app's entitlements: aps-environment, and a keychain group shared with
|
||||
// the notification service extension (the app's own group stays first, so
|
||||
// items saved before keep their group).
|
||||
// - A Notification Service Extension target ("NotificationService",
|
||||
// org.bulwarkmail.mobile.NotificationService). The relay's push carries
|
||||
// only ids; the extension fetches sender and subject over JMAP on the device
|
||||
// before iOS shows the notification. Its sources live next to this file and
|
||||
// are copied into ios/ at prebuild, like everything else in ios/.
|
||||
//
|
||||
// Options (app.config.js):
|
||||
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
||||
// teamId: Apple team id, for the shared keychain group
|
||||
// extensionProfile / appProfile: provisioning profile specifiers for the
|
||||
// Release configuration of the extension and the app (IOS_NSE_PROFILE and
|
||||
// IOS_APP_PROFILE env win). Set per target because xcodebuild's command
|
||||
// line applies a specifier to every target. Empty = leave unset.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withXcodeProject,
|
||||
withDangerousMod,
|
||||
createRunOncePlugin,
|
||||
} = require('@expo/config-plugins');
|
||||
|
||||
const TARGET = 'NotificationService';
|
||||
const DEPLOYMENT_TARGET = '15.1';
|
||||
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
|
||||
|
||||
function sharedGroupSuffix() {
|
||||
return 'org.bulwarkmail.shared';
|
||||
}
|
||||
|
||||
function withAppEntitlements(config, { apsEnvironment }) {
|
||||
return withEntitlementsPlist(config, (cfg) => {
|
||||
const bundleId = cfg.ios?.bundleIdentifier;
|
||||
cfg.modResults['aps-environment'] = apsEnvironment;
|
||||
const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []);
|
||||
groups.add(`$(AppIdentifierPrefix)${bundleId}`);
|
||||
groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`);
|
||||
cfg.modResults['keychain-access-groups'] = [...groups];
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
function infoPlist({ version, buildNumber }) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Bulwark Mail notifications</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${version}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${buildNumber}</string>
|
||||
<key>BulwarkKeychainGroup</key>
|
||||
<string>$(DEVELOPMENT_TEAM).${sharedGroupSuffix()}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function entitlementsPlist() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)${sharedGroupSuffix()}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function withExtensionFiles(config) {
|
||||
return withDangerousMod(config, [
|
||||
'ios',
|
||||
async (cfg) => {
|
||||
const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const file of SOURCE_FILES) {
|
||||
fs.copyFileSync(path.join(__dirname, file), path.join(dir, file));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${TARGET}-Info.plist`),
|
||||
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
|
||||
return cfg;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
||||
if (!appProfile && !extensionProfile) return;
|
||||
const project = cfg.modResults;
|
||||
const appName = `"${cfg.modRequest.projectName}"`;
|
||||
const configs = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configs)) {
|
||||
const entry = configs[key];
|
||||
if (!entry.buildSettings || entry.name !== 'Release') continue;
|
||||
const name = entry.buildSettings.PRODUCT_NAME;
|
||||
const profile = name === `"${TARGET}"` ? extensionProfile
|
||||
: (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile
|
||||
: '';
|
||||
if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`;
|
||||
}
|
||||
}
|
||||
|
||||
// One mod for creating the target and naming the profiles: separate
|
||||
// withXcodeProject mods don't run in the order they are registered.
|
||||
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
addExtensionTarget(cfg, { teamId });
|
||||
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
function addExtensionTarget(cfg, { teamId }) {
|
||||
const project = cfg.modResults;
|
||||
if (project.pbxTargetByName(TARGET)) return;
|
||||
|
||||
// addTarget expects these sections; a fresh Expo project may not have them.
|
||||
const objects = project.hash.project.objects;
|
||||
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
|
||||
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
|
||||
|
||||
const group = project.addPbxGroup(
|
||||
[...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
|
||||
TARGET,
|
||||
TARGET,
|
||||
);
|
||||
// Hang the group off the project's main group.
|
||||
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
|
||||
project.addToPbxGroup(group.uuid, mainGroupId);
|
||||
|
||||
const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`;
|
||||
const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
|
||||
project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
||||
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
||||
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
|
||||
|
||||
const configs = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configs)) {
|
||||
const entry = configs[key];
|
||||
if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue;
|
||||
const s = entry.buildSettings;
|
||||
s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`;
|
||||
s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET;
|
||||
s.TARGETED_DEVICE_FAMILY = '"1,2"';
|
||||
s.SWIFT_VERSION = '5.0';
|
||||
s.CLANG_ENABLE_MODULES = 'YES';
|
||||
s.GENERATE_INFOPLIST_FILE = 'NO';
|
||||
if (teamId) s.DEVELOPMENT_TEAM = teamId;
|
||||
}
|
||||
}
|
||||
|
||||
function withIosPush(config, options = {}) {
|
||||
const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production';
|
||||
const teamId = process.env.APPLE_TEAM_ID || options.teamId || '';
|
||||
const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || '';
|
||||
const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || '';
|
||||
config = withAppEntitlements(config, { apsEnvironment });
|
||||
config = withExtensionFiles(config);
|
||||
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
|
||||
@@ -418,40 +418,6 @@ describe('JMAPClient hardening', () => {
|
||||
expect(client.authHeader).toBe('Bearer new');
|
||||
});
|
||||
|
||||
// On iOS the notification extension refreshes OAuth tokens too and writes
|
||||
// the new bundle back. Refreshing again with the refresh token in memory
|
||||
// would spend a token the server has already replaced.
|
||||
it('retries a 401 with a newer stored token instead of refreshing again', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = new JMAPClient();
|
||||
mockFetch([{ status: 200, json: SESSION }]);
|
||||
await client.connectWithOAuth('https://mail.example.com', {
|
||||
accessToken: 'old',
|
||||
refreshToken: 'r1',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
});
|
||||
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
password: '',
|
||||
accessToken: 'from-extension',
|
||||
refreshToken: 'r2',
|
||||
expiresAt: Date.now() + 7_200_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
}));
|
||||
const fetch = mockFetch([
|
||||
{ status: 401 },
|
||||
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
|
||||
]);
|
||||
await client.request([['Mailbox/get', {}, '0']]);
|
||||
expect(mockRefresh).not.toHaveBeenCalled();
|
||||
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer from-extension' });
|
||||
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
it('updatePassword rewrites the stored credential', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = await connected();
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const secure = new Map<string, string>();
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }),
|
||||
getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null),
|
||||
deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }),
|
||||
}));
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { JMAPClient, AuthenticationError } from '../jmap-client';
|
||||
import type { JMAPSession } from '../types';
|
||||
|
||||
// Start-up used to wait for the server's session document before the
|
||||
// account could show. The client now keeps the last one per account and
|
||||
// starts from it (loadAccountFromCache), then fetches a fresh one.
|
||||
|
||||
const ACCOUNT = 'user@mail.example.com';
|
||||
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' };
|
||||
|
||||
const SESSION: JMAPSession = {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/eventsource/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1', 'urn:ietf:params:jmap:core': 'acc-1' },
|
||||
accounts: { 'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false } },
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
|
||||
state: 'state-1',
|
||||
};
|
||||
|
||||
function mockFetch(responses: Array<{ status: number; json?: unknown }>) {
|
||||
let i = 0;
|
||||
const fn = vi.fn(async (url: string) => {
|
||||
const r = responses[i++] ?? responses[responses.length - 1];
|
||||
return {
|
||||
ok: r.status >= 200 && r.status < 300,
|
||||
status: r.status,
|
||||
statusText: r.status === 200 ? 'OK' : 'Error',
|
||||
url,
|
||||
redirected: false,
|
||||
headers: { get: () => null },
|
||||
json: async () => r.json,
|
||||
text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
async function storeCredentials() {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
await SecureStore.setItemAsync(`jmap_credentials__${ACCOUNT.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(CREDS));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
secure.clear();
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('saved session', () => {
|
||||
it('starts from the session of the last connect, without a request', async () => {
|
||||
await storeCredentials();
|
||||
mockFetch([{ status: 200, json: SESSION }]);
|
||||
expect(await new JMAPClient().loadAccount(ACCOUNT)).toBe(true);
|
||||
await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1));
|
||||
|
||||
const fetch = mockFetch([{ status: 500 }]);
|
||||
const client = new JMAPClient();
|
||||
expect(await client.loadAccountFromCache(ACCOUNT)).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(client.accountId).toBe('acc-1');
|
||||
expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl);
|
||||
});
|
||||
|
||||
it('has nothing to start from before the first connect', async () => {
|
||||
await storeCredentials();
|
||||
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a session saved for another server', async () => {
|
||||
await storeCredentials();
|
||||
await AsyncStorage.setItem(
|
||||
`jmap_session_cache__${ACCOUNT}`,
|
||||
JSON.stringify({ serverUrl: 'https://old.example.com', session: SESSION }),
|
||||
);
|
||||
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
|
||||
});
|
||||
|
||||
it('is dropped with the credentials', async () => {
|
||||
await storeCredentials();
|
||||
await AsyncStorage.setItem(
|
||||
`jmap_session_cache__${ACCOUNT}`,
|
||||
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
|
||||
);
|
||||
await new JMAPClient().clearAccountCredentials(ACCOUNT);
|
||||
expect(await AsyncStorage.getItem(`jmap_session_cache__${ACCOUNT}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('revalidates against the server and reports refused credentials', async () => {
|
||||
await storeCredentials();
|
||||
await AsyncStorage.setItem(
|
||||
`jmap_session_cache__${ACCOUNT}`,
|
||||
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
|
||||
);
|
||||
const client = new JMAPClient();
|
||||
await client.loadAccountFromCache(ACCOUNT);
|
||||
mockFetch([{ status: 401 }]);
|
||||
await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it('drops a revalidation that finishes after a switch to another account', async () => {
|
||||
await storeCredentials();
|
||||
await AsyncStorage.setItem(
|
||||
`jmap_session_cache__${ACCOUNT}`,
|
||||
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
|
||||
);
|
||||
const client = new JMAPClient();
|
||||
await client.loadAccountFromCache(ACCOUNT);
|
||||
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]);
|
||||
const pending = client.revalidateSession();
|
||||
client.reset();
|
||||
expect(await pending).toBeNull();
|
||||
expect(client.currentSession).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
|
||||
}));
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { JMAPClient, AuthenticationError, InvalidTokenError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
||||
import { JMAPClient, AuthenticationError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
||||
import type { JMAPSession } from '../types';
|
||||
|
||||
const MOCK_SESSION: JMAPSession = {
|
||||
@@ -113,10 +113,8 @@ describe('JMAPClient', () => {
|
||||
});
|
||||
|
||||
describe('connectWithToken', () => {
|
||||
const TOKEN_SESSION = { ...MOCK_SESSION, username: 'user@example.com' };
|
||||
|
||||
it('should use Bearer auth', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
|
||||
await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
@@ -130,70 +128,6 @@ describe('JMAPClient', () => {
|
||||
);
|
||||
expect(client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('names the account after the session and remembers the token', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
|
||||
const { username, accountId } = await client.connectWithToken('https://mail.example.com/', 'my-token');
|
||||
|
||||
expect(username).toBe('user@example.com');
|
||||
expect(accountId).toBe('user@example.com@mail.example.com');
|
||||
const [key, stored] = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)!;
|
||||
expect(key).toContain('user_example.com');
|
||||
expect(JSON.parse(stored)).toMatchObject({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
accessToken: 'my-token',
|
||||
});
|
||||
expect(client.usesPastedToken).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a token the server rejects', async () => {
|
||||
global.fetch = mockFetch([{ status: 401 }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'bad')).rejects.toThrow(InvalidTokenError);
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a session that does not name the account', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'my-token')).rejects.toThrow('did not name the account');
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a download host the server runs on another HTTPS origin', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'https://api.fastmail.com/jmap/api/',
|
||||
downloadUrl: 'https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
uploadUrl: 'https://api.fastmail.com/jmap/upload/{accountId}/',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://api.fastmail.com', 'my-token');
|
||||
|
||||
expect(session.downloadUrl).toBe('https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
expect(session.uploadUrl).toBe('https://api.fastmail.com/jmap/upload/{accountId}/');
|
||||
});
|
||||
|
||||
it('still moves self-reported internal hosts onto the server address', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'http://localhost:8080/jmap/',
|
||||
downloadUrl: 'http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
|
||||
expect(session.downloadUrl).toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request', () => {
|
||||
|
||||
+110
-104
@@ -1,4 +1,5 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type {
|
||||
JMAPSession,
|
||||
JMAPMethodCall,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
type OAuthTokenSource,
|
||||
} from '../lib/oauth';
|
||||
import { FirstTouchGate } from './first-touch-gate';
|
||||
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
|
||||
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
|
||||
|
||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||
@@ -41,10 +41,24 @@ const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
|
||||
export function credentialsKey(accountId: string): string {
|
||||
function credentialsKey(accountId: string): string {
|
||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
// The session document from the account's last connect (URLs, account ids,
|
||||
// capabilities; no secrets). Start-up uses it to show the account without
|
||||
// waiting for the network, then fetches a fresh one (revalidateSession).
|
||||
const SESSION_CACHE_PREFIX = 'jmap_session_cache__';
|
||||
|
||||
function sessionCacheKey(accountId: string): string {
|
||||
return SESSION_CACHE_PREFIX + accountId;
|
||||
}
|
||||
|
||||
interface CachedSession {
|
||||
serverUrl: string;
|
||||
session: JMAPSession;
|
||||
}
|
||||
|
||||
export interface StoredCredentials {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
@@ -177,12 +191,6 @@ export class JMAPClient {
|
||||
return !!this.credentials?.accessToken;
|
||||
}
|
||||
|
||||
// A pasted access token, as opposed to an OAuth bundle (which always names
|
||||
// the token endpoint it renews at).
|
||||
get usesPastedToken(): boolean {
|
||||
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.session !== null && this._accountId !== null;
|
||||
}
|
||||
@@ -267,7 +275,10 @@ export class JMAPClient {
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
|
||||
return this.session;
|
||||
}
|
||||
@@ -300,42 +311,15 @@ export class JMAPClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-token login (an API token the user pasted, e.g. from Fastmail).
|
||||
* The token is the whole credential: it names the account, so the username
|
||||
* comes from the session, and with no refresh token it lasts until the
|
||||
* provider expires or revokes it.
|
||||
*/
|
||||
async connectWithToken(
|
||||
serverUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
||||
|
||||
try {
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
} catch (err) {
|
||||
this.credentials = null;
|
||||
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
const username = this.session.username;
|
||||
if (!username) {
|
||||
this.reset();
|
||||
throw new Error('The server did not name the account');
|
||||
}
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
return this.session;
|
||||
}
|
||||
|
||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||
@@ -373,7 +357,10 @@ export class JMAPClient {
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
}
|
||||
@@ -395,36 +382,15 @@ export class JMAPClient {
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
for (const l of this.tokenRefreshListeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the stored OAuth tokens when they are newer than the ones in memory:
|
||||
* on iOS the notification extension refreshes them too and writes the new
|
||||
* bundle back. True when the in-memory tokens changed.
|
||||
*/
|
||||
private async adoptStoredTokens(): Promise<boolean> {
|
||||
const current = this.credentials;
|
||||
if (!current?.accessToken || !current.refreshToken) return false;
|
||||
const accountId = generateAccountId(current.username, current.serverUrl);
|
||||
const stored = await this.getStoredCredentials(accountId).catch(() => null);
|
||||
if (!stored?.accessToken || stored.accessToken === current.accessToken) return false;
|
||||
if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false;
|
||||
this.credentials = {
|
||||
...current,
|
||||
accessToken: stored.accessToken,
|
||||
refreshToken: stored.refreshToken ?? current.refreshToken,
|
||||
expiresAt: stored.expiresAt,
|
||||
};
|
||||
for (const l of this.tokenRefreshListeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private currentOAuthTokens(): OAuthTokens | null {
|
||||
if (
|
||||
!this.credentials?.accessToken ||
|
||||
@@ -463,17 +429,10 @@ export class JMAPClient {
|
||||
// consumers of `authHeader` (SSE stream, file downloads) can make sure the
|
||||
// header they capture is not about to expire.
|
||||
async ensureFreshToken(): Promise<void> {
|
||||
let tokens = this.currentOAuthTokens();
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return;
|
||||
if (tokens.expiresAt == null) return;
|
||||
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
|
||||
// Another process may have refreshed already (the iOS notification
|
||||
// extension); use its tokens rather than spend a refresh token the
|
||||
// server has replaced.
|
||||
if (await this.adoptStoredTokens()) {
|
||||
tokens = this.currentOAuthTokens();
|
||||
if (!tokens || (tokens.expiresAt != null && tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return;
|
||||
}
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
@@ -489,10 +448,6 @@ export class JMAPClient {
|
||||
// the account instead of evicting it (webmail 1.7.6 "keep the session when
|
||||
// the auth server is briefly unreachable").
|
||||
async forceRefreshToken(): Promise<boolean> {
|
||||
if (!this.currentOAuthTokens()) return false;
|
||||
// A newer token in storage (refreshed by the iOS notification extension)
|
||||
// is worth a retry before refreshing again.
|
||||
if (await this.adoptStoredTokens()) return true;
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return false;
|
||||
try {
|
||||
@@ -519,7 +474,10 @@ export class JMAPClient {
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
@@ -547,9 +505,6 @@ export class JMAPClient {
|
||||
async loadAccount(accountId: string): Promise<boolean> {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return false;
|
||||
// iOS: credentials saved before the notification extension existed move
|
||||
// to the keychain group it can read (a no-op elsewhere and afterwards).
|
||||
void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined);
|
||||
|
||||
let creds: StoredCredentials;
|
||||
try {
|
||||
@@ -584,9 +539,74 @@ export class JMAPClient {
|
||||
);
|
||||
}
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.rememberSession(accountId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start-up without a network round trip: the account's credentials and the
|
||||
* session document saved at its last connect. Returns false when either is
|
||||
* missing (or the saved one is for another server); the caller then uses
|
||||
* loadAccount. A wrong password or revoked token surfaces on the first
|
||||
* request (401, onAuthFailure) or in revalidateSession.
|
||||
*/
|
||||
async loadAccountFromCache(accountId: string): Promise<boolean> {
|
||||
const [stored, cachedRaw] = await Promise.all([
|
||||
SecureStore.getItemAsync(credentialsKey(accountId)),
|
||||
AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null),
|
||||
]);
|
||||
if (!stored || !cachedRaw) return false;
|
||||
let creds: StoredCredentials;
|
||||
let cached: CachedSession;
|
||||
try {
|
||||
creds = JSON.parse(stored);
|
||||
cached = JSON.parse(cachedRaw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!cached?.session?.apiUrl || cached.serverUrl !== creds.serverUrl) return false;
|
||||
let accountIdInSession: string;
|
||||
try {
|
||||
accountIdInSession = this.resolveAccountId(cached.session);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
this.credentials = creds;
|
||||
this.firstTouchGate.reset();
|
||||
this.session = cached.session;
|
||||
this._accountId = accountIdInSession;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a fresh session document for the account the client holds now,
|
||||
* after a start from the saved one. Resolves null when the client moved to
|
||||
* another account meanwhile (the result is dropped). Throws
|
||||
* AuthenticationError / NetworkError like loadAccount.
|
||||
*/
|
||||
async revalidateSession(): Promise<JMAPSession | null> {
|
||||
const creds = this.credentials;
|
||||
if (!creds) return null;
|
||||
let fresh: JMAPSession;
|
||||
try {
|
||||
fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl);
|
||||
} catch (err) {
|
||||
if (this.credentials !== creds) return null;
|
||||
throw err;
|
||||
}
|
||||
if (this.credentials !== creds) return null;
|
||||
this.session = fresh;
|
||||
this._accountId = this.resolveAccountId(fresh);
|
||||
this.rememberSession(generateAccountId(creds.username, creds.serverUrl));
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private rememberSession(accountId: string): void {
|
||||
if (!this.session || !this.credentials) return;
|
||||
const entry: CachedSession = { serverUrl: this.credentials.serverUrl, session: this.session };
|
||||
void AsyncStorage.setItem(sessionCacheKey(accountId), JSON.stringify(entry)).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the live connection so a failed "add account" / switch can put
|
||||
* the previous account back without a network round-trip.
|
||||
@@ -608,12 +628,14 @@ export class JMAPClient {
|
||||
|
||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
||||
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
|
||||
}
|
||||
|
||||
async clearAllCredentials(accountIds: string[]): Promise<void> {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
|
||||
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
|
||||
...accountIds.map((id) => AsyncStorage.removeItem(sessionCacheKey(id)).catch(() => undefined)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -626,7 +648,7 @@ export class JMAPClient {
|
||||
const creds: StoredCredentials = JSON.parse(stored);
|
||||
// Re-save under the per-account key before dropping the legacy entry
|
||||
const accountId = generateAccountId(creds.username, creds.serverUrl);
|
||||
await writeSharedSecret(credentialsKey(accountId), stored);
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), stored);
|
||||
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||
return creds;
|
||||
} catch {
|
||||
@@ -645,26 +667,16 @@ export class JMAPClient {
|
||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||
// before the caller has a chance to substitute values into them.
|
||||
//
|
||||
// A URL on another HTTPS origin than the server's own apiUrl is one it
|
||||
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
|
||||
// domain) and is kept; everything else moves onto the origin we connected to.
|
||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||
const serverOrigin = extractOrigin(serverUrl);
|
||||
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
|
||||
const rewrite = (url: string | undefined): string | undefined =>
|
||||
rewriteSessionUrl(url, serverOrigin);
|
||||
const rewriteHosted = (url: string | undefined): string | undefined => {
|
||||
const origin = url ? extractOrigin(url) : null;
|
||||
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
|
||||
return hostedElsewhere ? url : rewrite(url);
|
||||
};
|
||||
return {
|
||||
...session,
|
||||
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
||||
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -686,6 +698,7 @@ export class JMAPClient {
|
||||
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||
if (currentAccountId) {
|
||||
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
|
||||
await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,6 +918,7 @@ export class JMAPClient {
|
||||
const baseUrl = this.credentials.serverUrl;
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
this.rememberSession(generateAccountId(this.credentials.username, baseUrl));
|
||||
return this.session;
|
||||
}
|
||||
|
||||
@@ -1229,7 +1243,7 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
|
||||
}
|
||||
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
@@ -1326,14 +1340,6 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The server refused a pasted access token (wrong, expired or revoked).
|
||||
export class InvalidTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid access token');
|
||||
this.name = 'InvalidTokenError';
|
||||
}
|
||||
}
|
||||
|
||||
// The server wants a second factor before it will hand out a session.
|
||||
export class TotpRequiredError extends AuthenticationError {
|
||||
constructor() {
|
||||
|
||||
@@ -1,57 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { appBadgeCount, setAppBadge } from '../lib/app-badge';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { writeIosPushSettings } from '../lib/ios-push-shared';
|
||||
import { shareAccountWithExtension } from '../lib/push-notifications';
|
||||
|
||||
/**
|
||||
* Keeps the app icon badge on the active account's inbox unread count (see
|
||||
* `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last
|
||||
* account signed out, so the badge is cleared then. Renders nothing.
|
||||
*
|
||||
* On iOS it also tells the notification extension (plugins/ios-push) which
|
||||
* account the badge follows and the few texts it writes itself, so a push
|
||||
* that arrives while the app is closed updates the badge the same way.
|
||||
*/
|
||||
export function AppIconBadge() {
|
||||
const enabled = useSettingsStore((s) => s.appIconUnreadBadge);
|
||||
const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled));
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
// The JMAP session comes up after the account is known (offline start).
|
||||
const haveSession = useAuthStore((s) => s.session != null);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
useEffect(() => {
|
||||
void setAppBadge(count);
|
||||
}, [count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'ios') return;
|
||||
let jmapAccountId: string | null = null;
|
||||
try {
|
||||
jmapAccountId = jmapClient.accountId;
|
||||
} catch {
|
||||
jmapAccountId = null;
|
||||
}
|
||||
// The extension learns how to reach the active account as soon as it is
|
||||
// signed in, so a push for it shows sender and subject even before (or
|
||||
// without) this device's own push setup, e.g. after a reinstall.
|
||||
if (activeAccountId) void shareAccountWithExtension(activeAccountId, jmapAccountId);
|
||||
void writeIosPushSettings({
|
||||
badge: enabled,
|
||||
activeAccountId: jmapAccountId,
|
||||
strings: {
|
||||
noSubject: t('push.ios.no_subject', '(no subject)'),
|
||||
more: t('push.ios.more_messages', 'and {count} more'),
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
}, [enabled, activeAccountId, haveSession, t]);
|
||||
|
||||
useEffect(() => () => { void setAppBadge(0); }, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -4,12 +4,11 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
/**
|
||||
* A Modal with its own safe-area provider. A Modal is a separate native
|
||||
* window, and a SafeAreaView inside a Modal whose content slides in (a
|
||||
* slide-up Modal, or a drawer or sheet the app animates itself) measured no
|
||||
* insets: the view starts off screen. On iPhones with a notch or Dynamic
|
||||
* Island headers (and their close buttons) ended up under the status bar,
|
||||
* where taps don't reach them, and bottom rows on the home indicator. Use
|
||||
* this for any Modal with a SafeAreaView inside.
|
||||
* window, and a SafeAreaView inside a slide-up Modal measured no insets: the
|
||||
* view starts off screen when the slide begins, so on iPhones with a notch or
|
||||
* Dynamic Island the header (and its close button) ended up under the status
|
||||
* bar, where taps don't reach it. Use this for full-screen modals with a
|
||||
* SafeAreaView header.
|
||||
*/
|
||||
export function SafeAreaModal({ children, ...props }: ModalProps) {
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,6 @@ import type { RootStackParamList } from '../navigation/types';
|
||||
import type { Mailbox } from '../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { confirmSignOut } from '../lib/sign-out-confirm';
|
||||
import { SafeAreaModal } from './SafeAreaModal';
|
||||
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
|
||||
import {
|
||||
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
|
||||
@@ -1241,11 +1240,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
);
|
||||
}
|
||||
|
||||
// Its own safe-area provider: the panel's SafeAreaView starts off screen
|
||||
// (it slides in), and without one it measured no insets on iOS, so the
|
||||
// account header sat under the status bar.
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -1272,7 +1268,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
onClose={() => setPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// A SafeAreaView inside a react-native Modal whose content starts off screen
|
||||
// measured no insets on iOS: the Modal is its own window, and the view was
|
||||
// measured before it slid in. On iPhones with a notch or Dynamic Island the
|
||||
// header and its close button sat under the status bar, out of reach
|
||||
// (attachment preview, QR scanner, paste sign-in link, filter and event
|
||||
// editors), the drawers' account headers did too, and the bottom sheets'
|
||||
// last rows sat on the home indicator. Such modals use SafeAreaModal, which
|
||||
// gives the window its own SafeAreaProvider. Slid in means animationType
|
||||
// "slide", or "none" with the app's own slide animation (drawers, sheets).
|
||||
// A SafeAreaView inside a slide-up react-native Modal measured no insets on
|
||||
// iOS: the Modal is its own window and its content starts off screen, so on
|
||||
// iPhones with a notch or Dynamic Island the header and its close button sat
|
||||
// under the status bar, out of reach (attachment preview, QR scanner, paste
|
||||
// sign-in link, filter and event editors). Such modals use SafeAreaModal,
|
||||
// which gives the window its own SafeAreaProvider.
|
||||
|
||||
const SRC = path.resolve(__dirname, '../..');
|
||||
|
||||
@@ -22,15 +19,6 @@ function sourceFiles(dir: string): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Plain <Modal> elements: the opening tag and what is between the tags. */
|
||||
function modals(source: string): { tag: string; body: string }[] {
|
||||
return modalTags(source).map((tag) => {
|
||||
const start = source.indexOf(tag) + tag.length;
|
||||
const end = source.indexOf('</Modal>', start);
|
||||
return { tag, body: source.slice(start, end < 0 ? undefined : end) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Opening tags of plain <Modal> elements, attributes included. */
|
||||
function modalTags(source: string): string[] {
|
||||
const tags: string[] = [];
|
||||
@@ -52,32 +40,18 @@ function modalTags(source: string): string[] {
|
||||
return tags;
|
||||
}
|
||||
|
||||
describe('modals that slide in with a SafeAreaView', () => {
|
||||
it('use SafeAreaModal so their content clears the status bar and home indicator', () => {
|
||||
describe('slide-up modals with a SafeAreaView', () => {
|
||||
it('use SafeAreaModal so their header clears the status bar', () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
if (!source.includes('SafeAreaView')) return false;
|
||||
return modals(source).some(({ tag, body }) =>
|
||||
/animationType="slide"/.test(tag)
|
||||
|| (/animationType="none"/.test(tag) && body.includes('<SafeAreaView')));
|
||||
return modalTags(source).some((tag) => /animationType="slide"/.test(tag));
|
||||
})
|
||||
.map((file) => path.relative(SRC, file));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// The mail drawer builds its panel (with the SafeAreaView) outside the
|
||||
// modal's tags, so the check above can't see it there.
|
||||
it('include the side drawers', () => {
|
||||
const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file));
|
||||
expect(drawers.length).toBeGreaterThanOrEqual(3);
|
||||
for (const file of drawers) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
expect(modalTags(source).filter((tag) => /animationType="none"/.test(tag)), path.relative(SRC, file)).toEqual([]);
|
||||
expect(source).toContain('<SafeAreaModal');
|
||||
}
|
||||
});
|
||||
|
||||
it('finds the tags it is looking for', () => {
|
||||
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
|
||||
expect(tags).toHaveLength(1);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -23,7 +24,6 @@ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
/** A calendar view listed at the top of the drawer. */
|
||||
export interface DrawerViewOption {
|
||||
@@ -157,7 +157,7 @@ export function CalendarSidebarDrawer({
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -271,7 +271,7 @@ export function CalendarSidebarDrawer({
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -62,7 +63,6 @@ import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrenc
|
||||
import type { TranslateFn } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
@@ -248,7 +248,7 @@ export function EventDetailSheet({
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -464,7 +464,7 @@ export function EventDetailSheet({
|
||||
{/* The screen's own toast host sits under this Modal; this one shows
|
||||
the copy confirmation above the sheet. */}
|
||||
<ToastHost />
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
|
||||
@@ -19,7 +19,6 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||
@@ -189,7 +188,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -379,7 +378,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, TextInput, ActivityIndicator, Alert,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
// The webmail's refresh intervals and their labels.
|
||||
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
||||
@@ -136,7 +135,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -314,7 +313,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
<View style={{ height: insets.bottom }} />
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -44,7 +45,6 @@ import { formatCalendarDate } from '../../lib/date-format';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||
|
||||
@@ -235,7 +235,7 @@ export function TasksSheet({
|
||||
];
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -480,7 +480,7 @@ export function TasksSheet({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
@@ -15,7 +15,6 @@ import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen upload flow).
|
||||
@@ -165,7 +164,7 @@ export default function ContactImportSheet({
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -305,7 +304,7 @@ export default function ContactImportSheet({
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, TextInput, FlatList,
|
||||
View, Text, StyleSheet, Pressable, Modal, TextInput, FlatList,
|
||||
Animated, Dimensions, Easing,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -18,7 +18,6 @@ import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -99,7 +98,7 @@ export default function ContactPickerSheet({
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -180,7 +179,7 @@ export default function ContactPickerSheet({
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Animated, Dimensions, Easing, TextInput, Alert,
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -19,7 +19,6 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
@@ -163,7 +162,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -311,7 +310,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,7 @@ export function AccountSettings() {
|
||||
const username = account?.username || authUsername || undefined;
|
||||
const serverUrl = account?.serverUrl || authServerUrl || '';
|
||||
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
|
||||
const authMode: 'oauth' | 'token' | 'basic' = jmapClient.usesPastedToken
|
||||
? 'token'
|
||||
: jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
||||
const authMode: 'oauth' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
||||
|
||||
const [quota, setQuota] = useState<MailQuota | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -130,9 +128,7 @@ export function AccountSettings() {
|
||||
<Text style={styles.value}>
|
||||
{authMode === 'oauth'
|
||||
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
|
||||
: authMode === 'token'
|
||||
? t('settings.account.auth_method_token', 'Access token')
|
||||
: t('settings.account.auth_method_basic', 'Password')}
|
||||
: t('settings.account.auth_method_basic', 'Password')}
|
||||
</Text>
|
||||
</SettingItem>
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { iosTapPayload } from '../push-notifications';
|
||||
|
||||
// A tapped iOS mail notification: the relay's ids arrive as the
|
||||
// notification's data (APNs `body`), the notification extension adds the
|
||||
// message it showed, and the app opens that message under the account whose
|
||||
// push setup recorded the JMAP account id.
|
||||
|
||||
describe('iosTapPayload', () => {
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c' }));
|
||||
});
|
||||
|
||||
it('opens the message the extension showed, in the right account', async () => {
|
||||
const payload = await iosTapPayload({
|
||||
kind: 'jmap-email-push',
|
||||
accountId: 'c',
|
||||
emailIds: ['e1', 'e2'],
|
||||
emailId: 'e2',
|
||||
threadId: 't2',
|
||||
});
|
||||
expect(payload).toEqual({ emailId: 'e2', threadId: 't2', accountId: 'ada@mail.example.org', jmapAccountId: 'c' });
|
||||
});
|
||||
|
||||
it('falls back to the first pushed id when the extension could not fetch', async () => {
|
||||
const payload = await iosTapPayload({ kind: 'jmap-email-push', accountId: 'c', emailIds: ['e1'] });
|
||||
expect(payload?.emailId).toBe('e1');
|
||||
expect(payload?.threadId).toBe('');
|
||||
});
|
||||
|
||||
it('leaves calendar reminders and other local notifications alone', async () => {
|
||||
expect(await iosTapPayload({ eventId: 'ev1', calendarId: 'cal' })).toBeNull();
|
||||
expect(await iosTapPayload(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing for a push without a message id', async () => {
|
||||
expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,14 +16,6 @@ describe('describeLoginError', () => {
|
||||
expect(copy.detail).toMatch(/app password/i);
|
||||
});
|
||||
|
||||
it('blames a refused access token on the token, not on a password', () => {
|
||||
for (const err of [named('InvalidTokenError', 'Invalid access token'), 'Invalid access token']) {
|
||||
const copy = describeLoginError(err);
|
||||
expect(copy.detail).toMatch(/access token/);
|
||||
expect(copy.detail).not.toMatch(/password/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('explains the auth store\'s "Session expired" notice', () => {
|
||||
expect(describeLoginError('Session expired').title).toBe('Your session has expired. Please sign in again.');
|
||||
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
// What the app shares with the iOS notification service extension
|
||||
// (plugins/ios-push): account credentials, where to reach each pushed account,
|
||||
// and the badge counts. They live in a keychain access group both targets
|
||||
// belong to; nothing else of the app is visible to the extension.
|
||||
//
|
||||
// Readable after the first unlock: a push usually arrives while the phone is
|
||||
// locked, and a notification that has to wait for the user to unlock would
|
||||
// show only "New message".
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
type Extra = { iosSharedKeychainGroup?: unknown; iosAppKeychainGroup?: unknown; apnsEnvironment?: unknown };
|
||||
|
||||
// Loaded on first use, on iOS only: the module pulls in native code that
|
||||
// Android's headless tasks and the unit tests can do without.
|
||||
function extra(): Extra {
|
||||
const { default: Constants } = require('expo-constants') as typeof import('expo-constants');
|
||||
return (Constants.expoConfig?.extra ?? {}) as Extra;
|
||||
}
|
||||
|
||||
/** The APNs host this build's device tokens belong to. */
|
||||
export function apnsEnvironment(): 'production' | 'sandbox' {
|
||||
return extra().apnsEnvironment === 'sandbox' ? 'sandbox' : 'production';
|
||||
}
|
||||
|
||||
/** The access group shared with the extension, or null off iOS. */
|
||||
export function sharedKeychainGroup(): string | null {
|
||||
if (Platform.OS !== 'ios') return null;
|
||||
const group = extra().iosSharedKeychainGroup;
|
||||
return typeof group === 'string' && group ? group : null;
|
||||
}
|
||||
|
||||
/** Options for an item the extension must read. Undefined off iOS. */
|
||||
export function sharedStoreOptions(): SecureStore.SecureStoreOptions | undefined {
|
||||
const group = sharedKeychainGroup();
|
||||
if (!group) return undefined;
|
||||
return { accessGroup: group, keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK };
|
||||
}
|
||||
|
||||
/** Options naming the app's own access group, where items used to live. */
|
||||
export function appStoreOptions(): SecureStore.SecureStoreOptions | undefined {
|
||||
if (Platform.OS !== 'ios') return undefined;
|
||||
const group = extra().iosAppKeychainGroup;
|
||||
return typeof group === 'string' && group ? { accessGroup: group } : undefined;
|
||||
}
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only. The extension
|
||||
// builds the same key from the push's account id (NotificationService.swift).
|
||||
function keyPart(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
export function pushAccountKey(jmapAccountId: string): string {
|
||||
return `bulwark_push_account__${keyPart(jmapAccountId)}`;
|
||||
}
|
||||
|
||||
export const PUSH_SETTINGS_KEY = 'bulwark_push_settings';
|
||||
|
||||
export interface IosPushAccount {
|
||||
/** SecureStore key of the account's credentials. */
|
||||
credentialsKey: string;
|
||||
/** The JMAP API URL as the app uses it (rewritten to the server's host). */
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.setItemAsync(pushAccountKey(jmapAccountId), JSON.stringify(account), options);
|
||||
}
|
||||
|
||||
export async function removeIosPushAccount(jmapAccountId: string): Promise<void> {
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.deleteItemAsync(pushAccountKey(jmapAccountId), options).catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface IosPushSettings {
|
||||
/** Show the unread count on the app icon. */
|
||||
badge: boolean;
|
||||
/** JMAP id of the active account: the badge is its inbox unread count. */
|
||||
activeAccountId: string | null;
|
||||
/** The few texts the extension writes itself, in the app's language. */
|
||||
strings: { noSubject: string; more: string };
|
||||
}
|
||||
|
||||
export async function writeIosPushSettings(settings: IosPushSettings): Promise<void> {
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.setItemAsync(PUSH_SETTINGS_KEY, JSON.stringify(settings), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credentials item where the extension can read it. Written to the
|
||||
* shared group first, then removed from the app's own group, so a crash in
|
||||
* between leaves two copies rather than none. Off iOS a plain write.
|
||||
*/
|
||||
export async function writeSharedSecret(key: string, value: string): Promise<void> {
|
||||
const shared = sharedStoreOptions();
|
||||
if (!shared) {
|
||||
await SecureStore.setItemAsync(key, value);
|
||||
return;
|
||||
}
|
||||
await SecureStore.setItemAsync(key, value, shared);
|
||||
const own = appStoreOptions();
|
||||
if (own) await SecureStore.deleteItemAsync(key, own).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Move an item saved before the shared group existed. True if it moved. */
|
||||
export async function moveToSharedGroup(key: string): Promise<boolean> {
|
||||
const own = appStoreOptions();
|
||||
if (!own || !sharedStoreOptions()) return false;
|
||||
const value = await SecureStore.getItemAsync(key, own).catch(() => null);
|
||||
if (value === null) return false;
|
||||
await writeSharedSecret(key, value);
|
||||
return true;
|
||||
}
|
||||
@@ -151,13 +151,6 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'InvalidTokenError' || lower.includes('invalid access token')) {
|
||||
return {
|
||||
title: t('login.mobile.err_bad_title', "That didn't work"),
|
||||
detail: t('login.error.invalid_token', 'The server did not accept this access token. Check that it is complete and has not expired or been revoked.'),
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
|
||||
return {
|
||||
title: t('login.mobile.err_bad_title', "That didn't work"),
|
||||
|
||||
@@ -29,9 +29,6 @@ import {
|
||||
persistedAccounts,
|
||||
} from '../device-sync/app/prefs';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../device-sync/types';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { credentialsKey } from '../api/jmap-client';
|
||||
import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared';
|
||||
import {
|
||||
getUnifiedPushDistributors,
|
||||
isUnifiedPushSupported,
|
||||
@@ -527,12 +524,12 @@ function getNative(): BulwarkFcmNative | null {
|
||||
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
|
||||
}
|
||||
|
||||
/** True on platforms that have a push transport wired up: FCM or UnifiedPush on Android, APNs on iOS. */
|
||||
/** True on platforms that have a push transport wired up (Android only). */
|
||||
export function isPushSupported(): boolean {
|
||||
return Platform.OS === 'ios' || getNative() !== null || isUnifiedPushSupported();
|
||||
return getNative() !== null || isUnifiedPushSupported();
|
||||
}
|
||||
|
||||
export type PushTransport = 'fcm' | 'unifiedpush' | 'apns';
|
||||
export type PushTransport = 'fcm' | 'unifiedpush';
|
||||
|
||||
export async function getStoredPushTransport(): Promise<PushTransport | null> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
|
||||
@@ -548,8 +545,6 @@ export async function setStoredPushTransport(transport: PushTransport | null): P
|
||||
}
|
||||
|
||||
export async function getEffectivePushTransport(): Promise<PushTransport> {
|
||||
// iOS has one way in: APNs, through the relay.
|
||||
if (Platform.OS === 'ios') return 'apns';
|
||||
return (await getStoredPushTransport()) ?? 'fcm';
|
||||
}
|
||||
|
||||
@@ -642,14 +637,6 @@ export async function dismissPushPrompt(accountId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<boolean> {
|
||||
if (Platform.OS === 'ios') {
|
||||
const current = await Notifications.getPermissionsAsync();
|
||||
if (current.granted) return true;
|
||||
const asked = await Notifications.requestPermissionsAsync({
|
||||
ios: { allowAlert: true, allowBadge: true, allowSound: true },
|
||||
});
|
||||
return asked.granted;
|
||||
}
|
||||
if (Platform.OS !== 'android') return true;
|
||||
if (Platform.Version < 33) return true;
|
||||
const status = await PermissionsAndroid.request(
|
||||
@@ -701,26 +688,6 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
// The APNs device token (hex). iOS hands it out once notifications are
|
||||
// allowed; expo-notifications asks the OS for it.
|
||||
async function getApnsTokenOrThrow(): Promise<string> {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
const result = await Notifications.getDevicePushTokenAsync();
|
||||
token = typeof result.data === 'string' ? result.data : null;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'token',
|
||||
t('settings.notifications.push.err_apns_token', 'iOS could not issue a push token ({detail}).', { detail }),
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
throw new PushSetupError('token', t('settings.notifications.push.err_apns_empty', 'iOS returned an empty push token.'));
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's VAPID public key, when it has one. Passed to the UnifiedPush
|
||||
* distributor at registration so distributors that pin the application server
|
||||
@@ -811,64 +778,6 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerWithRelayApns(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
deviceToken: string;
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/apns'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscriptionId: params.subscriptionId,
|
||||
deviceToken: params.deviceToken,
|
||||
environment: apnsEnvironment(),
|
||||
accountLabel: params.accountLabel,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'relay',
|
||||
t('settings.notifications.push.err_relay_unreachable', 'Could not reach the push relay at {url} ({detail}).', {
|
||||
url: params.relayBaseUrl,
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (res.status === 503) {
|
||||
throw new PushSetupError(
|
||||
'relay',
|
||||
t('settings.notifications.push.err_relay_no_apns', 'The push relay at {url} does not deliver to iPhones and iPads yet.', {
|
||||
url: params.relayBaseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new PushSetupError(
|
||||
'relay',
|
||||
t('settings.notifications.push.err_relay_rejected', 'The push relay rejected the registration: {detail}', {
|
||||
detail: await readRelayError(res),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the iOS notification extension how to reach an account: which stored
|
||||
* credentials to use and the JMAP API URL. Without it the extension shows the
|
||||
* relay's placeholder text.
|
||||
*/
|
||||
export async function shareAccountWithExtension(accountId: string, jmapAccountId: string | null): Promise<void> {
|
||||
if (Platform.OS !== 'ios' || !jmapAccountId) return;
|
||||
const apiUrl = jmapClient.currentSession?.apiUrl;
|
||||
if (!apiUrl) return;
|
||||
await writeIosPushAccount(jmapAccountId, { credentialsKey: credentialsKey(accountId), apiUrl }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function registerWithRelayUnifiedPush(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
@@ -1039,9 +948,6 @@ async function setupPushNotificationsInner(
|
||||
if (transport === 'fcm' && !native) {
|
||||
throw new PushSetupError('platform', t('settings.notifications.push.err_android_only', 'Push notifications are only available on Android.'));
|
||||
}
|
||||
if (transport === 'apns' && Platform.OS !== 'ios') {
|
||||
throw new PushSetupError('platform', 'APNs is only available on iOS.');
|
||||
}
|
||||
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
|
||||
throw new PushSetupError('platform', t('settings.notifications.push.err_up_android_only', 'UnifiedPush is only available on Android.'));
|
||||
}
|
||||
@@ -1061,11 +967,8 @@ async function setupPushNotificationsInner(
|
||||
logPhase('token', transport);
|
||||
let fcmToken: string | null = null;
|
||||
let upEndpoint: UnifiedPushEndpoint | null = null;
|
||||
let apnsToken: string | null = null;
|
||||
if (transport === 'unifiedpush') {
|
||||
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
|
||||
} else if (transport === 'apns') {
|
||||
apnsToken = await getApnsTokenOrThrow();
|
||||
} else {
|
||||
fcmToken = await getFcmTokenOrThrow(native!);
|
||||
}
|
||||
@@ -1090,14 +993,7 @@ async function setupPushNotificationsInner(
|
||||
// individually and tags it with the JMAP account id so the headless task
|
||||
// can route it.
|
||||
logPhase('relay', relayBaseUrl);
|
||||
if (apnsToken) {
|
||||
await registerWithRelayApns({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
deviceToken: apnsToken,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
} else if (upEndpoint) {
|
||||
if (upEndpoint) {
|
||||
await registerWithRelayUnifiedPush({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
@@ -1147,7 +1043,6 @@ async function setupPushNotificationsInner(
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||
await addPushAccountId(accountId);
|
||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||
await shareAccountWithExtension(accountId, jmapAccountId);
|
||||
await AsyncStorage.removeItem(optedOutKey(accountId));
|
||||
logPhase('done', 'reused existing subscription');
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
@@ -1230,7 +1125,6 @@ async function setupPushNotificationsInner(
|
||||
}
|
||||
await addPushAccountId(accountId);
|
||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||
await shareAccountWithExtension(accountId, jmapAccountId);
|
||||
await AsyncStorage.removeItem(optedOutKey(accountId));
|
||||
logPhase('done', 'subscription verified');
|
||||
|
||||
@@ -1302,10 +1196,6 @@ async function deregisterFromRelay(
|
||||
}
|
||||
|
||||
async function clearAccountPushKeys(accountId: string): Promise<void> {
|
||||
if (Platform.OS === 'ios') {
|
||||
const jmapAccountId = (await readPushJmapAccountIds())[accountId];
|
||||
if (jmapAccountId) await removeIosPushAccount(jmapAccountId);
|
||||
}
|
||||
await AsyncStorage.multiRemove([
|
||||
subscriptionIdKey(accountId),
|
||||
deviceClientIdKey(accountId),
|
||||
@@ -1619,17 +1509,6 @@ export function notificationTapJmapAccountId(payload: NotificationTapPayload): s
|
||||
// before JS was ready to handle it. Subsequent taps while running are delivered
|
||||
// via addNotificationTapListener.
|
||||
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
|
||||
if (Platform.OS === 'ios') {
|
||||
const response = await Notifications.getLastNotificationResponseAsync().catch(() => null);
|
||||
if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return null;
|
||||
const handledKey = 'push:lastHandledResponse:v1';
|
||||
const id = response.notification.request.identifier;
|
||||
// The last response survives app restarts; open it once.
|
||||
if ((await AsyncStorage.getItem(handledKey)) === id) return null;
|
||||
const payload = await iosTapPayload(response.notification.request.content.data);
|
||||
if (payload) await AsyncStorage.setItem(handledKey, id);
|
||||
return payload;
|
||||
}
|
||||
if (Platform.OS !== 'android') return null;
|
||||
const native = (NativeModules as Record<string, unknown>).BulwarkFcm as
|
||||
| { getInitialNotification?: () => Promise<NotificationTapPayload | null> }
|
||||
@@ -1645,47 +1524,8 @@ export async function getInitialNotificationTap(): Promise<NotificationTapPayloa
|
||||
export function addNotificationTapListener(
|
||||
listener: (payload: NotificationTapPayload) => void,
|
||||
): () => void {
|
||||
if (Platform.OS === 'ios') {
|
||||
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
|
||||
const id = response.notification.request.identifier;
|
||||
void iosTapPayload(response.notification.request.content.data).then((payload) => {
|
||||
if (!payload) return;
|
||||
void AsyncStorage.setItem('push:lastHandledResponse:v1', id);
|
||||
listener(payload);
|
||||
});
|
||||
});
|
||||
return () => sub.remove();
|
||||
}
|
||||
if (Platform.OS !== 'android') return () => undefined;
|
||||
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
|
||||
const sub = emitter.addListener('fcm:notificationTap', listener);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* A tapped iOS mail notification as the app opens it. The relay puts the
|
||||
* JMAP account and message ids into the payload's `body`, which
|
||||
* expo-notifications hands over as `data`; the notification extension adds
|
||||
* the message (and thread) it showed. Calendar reminders and other local
|
||||
* notifications carry no `kind` and are left to their own handlers.
|
||||
*/
|
||||
export async function iosTapPayload(data: unknown): Promise<NotificationTapPayload | null> {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
if (d.kind !== 'jmap-email-push' && d.kind !== 'jmap-state-change') return null;
|
||||
const jmapAccountId = typeof d.accountId === 'string' ? d.accountId : '';
|
||||
const ids = Array.isArray(d.emailIds) ? d.emailIds.filter((v): v is string => typeof v === 'string') : [];
|
||||
const emailId = typeof d.emailId === 'string' && d.emailId ? d.emailId : ids[0];
|
||||
if (!emailId) return null;
|
||||
// The local account the subscription belongs to: the one whose push
|
||||
// setup recorded this JMAP account id.
|
||||
const byLocal = await readPushJmapAccountIds();
|
||||
const accountId = Object.keys(byLocal).find((local) => byLocal[local] === jmapAccountId);
|
||||
return {
|
||||
emailId,
|
||||
threadId: typeof d.threadId === 'string' ? d.threadId : '',
|
||||
accountId,
|
||||
jmapAccountId: jmapAccountId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Image, Share,
|
||||
Animated, Dimensions, Easing, } from 'react-native';
|
||||
Animated, Dimensions, Easing, Modal,
|
||||
} from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -34,7 +35,6 @@ import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
import { SafeAreaModal } from '../components/SafeAreaModal';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
||||
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
||||
@@ -814,7 +814,7 @@ function MoreActionsSheet({
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -846,7 +846,7 @@ function MoreActionsSheet({
|
||||
})}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</SafeAreaModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ function signInLinkConfirmation(
|
||||
*/
|
||||
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginWithToken = useAuthStore((state) => state.loginWithToken);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
@@ -103,10 +102,6 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
// common no-2FA sign-in stays a two-field form.
|
||||
const [totp, setTotp] = React.useState('');
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
// Access-token (Bearer) sign-in in place of username and password, for
|
||||
// providers that hand out API tokens (Fastmail accepts nothing else).
|
||||
const [tokenMode, setTokenMode] = React.useState(false);
|
||||
const [accessToken, setAccessToken] = React.useState('');
|
||||
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
||||
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
@@ -266,7 +261,6 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
setPassword(pending.password);
|
||||
setServerUrl(pending.serverUrl);
|
||||
setTotpRequired(true);
|
||||
setTokenMode(false);
|
||||
setBusy(null);
|
||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||
setHistory((prev) => [...prev, step]);
|
||||
@@ -346,26 +340,6 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
setNotice({ title: t('login.mobile.notice_need_server', 'We need a server address first') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (tokenMode) {
|
||||
if (!accessToken.trim()) {
|
||||
setNotice({ title: t('login.token_placeholder', 'Paste your API token') });
|
||||
return;
|
||||
}
|
||||
setNotice(null);
|
||||
setBusy('connecting');
|
||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
try {
|
||||
await loginWithToken(target, accessToken, { addAccount: isAddMode });
|
||||
finishIfSignedIn(wasAuthenticated);
|
||||
} catch (err) {
|
||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.trim() || !password) {
|
||||
setNotice({ title: t('login.mobile.notice_need_credentials', 'Enter your email and password') });
|
||||
return;
|
||||
@@ -395,7 +369,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [accessToken, email, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]);
|
||||
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired, t]);
|
||||
|
||||
// A sign-in code, however it arrived: scanned, pasted, or a tapped
|
||||
// `bulwarkmail://` link.
|
||||
@@ -543,7 +517,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
<SigningInStep
|
||||
phase={busy}
|
||||
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
|
||||
email={pairing || tokenMode ? null : email.trim() || null}
|
||||
email={pairing ? null : email.trim() || null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -681,17 +655,6 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
}}
|
||||
onSubmit={() => void handlePasswordSubmit()}
|
||||
notice={notice}
|
||||
tokenMode={tokenMode}
|
||||
token={accessToken}
|
||||
onChangeToken={(value) => {
|
||||
setNotice(null);
|
||||
setAccessToken(value);
|
||||
}}
|
||||
onToggleTokenMode={() => {
|
||||
setNotice(null);
|
||||
clearError();
|
||||
setTokenMode((v) => !v);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</LoginShell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, type TextInput } from 'react-native';
|
||||
import { Eye, EyeOff, KeyRound } from '../../components/icons';
|
||||
import { Eye, EyeOff } from '../../components/icons';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button, Input } from '../../components';
|
||||
@@ -19,11 +19,6 @@ interface PasswordStepProps {
|
||||
onChangeTotp?: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
notice?: { title: string; detail?: string } | null;
|
||||
/** Access-token sign-in in place of username and password. */
|
||||
tokenMode?: boolean;
|
||||
token?: string;
|
||||
onChangeToken?: (value: string) => void;
|
||||
onToggleTokenMode?: () => void;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
@@ -43,10 +38,6 @@ export default function PasswordStep({
|
||||
onChangeTotp,
|
||||
onSubmit,
|
||||
notice,
|
||||
tokenMode = false,
|
||||
token = '',
|
||||
onChangeToken,
|
||||
onToggleTokenMode,
|
||||
}: PasswordStepProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -61,107 +52,74 @@ export default function PasswordStep({
|
||||
<View style={styles.heading}>
|
||||
<Text style={styles.title}>{t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{tokenMode
|
||||
? t('login.token_hint', "Create an API token in your mail provider's settings and paste it here.")
|
||||
: t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
|
||||
{t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{tokenMode ? (
|
||||
<Input
|
||||
label={t('login.mobile.username_label', 'Email or username')}
|
||||
placeholder={t('login.username_placeholder', 'user@example.com')}
|
||||
value={email}
|
||||
onChangeText={onChangeEmail}
|
||||
testID="login-username-input"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
keyboardType="email-address"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('login.password_label', 'Password')}
|
||||
placeholder={t('login.password_placeholder', 'Enter your password')}
|
||||
value={password}
|
||||
onChangeText={onChangePassword}
|
||||
testID="login-password-input"
|
||||
autoFocus={Boolean(email)}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="current-password"
|
||||
textContentType="password"
|
||||
ref={passwordRef}
|
||||
returnKeyType={totpRequired ? 'next' : 'go'}
|
||||
blurOnSubmit={!totpRequired}
|
||||
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
|
||||
rightIcon={
|
||||
<Pressable
|
||||
onPress={() => setShowPassword((v) => !v)}
|
||||
hitSlop={14}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
{totpRequired ? (
|
||||
<Input
|
||||
label={t('login.token_label', 'Access token')}
|
||||
placeholder={t('login.token_placeholder', 'Paste your API token')}
|
||||
value={token}
|
||||
onChangeText={(value) => onChangeToken?.(value)}
|
||||
testID="login-token-input"
|
||||
label={t('login.totp_label', 'Two-factor code')}
|
||||
placeholder="123456"
|
||||
value={totp}
|
||||
ref={totpRef}
|
||||
onChangeText={(value) => onChangeTotp?.(value)}
|
||||
autoFocus
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
keyboardType="number-pad"
|
||||
autoComplete="one-time-code"
|
||||
textContentType="oneTimeCode"
|
||||
maxLength={8}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tokenMode ? null : (
|
||||
<>
|
||||
<Input
|
||||
label={t('login.mobile.username_label', 'Email or username')}
|
||||
placeholder={t('login.username_placeholder', 'user@example.com')}
|
||||
value={email}
|
||||
onChangeText={onChangeEmail}
|
||||
testID="login-username-input"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
keyboardType="email-address"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('login.password_label', 'Password')}
|
||||
placeholder={t('login.password_placeholder', 'Enter your password')}
|
||||
value={password}
|
||||
onChangeText={onChangePassword}
|
||||
testID="login-password-input"
|
||||
autoFocus={Boolean(email)}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="current-password"
|
||||
textContentType="password"
|
||||
ref={passwordRef}
|
||||
returnKeyType={totpRequired ? 'next' : 'go'}
|
||||
blurOnSubmit={!totpRequired}
|
||||
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
|
||||
rightIcon={
|
||||
<Pressable
|
||||
onPress={() => setShowPassword((v) => !v)}
|
||||
hitSlop={14}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
{totpRequired ? (
|
||||
<Input
|
||||
label={t('login.totp_label', 'Two-factor code')}
|
||||
placeholder="123456"
|
||||
value={totp}
|
||||
ref={totpRef}
|
||||
onChangeText={(value) => onChangeTotp?.(value)}
|
||||
autoFocus
|
||||
keyboardType="number-pad"
|
||||
autoComplete="one-time-code"
|
||||
textContentType="oneTimeCode"
|
||||
maxLength={8}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||
|
||||
<Button variant="default" size="md" onPress={onSubmit}>
|
||||
{t('login.sign_in', 'Sign in')}
|
||||
</Button>
|
||||
|
||||
{onToggleTokenMode ? (
|
||||
<Pressable onPress={onToggleTokenMode} hitSlop={8} style={styles.toggle} accessibilityRole="button" testID="login-token-toggle">
|
||||
<KeyRound size={16} color={c.textLink} />
|
||||
<Text style={styles.link}>
|
||||
{tokenMode ? t('login.password_toggle', 'Sign in with a password') : t('login.token_toggle', 'Sign in with an access token')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -172,7 +130,5 @@ function makeStyles(c: ThemePalette) {
|
||||
heading: { gap: spacing.sm },
|
||||
title: { ...typography.h1, color: c.text },
|
||||
subtitle: { ...typography.body, color: c.textSecondary },
|
||||
toggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, paddingVertical: spacing.xs },
|
||||
link: { ...typography.body, color: c.textLink },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Access-token sign-in: the pasted token is cleaned up before it is sent, the
|
||||
// account is named after the session, and a refused token leaves nothing
|
||||
// registered.
|
||||
|
||||
const { InvalidTokenError, SESSION, connectWithToken } = vi.hoisted(() => {
|
||||
class InvalidTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid access token');
|
||||
this.name = 'InvalidTokenError';
|
||||
}
|
||||
}
|
||||
const SESSION = { apiUrl: 'https://api.fastmail.com/jmap/api/', username: 'ada@fastmail.com' };
|
||||
const connectWithToken = vi.fn(async (_serverUrl: string, _token: string) => ({
|
||||
session: SESSION,
|
||||
username: 'ada@fastmail.com',
|
||||
accountId: 'ada@fastmail.com@api.fastmail.com',
|
||||
}));
|
||||
return { InvalidTokenError, SESSION, connectWithToken };
|
||||
});
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
connectWithToken: (serverUrl: string, token: string) => connectWithToken(serverUrl, token),
|
||||
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
|
||||
restoreSnapshot: vi.fn(),
|
||||
onAuthFailure: vi.fn(() => () => undefined),
|
||||
onTokenRefresh: vi.fn(() => () => undefined),
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
getAccountName: () => undefined,
|
||||
getSharedMailAccounts: () => [],
|
||||
request: vi.fn(async () => ({ methodResponses: [] })),
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
currentSession: SESSION,
|
||||
username: 'ada@fastmail.com',
|
||||
serverUrl: 'https://api.fastmail.com',
|
||||
},
|
||||
AuthenticationError: class AuthenticationError extends Error {},
|
||||
NetworkError: class NetworkError extends Error {},
|
||||
InvalidTokenError,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotifications: vi.fn(async () => undefined),
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useAccountStore } from '../account-store';
|
||||
|
||||
beforeEach(() => {
|
||||
connectWithToken.mockClear();
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null });
|
||||
useAuthStore.setState({ isAuthenticated: false, error: null, activeAccountId: null });
|
||||
});
|
||||
|
||||
describe('loginWithToken', () => {
|
||||
it('sends the bare token and registers the account the session names', async () => {
|
||||
await useAuthStore.getState().loginWithToken('https://api.fastmail.com/', ' Bearer fmu1-abc ');
|
||||
|
||||
expect(connectWithToken).toHaveBeenCalledWith('https://api.fastmail.com', 'fmu1-abc');
|
||||
const account = useAccountStore.getState().getActiveAccount();
|
||||
expect(account?.id).toBe('ada@fastmail.com@api.fastmail.com');
|
||||
expect(account?.serverUrl).toBe('https://api.fastmail.com');
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
isAuthenticated: true,
|
||||
username: 'ada@fastmail.com',
|
||||
activeAccountId: 'ada@fastmail.com@api.fastmail.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a token with spaces inside without asking the server', async () => {
|
||||
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1 abc'))
|
||||
.rejects.toThrow(InvalidTokenError);
|
||||
expect(connectWithToken).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().error).toBe('Invalid access token');
|
||||
});
|
||||
|
||||
it('registers nothing when the server refuses the token', async () => {
|
||||
connectWithToken.mockRejectedValueOnce(new InvalidTokenError());
|
||||
|
||||
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1-expired'))
|
||||
.rejects.toThrow(InvalidTokenError);
|
||||
expect(useAccountStore.getState().accounts).toHaveLength(0);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ vi.mock('../../api/jmap-client', () => ({
|
||||
logout: vi.fn(),
|
||||
restoreSession: vi.fn(),
|
||||
loadAccount: vi.fn(),
|
||||
loadAccountFromCache: vi.fn(async () => false),
|
||||
revalidateSession: vi.fn(async () => null),
|
||||
consumeLegacyCredentials: vi.fn(async () => null),
|
||||
clearAccountCredentials: vi.fn(async () => undefined),
|
||||
clearAllCredentials: vi.fn(async () => undefined),
|
||||
@@ -35,7 +37,7 @@ vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { jmapClient, AuthenticationError } from '../../api/jmap-client';
|
||||
import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store';
|
||||
import { useAccountStore } from '../account-store';
|
||||
import { useCalendarStore } from '../calendar-store';
|
||||
@@ -49,6 +51,8 @@ import { lastBodyHeight, rememberBodyHeight } from '../../lib/body-heights';
|
||||
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
|
||||
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
||||
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
|
||||
const mockLoadFromCache = jmapClient.loadAccountFromCache as ReturnType<typeof vi.fn>;
|
||||
const mockRevalidate = jmapClient.revalidateSession as ReturnType<typeof vi.fn>;
|
||||
|
||||
function resetAccountStore(): void {
|
||||
useAccountStore.setState({
|
||||
@@ -236,6 +240,61 @@ describe('auth-store', () => {
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
describe('from the session saved at the last connect', () => {
|
||||
function registerAccount() {
|
||||
useAccountStore.setState({
|
||||
accounts: [{
|
||||
id: 'acc-1', serverUrl: 'https://mail.example.com', username: 'user',
|
||||
displayName: 'user', email: 'user', avatarColor: '#000', lastLoginAt: 0,
|
||||
isConnected: true, hasError: false, isDefault: true,
|
||||
}],
|
||||
activeAccountId: 'acc-1',
|
||||
defaultAccountId: 'acc-1',
|
||||
});
|
||||
}
|
||||
|
||||
// Start-up used to wait for the server's session document before the
|
||||
// cached inbox could show.
|
||||
it('shows the account without waiting for the server', async () => {
|
||||
registerAccount();
|
||||
mockLoadFromCache.mockResolvedValueOnce(true);
|
||||
mockRevalidate.mockReturnValueOnce(new Promise(() => undefined));
|
||||
|
||||
const restored = await useAuthStore.getState().restoreSession();
|
||||
|
||||
expect(restored).toBe(true);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||
expect(mockLoadAccount).not.toHaveBeenCalled();
|
||||
expect(mockRevalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('signs out when the server refuses the stored credentials', async () => {
|
||||
registerAccount();
|
||||
mockLoadFromCache.mockResolvedValueOnce(true);
|
||||
mockRevalidate.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
|
||||
mockLoadAccount.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
|
||||
|
||||
await useAuthStore.getState().restoreSession();
|
||||
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
|
||||
|
||||
expect(useAuthStore.getState().error).toBe('Session expired');
|
||||
expect(useAccountStore.getState().accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays signed in and marks the account offline without network', async () => {
|
||||
registerAccount();
|
||||
mockLoadFromCache.mockResolvedValueOnce(true);
|
||||
mockRevalidate.mockRejectedValueOnce(new Error('Network request failed'));
|
||||
|
||||
await useAuthStore.getState().restoreSession();
|
||||
await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].hasError).toBe(true));
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(useAccountStore.getState().accounts[0].isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('stops waiting for a persisted store that never finishes hydrating', async () => {
|
||||
vi.useFakeTimers();
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
+52
-35
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { jmapClient, AuthenticationError, InvalidTokenError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
|
||||
import { jmapClient, AuthenticationError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
|
||||
import type { JMAPSession } from '../api/types';
|
||||
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
|
||||
import { useAccountStore } from './account-store';
|
||||
@@ -86,8 +86,6 @@ export interface AuthState {
|
||||
password: string,
|
||||
opts?: { addAccount?: boolean; totp?: string },
|
||||
) => Promise<void>;
|
||||
/** Access-token (Bearer) sign-in with a token the user pasted, e.g. a Fastmail API token. */
|
||||
loginWithToken: (serverUrl: string, token: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
|
||||
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
@@ -257,24 +255,11 @@ async function syncAccountDisplayName(accountId: string): Promise<void> {
|
||||
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
||||
// registers the account, and flips the store to connected. Throws on failure
|
||||
// so the caller can surface a flow-specific error.
|
||||
function completeOAuthHandoff(
|
||||
async function completeOAuthHandoff(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
||||
opts?: { addAccount?: boolean },
|
||||
): Promise<void> {
|
||||
return completeBearerSignIn(set, get, result.serverUrl, () => jmapClient.connectWithOAuth(result.serverUrl, result.tokens), opts);
|
||||
}
|
||||
|
||||
// Connects with `connect` (an OAuth bundle or a pasted access token, where the
|
||||
// server names the account), registers the account, and flips the store to
|
||||
// connected.
|
||||
async function completeBearerSignIn(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
serverUrl: string,
|
||||
connect: () => Promise<{ session: JMAPSession; username: string; accountId: string }>,
|
||||
opts?: { addAccount?: boolean },
|
||||
): Promise<void> {
|
||||
// Adding an account must not destroy the live one: the singleton keeps the
|
||||
// previous connection until the new sign-in has actually succeeded, and a
|
||||
@@ -283,7 +268,7 @@ async function completeBearerSignIn(
|
||||
|
||||
let connected: { session: JMAPSession; username: string; accountId: string };
|
||||
try {
|
||||
connected = await connect();
|
||||
connected = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
|
||||
} catch (err) {
|
||||
if (previous) jmapClient.restoreSnapshot(previous);
|
||||
throw err;
|
||||
@@ -294,7 +279,7 @@ async function completeBearerSignIn(
|
||||
const wasRegistered = !!accountStore.getAccountById(accountId);
|
||||
try {
|
||||
accountStore.addAccount({
|
||||
serverUrl: serverUrl.replace(/\/+$/, ''),
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
@@ -315,7 +300,7 @@ async function completeBearerSignIn(
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
// Start on the folder list now rather than once the mail screen has
|
||||
// mounted; the screen joins this load.
|
||||
void useEmailStore.getState().fetchMailboxes();
|
||||
@@ -429,21 +414,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginWithToken: async (serverUrl, typedToken, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
// Pasted tokens often come with the scheme or surrounding whitespace.
|
||||
const token = typedToken.trim().replace(/^Bearer\s+/i, '');
|
||||
const base = serverUrl.replace(/\/+$/, '');
|
||||
try {
|
||||
if (!token || /\s/.test(token)) throw new InvalidTokenError();
|
||||
await completeBearerSignIn(set, get, base, () => jmapClient.connectWithToken(base, token), opts);
|
||||
set({ pendingTotpLogin: null });
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
loginViaWebmail: async (webmailUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
refuseAddWhenFull(set, opts);
|
||||
@@ -841,6 +811,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// data from a previous session.
|
||||
useEmailStore.getState().setActiveAccount(target.id);
|
||||
|
||||
// Start from the session saved at the last connect: the cached mail
|
||||
// shows without waiting for the server, and a fresh session follows.
|
||||
let fromCache = false;
|
||||
try {
|
||||
fromCache = await jmapClient.loadAccountFromCache(target.id);
|
||||
} catch {
|
||||
fromCache = false;
|
||||
}
|
||||
if (fromCache) {
|
||||
accountStore.setActiveAccount(target.id);
|
||||
applyConnectedState(set, jmapClient.currentSession!, target.serverUrl, target.username, target.id);
|
||||
perfMark('restore-done', { cached: true });
|
||||
refetchFeatureStores();
|
||||
void revalidateRestoredSession(target.id);
|
||||
void syncAccountDisplayName(target.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
perfMark('restore-session');
|
||||
@@ -959,6 +947,35 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* After a start from the saved session: fetch a fresh one. New capabilities
|
||||
* or URLs replace the saved ones; bad credentials go the way of any 401 on a
|
||||
* live session (retrySession, then the sign-in screen); no network marks the
|
||||
* account offline, as a start without network always did.
|
||||
*/
|
||||
async function revalidateRestoredSession(accountId: string): Promise<void> {
|
||||
const before = jmapClient.accountId;
|
||||
try {
|
||||
const fresh = await jmapClient.revalidateSession();
|
||||
if (!fresh || useAuthStore.getState().activeAccountId !== accountId) return;
|
||||
useAuthStore.setState({ session: fresh, accountId: jmapClient.accountId });
|
||||
useAccountStore.getState().updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
|
||||
if (jmapClient.accountId !== before) refetchFeatureStores();
|
||||
} catch (err) {
|
||||
if (useAuthStore.getState().activeAccountId !== accountId) return;
|
||||
if (err instanceof AuthenticationError) {
|
||||
useAuthStore.setState({ session: null });
|
||||
await useAuthStore.getState().retrySession();
|
||||
return;
|
||||
}
|
||||
useAccountStore.getState().updateAccount(accountId, {
|
||||
isConnected: false,
|
||||
hasError: true,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A 401 on a live session (revoked password/token, expired refresh token)
|
||||
// used to leave the user on a dead session until relaunch: nothing outside
|
||||
// the login flow handled `AuthenticationError`, and `retrySession` bailed
|
||||
|
||||
Reference in New Issue
Block a user