Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 | ||
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea | ||
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d | ||
|
|
107eb807e3 | ||
|
|
e731357b89 | ||
|
|
9cf1d3e849 | ||
|
|
d3dcc4e830 | ||
|
|
e0d2b190d3 | ||
|
|
b048eaca03 | ||
|
|
6f515e15f7 | ||
|
|
0a176ab51c | ||
|
|
75299244cf | ||
|
|
6aaa479875 | ||
|
|
124c0258ad | ||
|
|
ccffd6b10d | ||
|
|
0d96d8d044 | ||
|
|
95c31631fb | ||
|
|
cf7275c9c3 | ||
|
|
e452f72a8a | ||
|
|
776447dd41 | ||
|
|
52eb18dc33 | ||
|
|
33dc3cd0dc | ||
|
|
72b4f4abac | ||
|
|
de1b955bfd | ||
|
|
c8434b0610 | ||
|
|
e19229a5c6 | ||
|
|
d7cee7ca78 | ||
|
|
b59d00f0b7 | ||
|
|
975ed08c82 | ||
|
|
76170648f5 | ||
|
|
509583e729 | ||
|
|
7b93265b4f | ||
|
|
b5c4d62bde | ||
|
|
c6cb44db61 | ||
|
|
0db4bf00aa | ||
|
|
e94b782f8e | ||
|
|
ae1ef5ba5e | ||
|
|
f891bf3abb | ||
|
|
af55e0acef | ||
|
|
1ebc5c66f7 | ||
|
|
0b3710265b | ||
|
|
56e1593646 | ||
|
|
e7857ef806 | ||
|
|
b3290c3202 | ||
|
|
f231d1c5c6 | ||
|
|
984c359bb4 | ||
|
|
50ab7145c4 | ||
|
|
8f68d24bdb | ||
|
|
f22e851c82 | ||
|
|
d4982f6932 | ||
|
|
275d674577 | ||
|
|
0460da51e5 | ||
|
|
90170ea0e7 | ||
|
|
cbc41c963b | ||
|
|
63eefd7700 | ||
|
|
d97571cf0f | ||
|
|
8403843e76 | ||
|
|
756ceaa436 | ||
|
|
99de74de93 | ||
|
|
46d30169b2 | ||
|
|
512a28c494 | ||
|
|
29c172f2af | ||
|
|
9e463d3a66 | ||
|
|
ab712f11ba | ||
|
|
053fde8d56 | ||
|
|
250c0a9c26 | ||
|
|
d62805f805 | ||
|
|
b9e6ba417d | ||
|
|
0087e4c8ff | ||
|
|
2470e99dc8 | ||
|
|
8ab82cdeb7 | ||
|
|
64a62b6306 | ||
|
|
f4ff07da7a | ||
|
|
2288d28e46 | ||
|
|
295e292e47 | ||
|
|
802474d3f2 | ||
|
|
bfa02f1177 | ||
|
|
4b29cdfd3c | ||
|
|
2a47913d70 | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f | ||
|
|
e19885dc72 | ||
|
|
4816f0e08b | ||
|
|
ef1d4bc387 | ||
|
|
61690603a2 | ||
|
|
765ff26aa5 | ||
|
|
b832ac0c49 | ||
|
|
f53cee7ec9 | ||
|
|
21d469f7e8 | ||
|
|
c1e0cb36c9 | ||
|
|
d8879617ed | ||
|
|
7b62755aee | ||
|
|
96e1cc1c3f | ||
|
|
ec137e931b | ||
|
|
cf78b07939 | ||
|
|
5237d9004d | ||
|
|
be15af5d4c | ||
|
|
90fe43882a | ||
|
|
465eeffd1e | ||
|
|
200404483c | ||
|
|
28f6787279 | ||
|
|
89f0524ad4 | ||
|
|
172110f7b0 | ||
|
|
aa612e6f0e | ||
|
|
76180b332d | ||
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
2a1265a9ac | ||
|
|
aba5c70b3e | ||
|
|
ccd8030af0 | ||
|
|
ad10d4a8aa | ||
|
|
9de8c56743 | ||
|
|
5fcf9367a6 | ||
|
|
bba0274a40 | ||
|
|
d179e1de82 | ||
|
|
63f2e07254 | ||
|
|
80b57161ca | ||
|
|
ee20f9a06d | ||
|
|
60b92e6ef4 | ||
|
|
c520e0a125 | ||
|
|
c9a0718acf | ||
|
|
c5143975db | ||
|
|
ae3121a746 | ||
|
|
2cec096040 | ||
|
|
a694751944 | ||
|
|
6c797f6e2c | ||
|
|
7fe4a1ba27 | ||
|
|
79c53e713c | ||
|
|
26d0cf3ccc | ||
|
|
bc74d3bbac | ||
|
|
80f7278a6f | ||
|
|
604843a408 | ||
|
|
1b4b185ac8 | ||
|
|
dfaf1f3e5e | ||
|
|
97c4f58379 | ||
|
|
5dfe869acb | ||
|
|
ab8a911863 | ||
|
|
dca9417aee | ||
|
|
8f604bf02e | ||
|
|
cf93f68fd3 | ||
|
|
2ee056749c | ||
|
|
10edc331a5 | ||
|
|
157ea398b8 | ||
|
|
e09640cfc2 | ||
|
|
ae679a7d3d | ||
|
|
156e79b348 | ||
|
|
df3175ff72 | ||
|
|
800c4e1442 | ||
|
|
5e62cf71f4 | ||
|
|
ef96acd05f | ||
|
|
0664a31f8b | ||
|
|
217bb59d57 | ||
|
|
eebb00238c | ||
|
|
41e2bdf922 | ||
|
|
11b897177d | ||
|
|
029eba4562 | ||
|
|
66789251e3 | ||
|
|
117823f746 | ||
|
|
3ab131dbe8 | ||
|
|
9439a4ffc0 | ||
|
|
4bcca70548 | ||
|
|
0110eebb78 | ||
|
|
724c3b80ee | ||
|
|
f24d919fdb | ||
|
|
13d5d1733b | ||
|
|
84f6b5777e | ||
|
|
7453513e08 | ||
|
|
d1ae713b1f | ||
|
|
1c75da8733 | ||
|
|
1a348792c0 | ||
|
|
f24ff7da9e | ||
|
|
0faee568cc | ||
|
|
654675d90b | ||
|
|
153e85cb80 | ||
|
|
b86e0a118e | ||
|
|
1ee6bc459d | ||
|
|
fe0aaa1247 | ||
|
|
78700401a2 | ||
|
|
2e1cb58548 | ||
|
|
ecbd218c34 | ||
|
|
474eae9992 | ||
|
|
7701a67dff | ||
|
|
2e9e5bb785 | ||
|
|
fad7b30dbf | ||
|
|
34236f677b | ||
|
|
09c2ff1f67 | ||
|
|
d088dd14e9 | ||
|
|
58b74527b2 | ||
|
|
7187cab904 | ||
|
|
7dbbd1ce25 | ||
|
|
78684e2492 | ||
|
|
c635ccd586 | ||
|
|
534cb8239e | ||
|
|
6f574de0b9 | ||
|
|
d34641d1f9 | ||
|
|
00f1a66095 | ||
|
|
05e3605ec7 | ||
|
|
20effab062 | ||
|
|
c30b4a23d2 | ||
|
|
13264d6479 | ||
|
|
7a53cd5d84 | ||
|
|
2b9d19521f | ||
|
|
c777dc4089 | ||
|
|
6785276377 | ||
|
|
d3262ff387 | ||
|
|
7736b3b551 | ||
|
|
2870a59971 | ||
|
|
232e892328 | ||
|
|
49c5d05c30 | ||
|
|
d135bb2449 | ||
|
|
417ed5a326 | ||
|
|
77fd494633 | ||
|
|
9d83b2fb62 | ||
|
|
a27d014c59 | ||
|
|
26bb947940 | ||
|
|
ad0f83c72f | ||
|
|
434c554038 | ||
|
|
1c574a5a93 | ||
|
|
eb8b33160c | ||
|
|
e67e240840 | ||
|
|
a948738506 | ||
|
|
1131073323 | ||
|
|
59cfe50793 | ||
|
|
3ed5fa53af | ||
|
|
96a6b38321 | ||
|
|
f743ab0f7d | ||
|
|
95c8324f12 | ||
|
|
59fcf386b9 | ||
|
|
f66669964a | ||
|
|
ed2525e5cb | ||
|
|
1f7acaf08a | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f |
@@ -0,0 +1,3 @@
|
||||
# patch-package patches must keep the patched files' line endings byte for byte
|
||||
# (react-native-android-widget ships CRLF sources).
|
||||
patches/*.patch -text
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
# Fail in seconds instead of after a 20-minute build. There is no
|
||||
# unsigned or debug-signed fallback: anything signed with the debug key
|
||||
@@ -249,6 +251,7 @@ jobs:
|
||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||
cp "$SRC" "$DEST"
|
||||
sha256sum "$DEST" > "$DEST.sha256"
|
||||
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
||||
MAPPING=""
|
||||
@@ -266,10 +269,18 @@ jobs:
|
||||
{
|
||||
echo "files<<FILES_EOF"
|
||||
echo "$DEST"
|
||||
echo "$DEST.sha256"
|
||||
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
||||
echo "FILES_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Ties the APK to this commit and workflow run, which the signing key
|
||||
# alone does not: `gh attestation verify <apk> --repo bulwarkmail/native`.
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
@@ -25,6 +25,9 @@ jobs:
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.export.outputs.version }}
|
||||
uploaded: ${{ steps.upload.outputs.done }}
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
@@ -254,6 +257,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
id: upload
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
@@ -271,4 +275,35 @@ jobs:
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "done=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
|
||||
# Puts the upload on the public TestFlight link (README): waits for App Store
|
||||
# Connect to process it, adds it to the external group and submits it for
|
||||
# beta review. On Linux, because most of this job is waiting.
|
||||
public-testflight:
|
||||
needs: build
|
||||
if: needs.build.outputs.uploaded == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Add the build to the public group and submit it for beta review
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import {
|
||||
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
|
||||
} from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
|
||||
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
startCalendarReminderTapHandling,
|
||||
} from './src/lib/calendar-notifications';
|
||||
import { startLivenessMonitor } from './src/lib/connection-liveness';
|
||||
import { startDeviceSyncTriggers } from './src/device-sync/app/triggers';
|
||||
import {
|
||||
setPendingCalendarOpen,
|
||||
type CalendarReminderTarget,
|
||||
@@ -42,10 +44,10 @@ 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 EmailListScreen from './src/screens/EmailListScreen';
|
||||
import MailTab from './src/screens/MailTab';
|
||||
import FilesScreen from './src/screens/FilesScreen';
|
||||
import CalendarScreen from './src/screens/CalendarScreen';
|
||||
import ContactsScreen from './src/screens/ContactsScreen';
|
||||
import ContactsTab from './src/screens/ContactsTab';
|
||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
@@ -56,12 +58,12 @@ import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
|
||||
import { mailboxAccountId } from './src/lib/mailbox-tree';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useLayout } from './src/lib/use-layout';
|
||||
import { toast } from './src/stores/toast-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
@@ -73,7 +75,16 @@ import { AppIconBadge } from './src/components/AppIconBadge';
|
||||
import { getEmails } from './src/api/email';
|
||||
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
||||
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
||||
import {
|
||||
acceptSignInLink,
|
||||
handleDeepLink,
|
||||
parseDeepLink,
|
||||
routeParkedSignInLink,
|
||||
shareToDeepLink,
|
||||
type DeepLink,
|
||||
} from './src/navigation/linking';
|
||||
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
||||
import { MAX_ACCOUNTS } from './src/lib/account-utils';
|
||||
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
@@ -86,6 +97,10 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||
|
||||
// The launch URL is read once per process: Android hands the same intent back
|
||||
// on every getInitialURL(), and a sign-in link must not run twice.
|
||||
let initialSignInLinkRead = false;
|
||||
|
||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||
if (!navigationRef.isReady()) return;
|
||||
|
||||
@@ -105,9 +120,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
emailIds: [payload.emailId],
|
||||
subject: payload.subject,
|
||||
// A group mailbox's message lives under another JMAP account (#839).
|
||||
jmapAccountId: notificationTapJmapAccountId(payload),
|
||||
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,9 +244,15 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
settings: t('sidebar.settings', 'Settings'),
|
||||
};
|
||||
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
|
||||
// Side by side layouts move the tabs into a rail on the left, like the
|
||||
// webmail's navigation rail.
|
||||
const { isSplit } = useLayout();
|
||||
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
|
||||
// with the tabs in a side rail the whole tab area has to.
|
||||
const bottomInset = useSafeAreaInsets().bottom;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<PushOnboardingPrompt />
|
||||
@@ -239,9 +262,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
headerShown: false,
|
||||
// Tabs out of view stop re-rendering until they are shown again.
|
||||
freezeOnBlur: true,
|
||||
tabBarPosition: isSplit ? 'left' : 'bottom',
|
||||
tabBarVariant: isSplit ? 'material' : 'uikit',
|
||||
// A narrow rail (icon over label), not a wide sidebar with labels
|
||||
// beside the icons.
|
||||
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
|
||||
tabBarActiveTintColor: c.text,
|
||||
tabBarInactiveTintColor: c.textSecondary,
|
||||
tabBarStyle: {
|
||||
tabBarStyle: isSplit ? {
|
||||
backgroundColor: c.secondary,
|
||||
borderRightColor: c.border,
|
||||
borderRightWidth: 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
shadowColor: 'transparent',
|
||||
} : {
|
||||
backgroundColor: c.background,
|
||||
borderTopColor: c.border,
|
||||
borderTopWidth: 1,
|
||||
@@ -275,30 +310,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<EmailListScreen
|
||||
onComposePress={() => navigation.navigate('Compose')}
|
||||
onEmailPress={(email) => {
|
||||
// The list holds the open folder's mail: name its account so the
|
||||
// viewer never has to guess it from whatever folder is open.
|
||||
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
|
||||
const target = {
|
||||
jmapAccountId: mailboxAccountId(all, currentMailboxId),
|
||||
// A row of an "All folders" list or a tag view names its own
|
||||
// account instead, and pages over that account's rows.
|
||||
...viewerParamsForRow(email),
|
||||
};
|
||||
// Start on the body now: mounting the viewer takes a while.
|
||||
prefetchMessage(email, target.jmapAccountId);
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
...target,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{() => <MailTab navigation={navigation} />}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name="Calendar"
|
||||
@@ -317,7 +329,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsScreen}
|
||||
component={ContactsTab}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
@@ -386,6 +398,7 @@ export default function App() {
|
||||
// cached mail list instead of the "Restoring session" spinner; the real
|
||||
// JMAP session comes up in the background.
|
||||
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
||||
const { isSplit } = useLayout();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasRestoredSession) {
|
||||
@@ -399,6 +412,9 @@ export default function App() {
|
||||
// Calendar reminders are kept scheduled from launch, not only once the
|
||||
// Calendar tab has been opened.
|
||||
startCalendarNotificationSync();
|
||||
// Device sync (Android, #34): live changes, the app's own edits and the
|
||||
// foreground ask Android to sync; accounts are checked against Android's.
|
||||
startDeviceSyncTriggers();
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
@@ -566,6 +582,52 @@ export default function App() {
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App,
|
||||
// `bulwarkmail://connect?…`) work signed out too, so unlike the links above
|
||||
// they are listened for from launch. They are parked for the login screen
|
||||
// (the signed-out one takes them as it mounts), marked as coming from
|
||||
// outside the app: any page or app can fire one, so the login screen asks
|
||||
// before it redeems or opens anything.
|
||||
React.useEffect(() => {
|
||||
if (!initialSignInLinkRead) {
|
||||
initialSignInLinkRead = true;
|
||||
void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined);
|
||||
}
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); });
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
// Signed in, a sign-in link adds an account: open Add account, whose login
|
||||
// screen takes the parked link (or says why a refused one won't be used).
|
||||
// On a cold start the navigator mounts right after the auth gate flips, so
|
||||
// wait for it like a reminder tap does.
|
||||
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null);
|
||||
const addAccountForLink = isAuthenticated && signInLinkPending;
|
||||
React.useEffect(() => {
|
||||
if (!addAccountForLink) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (cancelled) return;
|
||||
}
|
||||
if (cancelled || !navigationRef.isReady()) return;
|
||||
const route = routeParkedSignInLink(useAccountStore.getState().accounts.length);
|
||||
if (route === 'account-limit') {
|
||||
// Said like the Add account buttons say it, instead of opening a
|
||||
// screen whose sign-in can only fail.
|
||||
const t = useLocaleStore.getState().t;
|
||||
toast.error(
|
||||
t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }),
|
||||
t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (route === 'add-account') navigationRef.navigate('AddAccount');
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [addAccountForLink]);
|
||||
|
||||
// Re-register the device with the configured relay once authenticated,
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down THIS account's subscription so
|
||||
@@ -767,10 +829,11 @@ export default function App() {
|
||||
<Stack.Screen
|
||||
name="Compose"
|
||||
component={ComposeScreen}
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
// 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
|
||||
? { presentation: 'transparentModal', animation: 'fade' }
|
||||
: { presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||
<Stack.Screen
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
Bulwark Mobile
|
||||
Copyright (C) 2026 Bulwark Project Authors
|
||||
|
||||
This project is licensed under the GNU Affero General Public License,
|
||||
version 3 only, with the additional permission for app store
|
||||
distribution below. That permission is provisional; see the
|
||||
reservation at its end.
|
||||
|
||||
Additional permission under GNU AGPL version 3 section 7
|
||||
|
||||
The copyright holders of this Program give you additional permission to
|
||||
convey a covered work (this Program, a modified version of it, or a work
|
||||
that contains parts of it) through an app store, even though the app
|
||||
store's terms of service, usage rules or technical measures place
|
||||
restrictions on recipients that section 10 of this License would otherwise
|
||||
not allow, such as a limit on the number of devices a copy may be installed
|
||||
on, or digital rights management applied to the copy.
|
||||
|
||||
This permission applies only if you:
|
||||
|
||||
a) make the Corresponding Source of each version you convey through the
|
||||
app store available, at no charge and under the terms of this
|
||||
License, from a network server that anyone can access;
|
||||
|
||||
b) state, in the app store listing or inside the application, where
|
||||
that Corresponding Source can be obtained; and
|
||||
|
||||
c) do not impose any further restrictions of your own on the exercise
|
||||
of the rights granted under this License.
|
||||
|
||||
"App store" means a digital distribution service for applications that is
|
||||
operated by a third party, such as the Apple App Store and TestFlight,
|
||||
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
|
||||
AppGallery.
|
||||
|
||||
This permission covers only the parts of a covered work that its copyright
|
||||
holders license under this License; it does not extend to parts licensed
|
||||
under other terms. As section 7 of this License allows, you may remove this
|
||||
permission from copies you convey and from your modified versions.
|
||||
|
||||
Reservation
|
||||
|
||||
This additional permission has not been finalized. The contributors of
|
||||
earlier parts of this Program have been asked whether they agree to it:
|
||||
https://github.com/orgs/bulwarkmail/discussions/1113
|
||||
|
||||
Until that request is settled, the permission is granted subject to this
|
||||
reservation:
|
||||
|
||||
- It applies to all contributions made on or after 30 September 2026.
|
||||
These contributions are accepted under this License together with
|
||||
this permission.
|
||||
|
||||
- It applies to parts of this Program written before that date only
|
||||
where their copyright holders have agreed to it. Parts whose
|
||||
copyright holders do not agree will be rewritten or removed.
|
||||
|
||||
This reservation will be removed once every earlier contributor has
|
||||
agreed or their parts have been replaced.
|
||||
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ Open split them by tick; the P columns count the priority tags on those items
|
||||
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
|
||||
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
|
||||
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
|
||||
| #34 Native contacts/calendar sync adapters | done on Android: Settings → Contacts / Calendar → "Sync to this device" syncs both ways with the device's Contacts and Calendar apps ([design](docs/device-sync.md)); iOS has no API for it |
|
||||
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
|
||||
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
|
||||
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## Try it
|
||||
|
||||
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
|
||||
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
@@ -22,14 +27,15 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
|
||||
- Android: contacts and calendars sync both ways with the phone's Contacts and Calendar apps (Settings → Contacts / Calendar → Sync to this device)
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS builds, but push notifications and client certificates are Android-only so far
|
||||
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -45,4 +51,4 @@ For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-re
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
|
||||
|
||||
@@ -154,6 +154,11 @@ android {
|
||||
versionCode appVersionCode.toInteger()
|
||||
versionName appVersionName
|
||||
|
||||
// Android account type of device sync (#34), derived from the package
|
||||
// name so a later rename needs no other change. The XML in res/xml and
|
||||
// the Kotlin in sync/ read it as @string/device_sync_account_type.
|
||||
resValue "string", "device_sync_account_type", "${applicationId}.account"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
@@ -280,6 +285,11 @@ dependencies {
|
||||
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
|
||||
implementation("org.unifiedpush.android:connector:3.3.5")
|
||||
|
||||
// JVM unit tests for the logic in the Kotlin shell of device sync (src/test).
|
||||
// android.jar only stubs org.json, so the tests use the real one.
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
@@ -3,5 +3,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic">
|
||||
<!-- adb hooks for the device sync tests (#34). DUMP: only the shell may send them. -->
|
||||
<receiver android:name=".sync.DeviceSyncDebugReceiver" android:exported="true" android:permission="android.permission.DUMP" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Debug builds only: lets adb set up device sync accounts and drive the
|
||||
* provider bridge without the settings UI or JS, for the device tests in
|
||||
* docs/device-sync.md ("Testing"). Protected by android.permission.DUMP,
|
||||
* which only the shell and the system hold. Syncs themselves are triggered
|
||||
* with `adb shell requestsync`.
|
||||
*
|
||||
* adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \
|
||||
* --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \
|
||||
* --es enable contacts,calendar
|
||||
*
|
||||
* Commands: `ensure` (name, registry, optional enable), `enable` (name,
|
||||
* authority, optional --ez on false), `remove` (name), `list`, and the
|
||||
* provider calls `query` and `batch` (name, authority, and the
|
||||
* `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares
|
||||
* the shell's quoting, or pushed to the app's external files directory and
|
||||
* named by `file` when it is too long for a command line), `syncstate`
|
||||
* (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and
|
||||
* the sync-problem notification, `problem` (name, title, text, uri, optional
|
||||
* channel) and `clearproblem` (name). Results go to logcat (tag
|
||||
* BulwarkDeviceSync) and the broadcast's result data.
|
||||
*/
|
||||
class DeviceSyncDebugReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pending = goAsync()
|
||||
// Provider calls block; keep them off the main thread.
|
||||
thread(name = "DeviceSyncDebugReceiver") {
|
||||
val result = try {
|
||||
run(context, intent)
|
||||
} catch (e: Exception) {
|
||||
"error: ${e.javaClass.simpleName}: ${e.message}"
|
||||
}
|
||||
Log.i(TAG, "debug command: $result")
|
||||
pending.resultData = result
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun run(context: Context, intent: Intent): String {
|
||||
val name = intent.getStringExtra("name")
|
||||
return when (intent.getStringExtra("cmd")) {
|
||||
"ensure" -> {
|
||||
val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!)
|
||||
for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) {
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true)
|
||||
}
|
||||
"created=$created"
|
||||
}
|
||||
"enable" -> {
|
||||
val on = intent.getBooleanExtra("on", true)
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on)
|
||||
"enabled=$on"
|
||||
}
|
||||
"remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}"
|
||||
"list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" }
|
||||
"query" -> io(context, intent) { it.query(json(context, intent)) }
|
||||
"batch" -> io(context, intent) { it.applyBatch(json(context, intent)) }
|
||||
"syncstate" -> io(context, intent) { it.readSyncState().toString() }
|
||||
"photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use {
|
||||
it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString()
|
||||
}
|
||||
"problem" -> {
|
||||
SyncProblemNotifier.show(
|
||||
context,
|
||||
name!!,
|
||||
intent.getStringExtra("title").orEmpty(),
|
||||
intent.getStringExtra("text").orEmpty(),
|
||||
intent.getStringExtra("uri").orEmpty(),
|
||||
intent.getStringExtra("channel"),
|
||||
)
|
||||
"shown"
|
||||
}
|
||||
"clearproblem" -> {
|
||||
SyncProblemNotifier.clear(context, name!!)
|
||||
"cleared"
|
||||
}
|
||||
else -> "unknown cmd"
|
||||
}
|
||||
}
|
||||
|
||||
private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String =
|
||||
ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call)
|
||||
|
||||
private fun json(context: Context, intent: Intent): String {
|
||||
intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() }
|
||||
return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun authority(which: String) = when (which) {
|
||||
"contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||
"calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||
else -> which
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
@@ -17,7 +25,7 @@
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
@@ -28,6 +36,13 @@
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
<!-- Sign-in links from the webmail (bulwarkmail://pair?..., bulwarkmail://connect?...). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmail"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.intent.action.SENDTO"/>
|
||||
@@ -59,13 +74,37 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
|
||||
<!-- Device sync (#34), see docs/device-sync.md. The system binds these, so they are exported.
|
||||
They run in the app's main process on purpose: the adapters start the sync engine as a
|
||||
headless JS task in the same React Native runtime as the UI. -->
|
||||
<service android:name=".sync.BulwarkAuthenticatorService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator"/>
|
||||
</service>
|
||||
<service android:name=".sync.ContactsSyncService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_contacts"/>
|
||||
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts"/>
|
||||
</service>
|
||||
<service android:name=".sync.CalendarSyncService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendar"/>
|
||||
</service>
|
||||
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
|
||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
|
||||
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
||||
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
||||
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
thread(name = "bulwark-notification") {
|
||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
preview: String?,
|
||||
largeIcon: Bitmap,
|
||||
colorHex: String,
|
||||
emailId: String?,
|
||||
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
accountId: String?,
|
||||
jmapAccountId: String?,
|
||||
groupKey: String?,
|
||||
groupTitle: String?,
|
||||
markReadLabel: String,
|
||||
deleteLabel: String,
|
||||
replyLabel: String,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-notification://$notificationId")
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val bigText = buildString {
|
||||
if (!subject.isNullOrBlank()) append(subject)
|
||||
if (!preview.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(preview)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setContentTitle(title)
|
||||
// body is the subject with the "(No subject)" fallback; an empty
|
||||
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(if (bigText.isNotBlank()) bigText else body)
|
||||
.setBigContentTitle(title)
|
||||
.setSummaryText(groupTitle)
|
||||
)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
if (emailId != null && accountId != null) {
|
||||
// Action 1: Mark as read
|
||||
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_MARK_READ
|
||||
data = Uri.parse("bulwark-action-read://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "markRead")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val markReadPending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":read").hashCode(),
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
|
||||
)
|
||||
|
||||
// Action 2: Delete
|
||||
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_DELETE
|
||||
data = Uri.parse("bulwark-action-delete://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "delete")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val deletePending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":delete").hashCode(),
|
||||
deleteIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
||||
// Android 12+: unlock first, so a locked phone can't delete mail.
|
||||
.setAuthenticationRequired(true)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Action 3: Reply
|
||||
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-reply://$notificationId")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
putExtra(EXTRA_ACTION, "reply")
|
||||
}
|
||||
val replyPending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
(notificationId + ":reply").hashCode(),
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
|
||||
)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
val count = maxOf(children.size, 1)
|
||||
if (children.size < 2) {
|
||||
manager.cancel(groupKey, groupKey.hashCode())
|
||||
return
|
||||
}
|
||||
val count = children.size
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-group-summary://$groupKey")
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
|
||||
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
|
||||
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
@@ -12,6 +13,9 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkFcmModule(reactContext))
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
@@ -26,12 +27,16 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
|
||||
val data = message.data
|
||||
|
||||
// Contact and calendar changes become syncs of the Android accounts
|
||||
// they feed (device sync, #34); only mail needs the push task.
|
||||
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||
|
||||
// Hand off to JS via a headless task only when the app isn't already
|
||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||
// while foreground. When the app is open, the main JS instance already
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
if (!isAppInForeground()) {
|
||||
if (mail && !isAppInForeground()) {
|
||||
startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
|
||||
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||
|
||||
// Dismiss the notification immediately from the tray
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationId != null) {
|
||||
manager.cancel(notificationId, notificationId.hashCode())
|
||||
}
|
||||
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||
return
|
||||
}
|
||||
|
||||
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||
val pendingResult = goAsync()
|
||||
val app = context.applicationContext as Application
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Safety timeout to ensure pendingResult.finish() is always called
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||
|
||||
try {
|
||||
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
Log.w(TAG, "React context does not have active instance")
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
return@withReadyReactContext
|
||||
}
|
||||
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
val taskBundle = Bundle().apply {
|
||||
putString("action", action)
|
||||
putString("emailId", emailId)
|
||||
putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
// Keep raw extras as well
|
||||
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
|
||||
val config = HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(taskBundle),
|
||||
15_000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
|
||||
val listener = object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||
tasks.removeTaskEventListener(this)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
tasks.addTaskEventListener(listener)
|
||||
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId ->
|
||||
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||
},
|
||||
onRefused = { e ->
|
||||
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||
tasks.removeTaskEventListener(listener)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkNotifAction"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||
* its own so they zoom, asking for each page as it scrolls into view.
|
||||
*
|
||||
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||
* every call runs on one background thread, and the open document is kept
|
||||
* between calls for the file the preview shows.
|
||||
*/
|
||||
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||
private var openPath: String? = null
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var renderer: PdfRenderer? = null
|
||||
|
||||
override fun getName(): String = "BulwarkPdf"
|
||||
|
||||
override fun invalidate() {
|
||||
worker.execute { closeDocument() }
|
||||
worker.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||
@ReactMethod
|
||||
fun open(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
val pages = Arguments.createArray()
|
||||
for (i in 0 until doc.pageCount) {
|
||||
doc.openPage(i).use { page ->
|
||||
pages.pushMap(Arguments.createMap().apply {
|
||||
putInt("width", page.width)
|
||||
putInt("height", page.height)
|
||||
})
|
||||
}
|
||||
}
|
||||
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||
} catch (e: SecurityException) {
|
||||
// Password-protected; PdfRenderer can't open those.
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||
} catch (e: Throwable) {
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_OPEN", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||
@ReactMethod
|
||||
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||
doc.openPage(index).use { page ->
|
||||
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
// Pages are transparent where nothing is drawn.
|
||||
bitmap.eraseColor(Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
val out = fileOf(outUri)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
promise.resolve(outUri)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_PDF_RENDER", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the document the preview showed. */
|
||||
@ReactMethod
|
||||
fun close(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun document(fileUri: String): PdfRenderer {
|
||||
val path = fileOf(fileUri).absolutePath
|
||||
renderer?.let { if (openPath == path) return it }
|
||||
closeDocument()
|
||||
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
try {
|
||||
val doc = PdfRenderer(fd)
|
||||
descriptor = fd
|
||||
renderer = doc
|
||||
openPath = path
|
||||
return doc
|
||||
} catch (e: Throwable) {
|
||||
fd.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDocument() {
|
||||
try { renderer?.close() } catch (_: Throwable) {}
|
||||
try { descriptor?.close() } catch (_: Throwable) {}
|
||||
renderer = null
|
||||
descriptor = null
|
||||
openPath = null
|
||||
}
|
||||
|
||||
private fun fileOf(uri: String): File {
|
||||
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||
private const val MAX_PAGE_PX = 2480
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,11 @@ import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactInstanceEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
|
||||
@@ -44,7 +39,8 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
|
||||
withReadyReactContext { startTask(it, config, params) }
|
||||
// JobService callbacks run on the main thread, where HeadlessJs wants to be called.
|
||||
HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -76,68 +72,17 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
||||
tasks.addTaskEventListener(this)
|
||||
taskContext = tasks
|
||||
}
|
||||
UiThreadUtil.runOnUiThread {
|
||||
try {
|
||||
val taskId = tasks.startTask(config)
|
||||
running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
|
||||
} catch (e: IllegalStateException) {
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) },
|
||||
onRefused = { e ->
|
||||
// The app came to the foreground since the push arrived and
|
||||
// syncs on its own there.
|
||||
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
|
||||
jobFinished(params, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls [onReady] once with a React context whose JS instance is up,
|
||||
* starting React Native if the process was woken for this job.
|
||||
*
|
||||
* A context exists before its instance is ready, and a task started on
|
||||
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
||||
* not available" and waits for the timeout), which happened when several
|
||||
* pushes arrived while the app was starting. So the listener goes in
|
||||
* first and the ready check after it; whichever sees the ready instance
|
||||
* first runs the task.
|
||||
*/
|
||||
private fun withReadyReactContext(onReady: (ReactContext) -> Unit) {
|
||||
val done = AtomicBoolean(false)
|
||||
val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) }
|
||||
val app = application as ReactApplication
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" }
|
||||
val listener = object : ReactInstanceEventListener {
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
host.removeReactInstanceEventListener(this)
|
||||
runOnce(context)
|
||||
}
|
||||
}
|
||||
host.addReactInstanceEventListener(listener)
|
||||
val current = host.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
host.removeReactInstanceEventListener(listener)
|
||||
runOnce(current)
|
||||
} else {
|
||||
host.start()
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val manager = app.reactNativeHost.reactInstanceManager
|
||||
val listener = object : ReactInstanceEventListener {
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
manager.removeReactInstanceEventListener(this)
|
||||
runOnce(context)
|
||||
}
|
||||
}
|
||||
manager.addReactInstanceEventListener(listener)
|
||||
val current = manager.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
manager.removeReactInstanceEventListener(listener)
|
||||
runOnce(current)
|
||||
} else if (!manager.hasStartedCreatingInitialContext()) {
|
||||
manager.createReactContextInBackground()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -31,7 +32,10 @@ class BulwarkUnifiedPushService : PushService() {
|
||||
BulwarkMessagingService.ensureChannel(this)
|
||||
val data = parsePayload(message.content) ?: return
|
||||
|
||||
if (!BulwarkMessagingService.isAppInForeground()) {
|
||||
// Same routing as FCM: contact and calendar changes request device
|
||||
// syncs, and only mail starts the push task.
|
||||
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||
if (mail && !BulwarkMessagingService.isAppInForeground()) {
|
||||
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
|
||||
/**
|
||||
* Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
|
||||
* task when the app goes to the background. Run from the app itself the
|
||||
* refresh stalled: React Native pauses JS timers while the activity is in
|
||||
* the background, and fetch resolves every response through a timer, so the
|
||||
* first request went out and its answer was never read. A running headless
|
||||
* task keeps timers going, and the service keeps the process from being
|
||||
* frozen before the widgets are redrawn.
|
||||
*/
|
||||
class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/** JS side: src/widgets/sync.ts. */
|
||||
class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkWidgets"
|
||||
|
||||
/** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
|
||||
@ReactMethod
|
||||
fun refreshInBackground(promise: Promise) {
|
||||
try {
|
||||
reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Background start limits (the app left the foreground too long ago).
|
||||
promise.resolve(false)
|
||||
} catch (e: SecurityException) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactInstanceEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.interfaces.TaskInterface
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Running a headless JS task without HeadlessJsTaskService: starting React
|
||||
* Native when the process was woken without any UI, and starting the task on
|
||||
* its context. Shared by the push job (BulwarkPushJobService) and the device
|
||||
* sync adapters.
|
||||
*/
|
||||
object HeadlessJs {
|
||||
private const val TAG = "HeadlessJs"
|
||||
|
||||
/** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */
|
||||
private const val MAX_WAIT_MS = 600_000L
|
||||
private const val WATCH_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Calls [onReady] once with a React context whose JS instance is up,
|
||||
* starting React Native if the process was woken for this work.
|
||||
*
|
||||
* A context exists before its instance is ready, and a task started on
|
||||
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
||||
* not available" and waits for the timeout), which happened when several
|
||||
* pushes arrived while the app was starting. So the listener goes in
|
||||
* first and the ready check after it; whichever sees the ready instance
|
||||
* first runs the task.
|
||||
*
|
||||
* The wait ends once, and its listener goes with it: with a ready context,
|
||||
* or without one when React fails to start (the start task faults) or
|
||||
* after [MAX_WAIT_MS], when no caller can still be waiting. A context that
|
||||
* comes up later runs nothing of it, so waits given up on don't pile up on
|
||||
* the host with everything their [onReady] holds. (A caller that gives up
|
||||
* sooner, like the sync adapter at its deadline, checks that in [onReady].)
|
||||
*
|
||||
* Call it on the main thread; [onReady] runs there too. MainApplication
|
||||
* creates the ReactHost on first use without a lock (ExpoReactHostFactory),
|
||||
* so a background caller racing MainActivity could create a second one,
|
||||
* with a second JS runtime.
|
||||
*
|
||||
* Returns React Native's start task when it had to be started, so a caller
|
||||
* can notice a start that fails (a debug build with no Metro to load the
|
||||
* bundle from), or null when the context was already up.
|
||||
*/
|
||||
@MainThread
|
||||
fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface<Void>? {
|
||||
val reactApp = app as ReactApplication
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" }
|
||||
val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) }
|
||||
host.addReactInstanceEventListener(wait)
|
||||
val current = host.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
wait.onReactContextInitialized(current)
|
||||
return null
|
||||
}
|
||||
return host.start().also { wait.watch(it) }
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val manager = reactApp.reactNativeHost.reactInstanceManager
|
||||
val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) }
|
||||
manager.addReactInstanceEventListener(wait)
|
||||
val current = manager.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
wait.onReactContextInitialized(current)
|
||||
return null
|
||||
}
|
||||
if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground()
|
||||
wait.watch(null)
|
||||
return null
|
||||
}
|
||||
|
||||
/** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */
|
||||
private class ContextWait(
|
||||
private val onReady: (ReactContext) -> Unit,
|
||||
private val removeListener: (ReactInstanceEventListener) -> Unit,
|
||||
) : ReactInstanceEventListener {
|
||||
private val ended = AtomicBoolean(false)
|
||||
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
if (end()) onReady(context)
|
||||
}
|
||||
|
||||
/** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */
|
||||
fun watch(boot: TaskInterface<Void>?) {
|
||||
val main = Handler(Looper.getMainLooper())
|
||||
val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS
|
||||
main.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (ended.get()) return
|
||||
val faulted = boot?.isFaulted() == true
|
||||
if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) {
|
||||
main.postDelayed(this, WATCH_MS)
|
||||
} else if (end()) {
|
||||
val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms"
|
||||
Log.w(TAG, "$why; no longer waiting")
|
||||
}
|
||||
}
|
||||
}, WATCH_MS)
|
||||
}
|
||||
|
||||
private fun end(): Boolean {
|
||||
if (!ended.compareAndSet(false, true)) return false
|
||||
removeListener(this)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts [config] on the UI thread, where HeadlessJsTaskContext wants it.
|
||||
* [onStarted] gets the task id in that same UI-thread turn, so before any
|
||||
* finish callback (HeadlessJsTaskContext posts those to the UI thread).
|
||||
* [onRefused] gets the refusal when the context is resumed and [config]
|
||||
* isn't allowed in the foreground.
|
||||
*/
|
||||
fun startTask(
|
||||
tasks: HeadlessJsTaskContext,
|
||||
config: HeadlessJsTaskConfig,
|
||||
onStarted: (taskId: Int) -> Unit,
|
||||
onRefused: (IllegalStateException) -> Unit,
|
||||
) {
|
||||
UiThreadUtil.runOnUiThread {
|
||||
val taskId = try {
|
||||
tasks.startTask(config)
|
||||
} catch (e: IllegalStateException) {
|
||||
onRefused(e)
|
||||
return@runOnUiThread
|
||||
}
|
||||
onStarted(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ object NotificationTapStore {
|
||||
// The JMAP account a group/shared mailbox's message lives in - a JMAP
|
||||
// id like the email id. Optional: older notifications don't carry it.
|
||||
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
|
||||
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
@@ -51,6 +52,7 @@ object NotificationTapStore {
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_ACTION)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ object NotificationTapStore {
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
val jmapAccountId: String?,
|
||||
val action: String? = null,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
@@ -67,6 +70,7 @@ object NotificationTapStore {
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
if (action != null) putString("action", action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +79,5 @@ object NotificationTapStore {
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import com.anonymous.bulwarkmobile.MainActivity
|
||||
|
||||
/**
|
||||
* The authenticator of the "Bulwark" account type. The app holds the
|
||||
* credentials, not the account: accounts are only ever created by the app,
|
||||
* from Settings → Contacts or Calendar, for an account that is signed in.
|
||||
*/
|
||||
class BulwarkAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
|
||||
|
||||
/** Settings → Accounts → Add account opens the app's device sync settings instead. */
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
accountType: String?,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<out String>?,
|
||||
options: Bundle?,
|
||||
): Bundle {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(SETTINGS_URI))
|
||||
return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
|
||||
}
|
||||
|
||||
/** Removal from Android Settings is allowed; the app notices and turns device sync off. */
|
||||
override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse?, account: Account?): Bundle =
|
||||
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) }
|
||||
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported()
|
||||
|
||||
override fun confirmCredentials(response: AccountAuthenticatorResponse?, account: Account?, options: Bundle?): Bundle =
|
||||
unsupported()
|
||||
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported()
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle = unsupported()
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String?): String? = null
|
||||
|
||||
override fun hasFeatures(response: AccountAuthenticatorResponse?, account: Account?, features: Array<out String>?): Bundle =
|
||||
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
|
||||
|
||||
private fun unsupported() = Bundle().apply {
|
||||
putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
|
||||
putString(AccountManager.KEY_ERROR_MESSAGE, "Bulwark signs in from the app")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Handled by the app's deep links: Settings → Contacts, with its "Sync to this device" section. */
|
||||
const val SETTINGS_URI = "bulwarkmobile://settings/contacts"
|
||||
}
|
||||
}
|
||||
|
||||
class BulwarkAuthenticatorService : Service() {
|
||||
private val authenticator by lazy { BulwarkAuthenticator(this) }
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
/**
|
||||
* `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings, the run
|
||||
* handshake, provider I/O, push routes and the sync-problem notification of
|
||||
* device sync (#34). The contract is `DeviceSyncNativeModule` in
|
||||
* src/device-sync/types.ts; docs/device-sync.md explains it.
|
||||
*/
|
||||
class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val accountsListener = OnAccountsUpdateListener {
|
||||
emit(ACCOUNTS_CHANGED_EVENT, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider I/O runs here, one call at a time: a batch can hold the
|
||||
* provider for seconds (it sleeps at a contended yield point), which would
|
||||
* stall every other native module call on React's native-modules thread.
|
||||
*/
|
||||
private val io: ExecutorService = Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "BulwarkDeviceSync-io").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
init {
|
||||
synchronized(BulwarkDeviceSyncModule::class.java) { currentInstance = this }
|
||||
}
|
||||
|
||||
override fun getName(): String = NAME
|
||||
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
val manager = AccountManager.get(reactApplicationContext)
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.addOnAccountsUpdatedListener(accountsListener, handler, false, arrayOf(accountType()))
|
||||
} else {
|
||||
manager.addOnAccountsUpdatedListener(accountsListener, handler, false)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Account change listener not registered", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
try {
|
||||
AccountManager.get(reactApplicationContext).removeOnAccountsUpdatedListener(accountsListener)
|
||||
} catch (e: RuntimeException) {
|
||||
// Not registered.
|
||||
}
|
||||
io.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
private fun accountType() = DeviceSyncAccounts.accountType(reactApplicationContext)
|
||||
|
||||
// ── accounts ────────────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getInfo(promise: Promise) {
|
||||
promise.resolve(Arguments.createMap().apply {
|
||||
putString("accountType", accountType())
|
||||
putInt("sdkInt", Build.VERSION.SDK_INT)
|
||||
})
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun listAccounts(promise: Promise) = settle(promise) {
|
||||
Arguments.createArray().apply {
|
||||
for ((account, registryId) in DeviceSyncAccounts.list(reactApplicationContext)) {
|
||||
pushMap(Arguments.createMap().apply {
|
||||
putString("name", account.name)
|
||||
putString("registryId", registryId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun ensureAccount(name: String, registryId: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.ensure(reactApplicationContext, name, registryId)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun removeAccount(name: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.remove(reactApplicationContext, name)
|
||||
}
|
||||
|
||||
// ── sync settings ───────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getSyncSettings(name: String, promise: Promise) = settle(promise) {
|
||||
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
|
||||
Arguments.createMap().apply {
|
||||
putBoolean("masterAutomatic", ContentResolver.getMasterSyncAutomatically())
|
||||
putMap("authorities", Arguments.createMap().apply {
|
||||
for (authority in DeviceSyncAccounts.AUTHORITIES) putMap(authority, authoritySettings(account, authority))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun authoritySettings(account: Account, authority: String): WritableMap = Arguments.createMap().apply {
|
||||
putInt("syncable", ContentResolver.getIsSyncable(account, authority))
|
||||
putBoolean("automatic", ContentResolver.getSyncAutomatically(account, authority))
|
||||
val periodic = ContentResolver.getPeriodicSyncs(account, authority).minOfOrNull { it.period } ?: 0L
|
||||
putDouble("periodicSeconds", periodic.toDouble())
|
||||
putBoolean("active", ContentResolver.isSyncActive(account, authority))
|
||||
putBoolean("pending", ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setSyncEnabled(name: String, authority: String, enabled: Boolean, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.setSyncEnabled(reactApplicationContext, name, authority, enabled)
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setPeriodicSync(name: String, authority: String, seconds: Double, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.setPeriodicSync(reactApplicationContext, name, authority, seconds.toLong())
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun requestSync(name: String, authority: String, optionsJson: String, promise: Promise) = settle(promise) {
|
||||
val options = JSONObject(optionsJson)
|
||||
val flags = SyncRequestFlags(
|
||||
manual = options.optBoolean("manual"),
|
||||
expedited = options.optBoolean("expedited"),
|
||||
upload = options.optBoolean("upload"),
|
||||
overrideTooManyDeletions = options.optBoolean("overrideTooManyDeletions"),
|
||||
discardLocalDeletions = options.optBoolean("discardLocalDeletions"),
|
||||
)
|
||||
DeviceSyncAccounts.requestSync(reactApplicationContext, name, authority, flags)
|
||||
null
|
||||
}
|
||||
|
||||
/** The account's own sync screen where the platform has one, else the list of synced accounts. */
|
||||
@ReactMethod
|
||||
fun openAccountSettings(name: String, promise: Promise) = settle(promise) {
|
||||
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
|
||||
val perAccount = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
|
||||
.putExtra(EXTRA_ACCOUNT, account)
|
||||
// Newer Settings apps pass only these arguments on to the account's
|
||||
// screen; without them it closes at once ("Account provided does
|
||||
// not exist: null", Android 12L), and no exception tells us so.
|
||||
.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { putParcelable(EXTRA_ACCOUNT, account) })
|
||||
val overview = Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(account.type))
|
||||
val activity = reactApplicationContext.currentActivity
|
||||
for (intent in listOf(perAccount, overview)) {
|
||||
try {
|
||||
if (activity != null) {
|
||||
activity.startActivity(intent)
|
||||
} else {
|
||||
reactApplicationContext.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
return@settle null
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// Try the next one.
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("No settings screen for sync accounts")
|
||||
}
|
||||
|
||||
// ── run handshake ───────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun finishRun(runId: String, reportJson: String, promise: Promise) {
|
||||
val accepted = DeviceSyncRuns.finish(runId, reportJson)
|
||||
if (!accepted) Log.i(TAG, "Report for run $runId ignored: nothing waits for it")
|
||||
promise.resolve(accepted)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun isRunCancelled(runId: String, promise: Promise) {
|
||||
promise.resolve(DeviceSyncRuns.isCancelled(runId))
|
||||
}
|
||||
|
||||
// ── provider I/O ────────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out; rejects with `scope`, `permission` or `provider`. */
|
||||
@ReactMethod
|
||||
fun query(name: String, authority: String, queryJson: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.query(queryJson) }
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never rejects: a failed batch is a result. */
|
||||
@ReactMethod
|
||||
fun applyBatch(name: String, authority: String, opsJson: String, promise: Promise) {
|
||||
val failed = { e: Throwable -> ProviderResults.failed(ProviderIo.failureOf(e), e.message ?: e.javaClass.simpleName) }
|
||||
try {
|
||||
io.execute {
|
||||
val result = try {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.applyBatch(opsJson) }
|
||||
} catch (e: Throwable) {
|
||||
failed(e)
|
||||
}
|
||||
promise.resolve(result)
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.resolve(failed(e))
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun readSyncState(name: String, authority: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.readSyncState() }
|
||||
}
|
||||
|
||||
/** `PhotoData` JSON, or null when the raw contact has no photo; rejects with `scope` for another account's. */
|
||||
@ReactMethod
|
||||
fun readPhoto(name: String, rawContactId: Double, maxPx: Double, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, DeviceSyncAccounts.CONTACTS_AUTHORITY)
|
||||
.use { it.readPhoto(rawContactId.toLong(), maxPx.toInt()) }
|
||||
}
|
||||
|
||||
/** Settles [promise] with [block]'s result on the I/O thread; rejects with a `BatchFailure` code. */
|
||||
private fun onIo(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
io.execute {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: Throwable) {
|
||||
promise.reject(ProviderIo.failureOf(e).wire, e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.reject("provider", "Device sync is shutting down", e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── push routes and notifications ───────────────────────────
|
||||
|
||||
/** `{ jmapAccountId: [{ accountName, authorities }] }` for the push router (DeviceSyncPushRouter). */
|
||||
@ReactMethod
|
||||
fun setPushRoutes(routesJson: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncPushRouter.saveRoutes(reactApplicationContext, routesJson)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts (or replaces) the account's sync-problem notification. [channelName]
|
||||
* names the notification channel in the app's language; null keeps English.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun showSyncProblem(
|
||||
accountName: String,
|
||||
title: String,
|
||||
text: String,
|
||||
uri: String,
|
||||
channelName: String?,
|
||||
promise: Promise,
|
||||
) = settle(promise) {
|
||||
SyncProblemNotifier.show(reactApplicationContext, accountName, title, text, uri, channelName)
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearSyncProblem(accountName: String, promise: Promise) = settle(promise) {
|
||||
SyncProblemNotifier.clear(reactApplicationContext, accountName)
|
||||
null
|
||||
}
|
||||
|
||||
// NativeEventEmitter bookkeeping.
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) {}
|
||||
|
||||
private inline fun settle(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: AccountConflictException) {
|
||||
promise.reject("conflict", e.message, e)
|
||||
} catch (e: SecurityException) {
|
||||
promise.reject("permission", e.message, e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
promise.reject("bad_args", e.message, e)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("failed", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "BulwarkDeviceSync"
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
const val ACCOUNTS_CHANGED_EVENT = "BulwarkDeviceSync:accountsChanged"
|
||||
|
||||
/** Settings' per-account sync screen; public on AOSP-based settings apps, but not in the SDK. */
|
||||
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||
private const val EXTRA_ACCOUNT = "account"
|
||||
/** SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS: the arguments of the screen it shows. */
|
||||
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
|
||||
|
||||
@Volatile private var currentInstance: BulwarkDeviceSyncModule? = null
|
||||
|
||||
/** Dropped when no React instance or no JS listener is there, like BulwarkFcmModule.emit. */
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
if (!ctx.hasActiveReactInstance()) return
|
||||
ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import com.anonymous.bulwarkmobile.R
|
||||
|
||||
/**
|
||||
* The Android accounts of device sync (#34) and their sync settings. One
|
||||
* account per app account that has device sync on; see docs/device-sync.md.
|
||||
*
|
||||
* Android's own sync settings are the source of truth for on/off: the
|
||||
* Contacts and Calendar toggles in Settings → Accounts are
|
||||
* `getSyncAutomatically`, and the app reads them back instead of keeping a
|
||||
* copy.
|
||||
*/
|
||||
object DeviceSyncAccounts {
|
||||
const val CONTACTS_AUTHORITY: String = ContactsContract.AUTHORITY
|
||||
const val CALENDAR_AUTHORITY: String = CalendarContract.AUTHORITY
|
||||
val AUTHORITIES = listOf(CONTACTS_AUTHORITY, CALENDAR_AUTHORITY)
|
||||
|
||||
/** userData key holding the app account (`AccountEntry.id`) an Android account belongs to. */
|
||||
const val USER_DATA_REGISTRY_ID = "registryId"
|
||||
|
||||
/** `<applicationId>.account`, set in build.gradle so the package name is never hard-coded. */
|
||||
fun accountType(context: Context): String = context.getString(R.string.device_sync_account_type)
|
||||
|
||||
fun account(context: Context, name: String) = Account(name, accountType(context))
|
||||
|
||||
fun list(context: Context): List<Pair<Account, String?>> {
|
||||
val manager = AccountManager.get(context)
|
||||
return manager.getAccountsByType(accountType(context)).map { it to manager.getUserData(it, USER_DATA_REGISTRY_ID) }
|
||||
}
|
||||
|
||||
fun exists(context: Context, name: String): Boolean =
|
||||
AccountManager.get(context).getAccountsByType(accountType(context)).any { it.name == name }
|
||||
|
||||
/**
|
||||
* Adds the account when it is missing. Both authorities start syncable
|
||||
* with automatic sync off; the app turns on the ones the user picked.
|
||||
* Returns true when the account was created, false when it exists for
|
||||
* [registryId] already. An account that belongs to another registry id
|
||||
* keeps it ([AccountConflictException]): its rows and SyncState were
|
||||
* written for that app account.
|
||||
*/
|
||||
fun ensure(context: Context, name: String, registryId: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = account(context, name)
|
||||
val exists = manager.getAccountsByType(account.type).any { it.name == name }
|
||||
val current = if (exists) manager.getUserData(account, USER_DATA_REGISTRY_ID) else null
|
||||
when (ensureAction(exists, current, registryId)) {
|
||||
EnsureAction.KEEP -> return false
|
||||
EnsureAction.ADOPT -> {
|
||||
manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId)
|
||||
return false
|
||||
}
|
||||
EnsureAction.CONFLICT -> throw AccountConflictException(
|
||||
"The Android account $name belongs to another app account ($current)",
|
||||
)
|
||||
EnsureAction.CREATE -> Unit
|
||||
}
|
||||
val userData = Bundle().apply { putString(USER_DATA_REGISTRY_ID, registryId) }
|
||||
check(manager.addAccountExplicitly(account, null, userData)) { "Android refused to add the account" }
|
||||
for (authority in AUTHORITIES) {
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
enum class EnsureAction { CREATE, KEEP, ADOPT, CONFLICT }
|
||||
|
||||
/** An existing account without a registry id (never completed) is adopted; one with another id is not touched. */
|
||||
fun ensureAction(exists: Boolean, currentRegistryId: String?, registryId: String): EnsureAction = when {
|
||||
!exists -> EnsureAction.CREATE
|
||||
currentRegistryId == registryId -> EnsureAction.KEEP
|
||||
currentRegistryId.isNullOrEmpty() -> EnsureAction.ADOPT
|
||||
else -> EnsureAction.CONFLICT
|
||||
}
|
||||
|
||||
/** Removes the account; the Contacts and Calendar providers drop all of its rows. */
|
||||
fun remove(context: Context, name: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = account(context, name)
|
||||
if (manager.getAccountsByType(account.type).none { it.name == name }) return false
|
||||
return manager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
fun setSyncEnabled(context: Context, name: String, authority: String, enabled: Boolean) {
|
||||
requireAuthority(authority)
|
||||
val account = account(context, name)
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, authority, enabled)
|
||||
}
|
||||
|
||||
/** Replaces our periodic sync for the authority; 0 removes it. */
|
||||
fun setPeriodicSync(context: Context, name: String, authority: String, seconds: Long) {
|
||||
requireAuthority(authority)
|
||||
val account = account(context, name)
|
||||
for (periodic in ContentResolver.getPeriodicSyncs(account, authority)) {
|
||||
ContentResolver.removePeriodicSync(account, authority, periodic.extras)
|
||||
}
|
||||
if (seconds > 0) ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a sync. SyncManager drops a request that matches a sync it is
|
||||
* running, so a run of that account and authority is also flagged to
|
||||
* ask for another sync when it ends (see [DeviceSyncRuns.flagSyncAgain]).
|
||||
*/
|
||||
fun requestSync(context: Context, name: String, authority: String, flags: SyncRequestFlags) {
|
||||
requireAuthority(authority)
|
||||
DeviceSyncRuns.flagSyncAgain(name, authority)
|
||||
ContentResolver.requestSync(account(context, name), authority, flags.toExtras())
|
||||
}
|
||||
|
||||
fun requireAuthority(authority: String) {
|
||||
require(authority in AUTHORITIES) { "Unknown authority $authority" }
|
||||
}
|
||||
}
|
||||
|
||||
/** [DeviceSyncAccounts.ensure] refused to hand an account of one app account to another. */
|
||||
class AccountConflictException(message: String) : Exception(message)
|
||||
|
||||
/** The SYNC_EXTRAS_* flags a request carries. Constant extras let repeated requests coalesce. */
|
||||
data class SyncRequestFlags(
|
||||
val manual: Boolean = false,
|
||||
val expedited: Boolean = false,
|
||||
val upload: Boolean = false,
|
||||
val overrideTooManyDeletions: Boolean = false,
|
||||
val discardLocalDeletions: Boolean = false,
|
||||
) {
|
||||
fun toExtras(): Bundle = Bundle().apply {
|
||||
if (manual) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
if (expedited) putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
|
||||
if (upload) putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true)
|
||||
if (overrideTooManyDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, true)
|
||||
if (discardLocalDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Application
|
||||
import android.app.Service
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import com.anonymous.bulwarkmobile.HeadlessJs
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The sync adapter behind both authorities (#34). It does no syncing itself:
|
||||
* it starts the BulwarkDeviceSync headless JS task, where the engine lives
|
||||
* (src/device-sync), waits for its report and hands that to SyncManager. See
|
||||
* docs/device-sync.md.
|
||||
*
|
||||
* onPerformSync runs on its own SyncAdapterThread and may block; React Native
|
||||
* is started from the main thread (see HeadlessJs), which stays free.
|
||||
* SyncManager holds a wake lock for as long as onPerformSync runs.
|
||||
*/
|
||||
class DeviceSyncAdapter(context: Context) :
|
||||
AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true, /* allowParallelSyncs = */ false) {
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Anything thrown out of here is rethrown on the sync thread
|
||||
* (AbstractThreadedSyncAdapter's SyncThread) and kills the process, the UI
|
||||
* included. So every failure becomes a result: a soft error SyncManager
|
||||
* retries with back-off, or a database error for a missing permission,
|
||||
* as the framework would report it.
|
||||
*/
|
||||
override fun onPerformSync(
|
||||
account: Account,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult,
|
||||
) {
|
||||
try {
|
||||
performSync(account, extras, authority, syncResult)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Sync of $authority refused by a permission check", e)
|
||||
syncResult.databaseError = true
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Sync of $authority failed", t)
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSync(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
val registryId = AccountManager.get(context).getUserData(account, DeviceSyncAccounts.USER_DATA_REGISTRY_ID)
|
||||
if (registryId.isNullOrEmpty()) {
|
||||
Log.w(TAG, "Sync of $authority skipped: the account has no app account")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
// A sync scheduled as an expedited job (Android 12+) gets 3 minutes
|
||||
// from JobScheduler instead of 10; stay inside either.
|
||||
val expedited = extras.getBoolean(SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB, false)
|
||||
val deadline = startedAt + if (expedited) EXPEDITED_BUDGET_MS else BUDGET_MS
|
||||
val run = DeviceSyncRuns.open(account.name, authority)
|
||||
main.post { startRun(run, account, registryId, authority, extras, deadline) }
|
||||
try {
|
||||
while (!run.done.await(POLL_MS, TimeUnit.MILLISECONDS)) {
|
||||
val boot = run.boot
|
||||
if (boot != null && boot.isFaulted()) {
|
||||
run.end("React Native did not start: ${boot.getError()?.message}")
|
||||
} else if (SystemClock.elapsedRealtime() >= deadline) {
|
||||
run.end("no report before the deadline")
|
||||
}
|
||||
}
|
||||
val outcome = SyncReports.outcome(run.report, run.endReason)
|
||||
SyncReports.apply(outcome, syncResult)
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " +
|
||||
"${SystemClock.elapsedRealtime() - startedAt} ms: ${SyncReports.summary(run.report)}; $outcome")
|
||||
} catch (e: InterruptedException) {
|
||||
// onSyncCanceled interrupts this thread. JS sees the run as
|
||||
// cancelled at its next checkpoint (isRunCancelled).
|
||||
run.end("cancelled")
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority cancelled")
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
DeviceSyncRuns.close(run)
|
||||
// After startRun, which was posted first, so a listener it added is removed.
|
||||
main.post { run.detach() }
|
||||
}
|
||||
// A change pushed while the run was open: SyncManager dropped its
|
||||
// request as a duplicate of this sync, so ask for the next one here.
|
||||
if (run.syncAgainRequested) {
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority: changes arrived meanwhile, syncing again")
|
||||
syncResult.fullSyncRequested = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun startRun(
|
||||
run: DeviceSyncRuns.Run,
|
||||
account: Account,
|
||||
registryId: String,
|
||||
authority: String,
|
||||
extras: Bundle,
|
||||
deadline: Long,
|
||||
) {
|
||||
if (run.cancelled) return
|
||||
try {
|
||||
run.boot = HeadlessJs.withReadyReactContext(context.applicationContext as Application) { reactContext ->
|
||||
if (run.cancelled) return@withReadyReactContext
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
run.end("the React Native instance is gone")
|
||||
return@withReadyReactContext
|
||||
}
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
run.attach(tasks, object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
|
||||
// JS settled without a report, timed out, or the context was reloaded.
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
if (taskId == run.taskId) run.end("task finished")
|
||||
}
|
||||
})
|
||||
val taskTimeout = (deadline - SystemClock.elapsedRealtime() - LATCH_SLACK_MS).coerceAtLeast(MIN_TASK_MS)
|
||||
val data = Arguments.createMap().apply {
|
||||
putString("runId", run.runId)
|
||||
putString("accountName", account.name)
|
||||
putString("registryId", registryId)
|
||||
putString("authority", authority)
|
||||
putMap("extras", runExtras(extras))
|
||||
putDouble("deadline", (System.currentTimeMillis() + taskTimeout - JS_SLACK_MS).toDouble())
|
||||
}
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
HeadlessJsTaskConfig(TASK_KEY, data, taskTimeout, /* isAllowedInForeground = */ true),
|
||||
onStarted = { run.taskId = it },
|
||||
onRefused = { run.end("refused: ${it.message}") },
|
||||
)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
run.end("could not start: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun runExtras(extras: Bundle): WritableMap = Arguments.createMap().apply {
|
||||
fun flag(key: String, name: String) {
|
||||
if (extras.getBoolean(key, false)) putBoolean(name, true)
|
||||
}
|
||||
flag(ContentResolver.SYNC_EXTRAS_MANUAL, "manual")
|
||||
flag(ContentResolver.SYNC_EXTRAS_UPLOAD, "upload")
|
||||
flag(ContentResolver.SYNC_EXTRAS_EXPEDITED, "expedited")
|
||||
flag(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, "ignoreBackoff")
|
||||
flag(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, "overrideTooManyDeletions")
|
||||
flag(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, "discardLocalDeletions")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
const val TASK_KEY = "BulwarkDeviceSync"
|
||||
|
||||
/** ContentResolver.SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB (API 31). */
|
||||
private const val SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB = "schedule_as_expedited_job"
|
||||
|
||||
private const val BUDGET_MS = 570_000L // 9.5 min of JobScheduler's 10
|
||||
private const val EXPEDITED_BUDGET_MS = 150_000L // 2.5 min of 3
|
||||
private const val LATCH_SLACK_MS = 20_000L // the task times out this long before our deadline
|
||||
private const val JS_SLACK_MS = 30_000L // JS aims to be done this long before the task timeout
|
||||
private const val MIN_TASK_MS = 30_000L
|
||||
private const val POLL_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds the contacts authority's adapter (xml/sync_contacts.xml). */
|
||||
class ContactsSyncService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
|
||||
|
||||
companion object {
|
||||
@Volatile private var adapter: DeviceSyncAdapter? = null
|
||||
|
||||
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
|
||||
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds the calendar authority's adapter (xml/sync_calendar.xml). */
|
||||
class CalendarSyncService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
|
||||
|
||||
companion object {
|
||||
@Volatile private var adapter: DeviceSyncAdapter? = null
|
||||
|
||||
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
|
||||
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import com.facebook.react.interfaces.TaskInterface
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The device sync runs an adapter is waiting for, keyed by a random run id.
|
||||
*
|
||||
* A run ends exactly once, on the first of: JS calling finishRun, the
|
||||
* headless task ending (JS settled, timed out or was reloaded away), React
|
||||
* refusing or failing to start, the adapter's deadline, or a cancel. A report
|
||||
* that arrives after that is refused whole rather than half-applied. Headless
|
||||
* task ids restart at 1 with every React context, so they never identify a
|
||||
* run; the run id does.
|
||||
*/
|
||||
object DeviceSyncRuns {
|
||||
const val NO_TASK = -1
|
||||
|
||||
enum class State { WAITING, ENDED }
|
||||
|
||||
class Run(val runId: String, val accountName: String, val authority: String) {
|
||||
private val state = AtomicReference(State.WAITING)
|
||||
private val syncAgain = AtomicBoolean(false)
|
||||
val done = CountDownLatch(1)
|
||||
|
||||
/** Written before [done] opens, read after it: the latch orders them. */
|
||||
@Volatile var report: String? = null
|
||||
private set
|
||||
@Volatile var endReason: String? = null
|
||||
private set
|
||||
@Volatile var cancelled = false
|
||||
@Volatile var boot: TaskInterface<Void>? = null
|
||||
/** Written and compared on the UI thread only. */
|
||||
@Volatile var taskId = NO_TASK
|
||||
@Volatile private var tasks: HeadlessJsTaskContext? = null
|
||||
@Volatile private var listener: HeadlessJsTaskEventListener? = null
|
||||
|
||||
val isWaiting: Boolean get() = state.get() == State.WAITING
|
||||
|
||||
/** True once a sync was requested for this account and authority while the run was open. */
|
||||
val syncAgainRequested: Boolean get() = syncAgain.get()
|
||||
|
||||
/** Releases the waiting adapter. Only the first end counts; returns whether this was it. */
|
||||
fun end(reason: String): Boolean = end(reason, null)
|
||||
|
||||
internal fun end(reason: String, reportJson: String?): Boolean {
|
||||
if (!state.compareAndSet(State.WAITING, State.ENDED)) return false
|
||||
report = reportJson
|
||||
endReason = reason
|
||||
done.countDown()
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun requestSyncAgain() {
|
||||
syncAgain.set(true)
|
||||
}
|
||||
|
||||
fun attach(tasks: HeadlessJsTaskContext, listener: HeadlessJsTaskEventListener) {
|
||||
this.tasks = tasks
|
||||
this.listener = listener
|
||||
tasks.addTaskEventListener(listener)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
val tasks = tasks ?: return
|
||||
listener?.let { tasks.removeTaskEventListener(it) }
|
||||
this.tasks = null
|
||||
this.listener = null
|
||||
}
|
||||
}
|
||||
|
||||
private val runs = ConcurrentHashMap<String, Run>()
|
||||
|
||||
fun open(accountName: String, authority: String): Run =
|
||||
Run(UUID.randomUUID().toString(), accountName, authority).also { runs[it.runId] = it }
|
||||
|
||||
/**
|
||||
* Forgets the run: a late finishRun is then refused and isCancelled
|
||||
* answers true. Read [Run.syncAgainRequested] after this, so a request
|
||||
* can no longer slip in between reading the flag and closing.
|
||||
*/
|
||||
fun close(run: Run) {
|
||||
run.end("closed")
|
||||
run.cancelled = true
|
||||
runs.remove(run.runId)
|
||||
}
|
||||
|
||||
/** From JS. Accepted only while the adapter still waits for this run: false when it ended, timed out or is unknown. */
|
||||
fun finish(runId: String, reportJson: String): Boolean =
|
||||
runs[runId]?.end("reported", reportJson) ?: false
|
||||
|
||||
/** Runs nobody waits for, or that already ended, count as cancelled: JS should stop. */
|
||||
fun isCancelled(runId: String): Boolean {
|
||||
val run = runs[runId] ?: return true
|
||||
return run.cancelled || !run.isWaiting
|
||||
}
|
||||
|
||||
fun cancel(runId: String) {
|
||||
runs[runId]?.cancelled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the open runs of [accountName] and [authority] to request another
|
||||
* sync when they end (`SyncResult.fullSyncRequested`): SyncManager drops a
|
||||
* `requestSync` that matches a sync it is running, so a change pushed
|
||||
* mid-run would otherwise wait for the next trigger. Returns whether a
|
||||
* run was flagged.
|
||||
*/
|
||||
fun flagSyncAgain(accountName: String, authority: String): Boolean {
|
||||
var flagged = false
|
||||
for (run in runs.values) {
|
||||
if (run.accountName == accountName && run.authority == authority) {
|
||||
run.requestSyncAgain()
|
||||
flagged = true
|
||||
}
|
||||
}
|
||||
return flagged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.OperationApplicationException
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.TransactionTooLargeException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.SyncStateContract
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
/**
|
||||
* Runs device sync's provider calls for one Android account of our type and
|
||||
* one authority: `query`, `applyBatch`, `readSyncState` and `readPhoto` of
|
||||
* `NativeModules.BulwarkDeviceSync`. [ScopePlanner] decides what may run; this
|
||||
* class only builds the calls and reads the answers.
|
||||
*
|
||||
* Every URI carries `caller_is_syncadapter=true` and the account
|
||||
* (CalendarProvider accepts no other query parameter), so writes neither set
|
||||
* DIRTY nor schedule upload syncs, and ContactsProvider filters data rows by
|
||||
* the account. The scope lookups are the exception: they read ids and owners
|
||||
* of rows of any account.
|
||||
*
|
||||
* The client is unstable: a provider process that dies fails the call instead
|
||||
* of taking this process (and the UI) with it. Call from a background thread;
|
||||
* close when done.
|
||||
*/
|
||||
class ProviderIo(context: Context, accountName: String, private val authority: String) : ScopeLookup, AutoCloseable {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val account: AccountRef
|
||||
private val client: ContentProviderClient
|
||||
|
||||
init {
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$authority is not a device sync authority")
|
||||
}
|
||||
account = AccountRef(accountName, DeviceSyncAccounts.accountType(appContext))
|
||||
client = appContext.contentResolver.acquireUnstableContentProviderClient(authority)
|
||||
?: throw IllegalStateException("No content provider for $authority")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
client.close()
|
||||
}
|
||||
|
||||
// ── the bridge calls ────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out. Throws [ProviderRefusal] or what the provider threw. */
|
||||
fun query(queryJson: String): String {
|
||||
val plan = ScopePlanner(account, this).planQuery(ProviderOpParser.parseQuery(authority, queryJson))
|
||||
val writer = ProviderRowsWriter(plan.columns)
|
||||
val projection = plan.columns.toTypedArray()
|
||||
for (selection in plan.selections) {
|
||||
query(uri(plan.table), projection, selection, plan.orderBy).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
writer.beginRow()
|
||||
for (i in projection.indices) {
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> writer.longCell(cursor.getLong(i))
|
||||
Cursor.FIELD_TYPE_FLOAT -> writer.doubleCell(cursor.getDouble(i))
|
||||
Cursor.FIELD_TYPE_STRING -> writer.textCell(cursor.getString(i))
|
||||
// Blobs (photo thumbnails) never cross the bridge; readPhoto reads them.
|
||||
else -> writer.nullCell()
|
||||
}
|
||||
}
|
||||
writer.endRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
return writer.finish()
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never throws: failures are results. */
|
||||
fun applyBatch(opsJson: String): String = try {
|
||||
val ops = ProviderOpParser.parseBatch(authority, opsJson)
|
||||
// Rows written for an account that is gone would outlive it: the
|
||||
// providers purge an account's rows only when accounts change.
|
||||
if (!DeviceSyncAccounts.exists(appContext, account.name)) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "The account ${account.name} no longer exists")
|
||||
}
|
||||
if (ops.isEmpty()) {
|
||||
ProviderResults.ok(emptyList())
|
||||
} else {
|
||||
val planned = ScopePlanner(account, this).planBatch(ops)
|
||||
val results = client.applyBatch(ArrayList(planned.map(::build)))
|
||||
ProviderResults.ok(planned.zip(results) { op, result -> outcome(op, result.uri, result.count) })
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ProviderResults.failed(failureOf(e), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/** The account's SyncState blob as text, or null when there is none. */
|
||||
fun readSyncState(): String? =
|
||||
SyncStateContract.Helpers.get(client, syncStateUri(), Account(account.name, account.type))
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
/**
|
||||
* The raw contact's display photo, or its thumbnail when it has none,
|
||||
* scaled so the longer side is at most [maxPx], as `PhotoData` JSON; null
|
||||
* when the raw contact has no photo.
|
||||
*/
|
||||
fun readPhoto(rawContactId: Long, maxPx: Int): String? {
|
||||
require(authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) { "Photos are contacts data" }
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
if (rootAccounts(ProviderTable.RAW_CONTACTS, setOf(rawContactId))[rawContactId] != account) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "Raw contact $rawContactId is not part of the account")
|
||||
}
|
||||
var fileId: Long? = null
|
||||
var thumbnail: ByteArray? = null
|
||||
val photoRow = Selection(
|
||||
"raw_contact_id = ? AND mimetype = ?",
|
||||
listOf(rawContactId.toString(), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE),
|
||||
)
|
||||
query(
|
||||
uri(ProviderTable.DATA),
|
||||
arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID, ContactsContract.CommonDataKinds.Photo.PHOTO),
|
||||
photoRow,
|
||||
"${ContactsContract.Data.IS_PRIMARY} DESC",
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
if (!cursor.isNull(0)) fileId = cursor.getLong(0)
|
||||
if (cursor.getType(1) == Cursor.FIELD_TYPE_BLOB) thumbnail = cursor.getBlob(1)
|
||||
}
|
||||
}
|
||||
val bytes = readDisplayPhoto(rawContactId) ?: thumbnail ?: return null
|
||||
val jpeg = scaledJpeg(bytes, maxPx) ?: return null
|
||||
return JSONObject()
|
||||
.put("jpegBase64", Base64.encodeToString(jpeg, Base64.NO_WRAP))
|
||||
.put("fileId", fileId ?: JSONObject.NULL)
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun readDisplayPhoto(rawContactId: Long): ByteArray? {
|
||||
val uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, rawContactId).buildUpon()
|
||||
.appendPath(ContactsContract.RawContacts.DisplayPhoto.CONTENT_DIRECTORY)
|
||||
.scoped()
|
||||
.build()
|
||||
return try {
|
||||
client.openAssetFile(uri, "r")?.use { fd -> fd.createInputStream().use { it.readBytes() } }
|
||||
} catch (e: FileNotFoundException) {
|
||||
// No display photo stored (small photos only have the thumbnail).
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaledJpeg(bytes: ByteArray, maxPx: Int): ByteArray? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = PhotoScaling.sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
|
||||
val (width, height) = PhotoScaling.targetSize(decoded.width, decoded.height, maxPx)
|
||||
val scaled = if (width == decoded.width && height == decoded.height) {
|
||||
decoded
|
||||
} else {
|
||||
Bitmap.createScaledBitmap(decoded, width, height, true)
|
||||
}
|
||||
return ByteArrayOutputStream().use { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||
out.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScopeLookup ─────────────────────────────────────────────
|
||||
|
||||
override fun ownCalendarIds(): Set<Long> {
|
||||
val ids = HashSet<Long>()
|
||||
val selection = Selection(
|
||||
"${ScopePlanner.ACCOUNT_NAME} = ? AND ${ScopePlanner.ACCOUNT_TYPE} = ?",
|
||||
listOf(account.name, account.type),
|
||||
)
|
||||
query(uri(ProviderTable.CALENDARS), arrayOf(ID), selection, null).use { c ->
|
||||
while (c.moveToNext()) ids += c.getLong(0)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
override fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef> {
|
||||
val owners = HashMap<Long, AccountRef>()
|
||||
forEachRow(table, arrayOf(ID, ScopePlanner.ACCOUNT_NAME, ScopePlanner.ACCOUNT_TYPE), ID, ids) { c ->
|
||||
owners[c.getLong(0)] = AccountRef(c.getString(1).orEmpty(), c.getString(2).orEmpty())
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
override fun dataRawContacts(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.DATA, "raw_contact_id", ids)
|
||||
|
||||
override fun dataMimetypes(ids: Set<Long>): Map<Long, String> {
|
||||
val mimetypes = HashMap<Long, String>()
|
||||
forEachRow(ProviderTable.DATA, arrayOf(ID, "mimetype"), ID, ids) { c ->
|
||||
if (!c.isNull(1)) mimetypes[c.getLong(0)] = c.getString(1)
|
||||
}
|
||||
return mimetypes
|
||||
}
|
||||
|
||||
override fun eventCalendars(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.EVENTS, "calendar_id", ids)
|
||||
|
||||
override fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(table, "event_id", ids)
|
||||
|
||||
override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long> {
|
||||
val events = HashSet<Long>()
|
||||
query(uri(table, scoped = false), arrayOf("event_id"), Selection(where, args), null).use { c ->
|
||||
while (c.moveToNext()) if (!c.isNull(0)) events += c.getLong(0)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Row id → [column] for the rows of [ids] that exist, in any account. */
|
||||
private fun idPairs(table: ProviderTable, column: String, ids: Set<Long>): Map<Long, Long> {
|
||||
val pairs = HashMap<Long, Long>()
|
||||
forEachRow(table, arrayOf(ID, column), table.idColumn!!, ids) { c ->
|
||||
if (!c.isNull(1)) pairs[c.getLong(0)] = c.getLong(1)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
private fun forEachRow(
|
||||
table: ProviderTable,
|
||||
projection: Array<String>,
|
||||
idColumn: String,
|
||||
ids: Set<Long>,
|
||||
read: (Cursor) -> Unit,
|
||||
) {
|
||||
for (chunk in ids.chunked(ScopePlanner.ID_LIST_CHUNK)) {
|
||||
val selection = Selection(ScopePlanner.idList(idColumn, chunk), emptyList())
|
||||
query(uri(table, scoped = false), projection, selection, null).use { c ->
|
||||
while (c.moveToNext()) read(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun query(uri: Uri, projection: Array<String>, selection: Selection, orderBy: String?): Cursor =
|
||||
client.query(uri, projection, selection.sql, selection.args.toTypedArray(), orderBy)
|
||||
?: throw IllegalStateException("$authority returned no cursor for $uri")
|
||||
|
||||
// ── operations ──────────────────────────────────────────────
|
||||
|
||||
private fun build(op: PlannedOp): ContentProviderOperation = when (op) {
|
||||
is PlannedOp.Insert -> ContentProviderOperation.newInsert(uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.apply { for ((column, index) in op.backReferences) withValueBackReference(column, index) }
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Update -> ContentProviderOperation
|
||||
.newUpdate(op.itemId?.let { uri(op.table, itemId = it) } ?: uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Delete -> ContentProviderOperation.newDelete(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Assert -> ContentProviderOperation.newAssertQuery(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.apply {
|
||||
op.values?.let { values ->
|
||||
withValues(ContentValues().apply { for ((column, text) in values) put(column, text) })
|
||||
}
|
||||
}
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
// An insert that replaces the account's row; never a yield point.
|
||||
is PlannedOp.SetSyncState -> SyncStateContract.Helpers.newSetOperation(
|
||||
syncStateUri(),
|
||||
Account(account.name, account.type),
|
||||
op.value.toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.selectRows(selection: Selection) = apply {
|
||||
// An item URI takes no selection at all (CalendarProvider refuses one).
|
||||
if (selection.sql != null) withSelection(selection.sql, selection.args.toTypedArray())
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.expectRows(count: Int?) = apply {
|
||||
if (count != null) withExpectedCount(count)
|
||||
}
|
||||
|
||||
private fun outcome(op: PlannedOp, uri: Uri?, count: Int?): OpOutcome = when (op) {
|
||||
// Settings inserts answer with the account's URI, which has no id.
|
||||
is PlannedOp.Insert -> OpOutcome(id = uri?.lastPathSegment?.toLongOrNull())
|
||||
is PlannedOp.SetSyncState -> OpOutcome()
|
||||
else -> OpOutcome(count = count)
|
||||
}
|
||||
|
||||
private fun contentValues(values: Map<String, Cell>) = ContentValues(values.size).apply {
|
||||
for ((column, cell) in values) {
|
||||
when (cell) {
|
||||
is Cell.Text -> put(column, cell.value)
|
||||
is Cell.Integer -> put(column, cell.value)
|
||||
is Cell.Real -> put(column, cell.value)
|
||||
is Cell.Blob -> put(column, decodeBlob(column, cell.base64))
|
||||
Cell.Null -> putNull(column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBlob(column: String, base64: String): ByteArray = try {
|
||||
Base64.decode(base64, Base64.DEFAULT)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "$column is not base64")
|
||||
}
|
||||
|
||||
// ── URIs ────────────────────────────────────────────────────
|
||||
|
||||
private fun uri(table: ProviderTable, itemId: Long? = null, scoped: Boolean = true): Uri {
|
||||
val base = when (table) {
|
||||
ProviderTable.RAW_CONTACTS -> ContactsContract.RawContacts.CONTENT_URI
|
||||
ProviderTable.DATA -> ContactsContract.Data.CONTENT_URI
|
||||
ProviderTable.GROUPS -> ContactsContract.Groups.CONTENT_URI
|
||||
ProviderTable.SETTINGS -> ContactsContract.Settings.CONTENT_URI
|
||||
ProviderTable.CALENDARS -> CalendarContract.Calendars.CONTENT_URI
|
||||
ProviderTable.EVENTS -> CalendarContract.Events.CONTENT_URI
|
||||
ProviderTable.ATTENDEES -> CalendarContract.Attendees.CONTENT_URI
|
||||
ProviderTable.REMINDERS -> CalendarContract.Reminders.CONTENT_URI
|
||||
ProviderTable.EXTENDED_PROPERTIES -> CalendarContract.ExtendedProperties.CONTENT_URI
|
||||
ProviderTable.COLORS -> CalendarContract.Colors.CONTENT_URI
|
||||
}
|
||||
val builder = (itemId?.let { ContentUris.withAppendedId(base, it) } ?: base).buildUpon()
|
||||
return if (scoped) builder.scoped().build() else builder.syncAdapter().build()
|
||||
}
|
||||
|
||||
private fun syncStateUri(): Uri = when (authority) {
|
||||
DeviceSyncAccounts.CONTACTS_AUTHORITY -> ContactsContract.SyncState.CONTENT_URI
|
||||
else -> CalendarContract.SyncState.CONTENT_URI
|
||||
}.buildUpon().scoped().build()
|
||||
|
||||
private fun Uri.Builder.syncAdapter() = appendQueryParameter(CALLER_IS_SYNCADAPTER, "true")
|
||||
|
||||
private fun Uri.Builder.scoped() = syncAdapter()
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_TYPE, account.type)
|
||||
|
||||
companion object {
|
||||
private const val ID = "_id"
|
||||
private const val JPEG_QUALITY = 85
|
||||
|
||||
/** Same parameter name in ContactsContract and CalendarContract. */
|
||||
private const val CALLER_IS_SYNCADAPTER = ContactsContract.CALLER_IS_SYNCADAPTER
|
||||
|
||||
/** The `BatchFailure` of an exception from planning or from the provider (possibly wrapped). */
|
||||
fun failureOf(e: Throwable): BatchFailure {
|
||||
var t: Throwable? = e
|
||||
while (t != null) {
|
||||
when (t) {
|
||||
is ProviderRefusal -> return t.reason
|
||||
is TransactionTooLargeException -> return BatchFailure.TOO_LARGE
|
||||
is SecurityException -> return BatchFailure.PERMISSION
|
||||
is OperationApplicationException -> return ProviderResults.failureOfOperationApplication(t.message)
|
||||
}
|
||||
t = t.cause?.takeIf { it !== t }
|
||||
}
|
||||
return BatchFailure.PROVIDER
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The provider operations of device sync (#34) as the engine sends them: the
|
||||
* `ProviderQuery` and `ProviderOp[]` JSON of src/device-sync/types.ts, parsed
|
||||
* into a validated model. [ScopePlanner] turns the model into provider calls
|
||||
* scoped to one account and [ProviderIo] runs them. Nothing here touches
|
||||
* Android types, so JVM tests cover all of it.
|
||||
*/
|
||||
|
||||
/** `BatchFailure` of types.ts: why a batch was refused or failed. */
|
||||
enum class BatchFailure(val wire: String) {
|
||||
ASSERT("assert"),
|
||||
TOO_LARGE("tooLarge"),
|
||||
SCOPE("scope"),
|
||||
PERMISSION("permission"),
|
||||
PROVIDER("provider"),
|
||||
}
|
||||
|
||||
/** A query or batch refused before it reached the provider. */
|
||||
class ProviderRefusal(val reason: BatchFailure, message: String) : Exception(message)
|
||||
|
||||
/** How the rows of a table are tied to an account; see [ScopePlanner]. */
|
||||
enum class TableScope { ACCOUNT_COLUMNS, URI_PARAMETERS, CALENDAR_IDS, EVENT_IDS }
|
||||
|
||||
/** The tables the engine may touch, per authority. Any other table is refused. */
|
||||
enum class ProviderTable(
|
||||
val wire: String,
|
||||
val authority: String,
|
||||
val scope: TableScope,
|
||||
/** The table whose row each row here belongs to, named by [parentColumn]. */
|
||||
val parent: ProviderTable? = null,
|
||||
val parentColumn: String? = null,
|
||||
/**
|
||||
* How a selection names the row id; null when rows have none (Settings
|
||||
* rows are keyed by their account). CalendarProvider queries attendees
|
||||
* joined with events and calendars, where a bare `_id` is ambiguous.
|
||||
*/
|
||||
val idColumn: String? = "_id",
|
||||
) {
|
||||
RAW_CONTACTS("raw_contacts", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
DATA("data", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.URI_PARAMETERS, RAW_CONTACTS, "raw_contact_id"),
|
||||
GROUPS("groups", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
SETTINGS("settings", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS, idColumn = null),
|
||||
CALENDARS("calendars", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
EVENTS("events", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.CALENDAR_IDS, CALENDARS, "calendar_id"),
|
||||
ATTENDEES(
|
||||
"attendees", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Attendees._id",
|
||||
),
|
||||
REMINDERS(
|
||||
"reminders", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Reminders._id",
|
||||
),
|
||||
EXTENDED_PROPERTIES(
|
||||
"extended_properties",
|
||||
DeviceSyncAccounts.CALENDAR_AUTHORITY,
|
||||
TableScope.EVENT_IDS,
|
||||
EVENTS,
|
||||
"event_id",
|
||||
"ExtendedProperties._id",
|
||||
),
|
||||
COLORS("colors", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
;
|
||||
|
||||
/** An insert returns the new row id, which later ops can back-reference. */
|
||||
val insertReturnsId: Boolean get() = idColumn != null
|
||||
|
||||
companion object {
|
||||
fun of(authority: String, wire: String): ProviderTable =
|
||||
entries.firstOrNull { it.wire == wire && it.authority == authority }
|
||||
?: throw ProviderRefusal(BatchFailure.SCOPE, "Table $wire is not part of $authority")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell as it crosses the bridge: text, a number, null, or (written to
|
||||
* `data.data15` only) photo bytes as base64. Numbers that are integers become
|
||||
* [Integer], so an assert compares `5` with the provider's `5`, not `5.0`.
|
||||
*/
|
||||
sealed interface Cell {
|
||||
data class Text(val value: String) : Cell
|
||||
data class Integer(val value: Long) : Cell
|
||||
data class Real(val value: Double) : Cell
|
||||
data class Blob(val base64: String) : Cell
|
||||
data object Null : Cell
|
||||
|
||||
/** The text ContentProviderOperation compares an assert with; blobs never reach an assert. */
|
||||
fun text(): String? = when (this) {
|
||||
is Text -> value
|
||||
is Integer -> value.toString()
|
||||
is Real -> value.toString()
|
||||
is Blob -> throw IllegalStateException("Blobs have no text form")
|
||||
Null -> null
|
||||
}
|
||||
|
||||
/** The row id this cell names, if it names one (`12` or `"12"`). */
|
||||
fun rowId(): Long? = when (this) {
|
||||
is Integer -> value
|
||||
is Text -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Which rows an update, delete or assert addresses: one row by id, a `where` set, or the id row if it matches. */
|
||||
data class RowTarget(val id: Long?, val where: String?, val args: List<String>)
|
||||
|
||||
data class ProviderQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val where: String?,
|
||||
val args: List<String>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One `ProviderOp` of types.ts, validated. */
|
||||
sealed interface ProviderOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
/** Column → index of an earlier insert of the batch whose new row id fills it. */
|
||||
val refs: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>?,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class SetSyncState(val value: String, override val yieldAllowed: Boolean) : ProviderOp
|
||||
}
|
||||
|
||||
const val GROUP_MEMBERSHIP_MIMETYPE = "vnd.android.cursor.item/group_membership"
|
||||
|
||||
/**
|
||||
* Checks a selection or sort order before the account's scope is ANDed onto
|
||||
* it as `(text) AND (scope)`: its parentheses must balance outside quoted
|
||||
* literals, or `1) OR (1` would close the scope's own parenthesis. It may
|
||||
* not end or comment out the statement, nor query other tables. The engine's
|
||||
* selections are constants with `?` arguments; this guards against bugs.
|
||||
*/
|
||||
object SqlFragments {
|
||||
private val FORBIDDEN_WORDS = Regex("\\b(SELECT|UNION|ATTACH|DETACH|PRAGMA)\\b", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun check(text: String, what: String) {
|
||||
var depth = 0
|
||||
var quote: Char? = null
|
||||
val outside = StringBuilder(text.length)
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
if (quote != null) {
|
||||
if (c == quote) {
|
||||
// A doubled quote is an escaped one inside the literal.
|
||||
if (i + 1 < text.length && text[i + 1] == quote) i++ else quote = null
|
||||
}
|
||||
outside.append(' ')
|
||||
} else {
|
||||
when (c) {
|
||||
'\'', '"' -> {
|
||||
quote = c
|
||||
outside.append(' ')
|
||||
}
|
||||
'(' -> {
|
||||
depth++
|
||||
outside.append(c)
|
||||
}
|
||||
')' -> {
|
||||
if (--depth < 0) refuse(what, "closes a parenthesis it did not open")
|
||||
outside.append(c)
|
||||
}
|
||||
';' -> refuse(what, "ends the statement")
|
||||
'\u0000' -> refuse(what, "contains a NUL character")
|
||||
else -> outside.append(c)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
if (quote != null) refuse(what, "leaves a quote open")
|
||||
if (depth != 0) refuse(what, "leaves a parenthesis open")
|
||||
val bare = outside.toString()
|
||||
if ("--" in bare || "/*" in bare) refuse(what, "contains a comment")
|
||||
FORBIDDEN_WORDS.find(bare)?.let { refuse(what, "uses ${it.value.uppercase()}") }
|
||||
}
|
||||
|
||||
private fun refuse(what: String, why: String): Nothing =
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$what $why")
|
||||
}
|
||||
|
||||
/**
|
||||
* The table a written column points into when the row it names must belong to
|
||||
* the account as well: the parent of a row, the master of an exception event,
|
||||
* the group of a membership row.
|
||||
*/
|
||||
internal fun referencedTable(table: ProviderTable, column: String, values: Map<String, Cell>): ProviderTable? = when {
|
||||
column == table.parentColumn -> table.parent
|
||||
table == ProviderTable.EVENTS && column == "original_id" -> ProviderTable.EVENTS
|
||||
table == ProviderTable.DATA && column == "data1" &&
|
||||
values["mimetype"] == Cell.Text(GROUP_MEMBERSHIP_MIMETYPE) -> ProviderTable.GROUPS
|
||||
else -> null
|
||||
}
|
||||
|
||||
object ProviderOpParser {
|
||||
/**
|
||||
* ContactsProvider throws when its op counter reaches this. It counts an
|
||||
* op before resetting at a yield point, so at most 499 ops may follow a
|
||||
* yield point up to and including the next one.
|
||||
*/
|
||||
const val CONTACTS_OPS_PER_YIELD_POINT = 500
|
||||
|
||||
/** JS numbers beyond this are not exact integers any more. */
|
||||
private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0
|
||||
|
||||
fun parseQuery(authority: String, json: String): ProviderQuery {
|
||||
val o = parseObject(json, "query")
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
val rawColumns = o.opt("columns") as? JSONArray ?: throw invalid("query needs a columns array")
|
||||
val columns = (0 until rawColumns.length()).map { i ->
|
||||
rawColumns.opt(i) as? String ?: throw invalid("columns[$i] must be a string")
|
||||
}
|
||||
if (columns.isEmpty()) throw invalid("query needs at least one column")
|
||||
val (where, args) = o.whereArgs("query")
|
||||
val orderBy = o.optionalString("orderBy")?.also { SqlFragments.check(it, "query: orderBy") }
|
||||
return ProviderQuery(table, columns, where, args, orderBy)
|
||||
}
|
||||
|
||||
fun parseBatch(authority: String, json: String): List<ProviderOp> {
|
||||
val array = try {
|
||||
JSONArray(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("ops are not a JSON array: ${e.message}")
|
||||
}
|
||||
val ops = (0 until array.length()).map { i ->
|
||||
val o = array.opt(i) as? JSONObject ?: throw invalid("op $i is not an object")
|
||||
parseOp(authority, i, o)
|
||||
}
|
||||
checkSyncState(ops)
|
||||
checkBackReferences(ops)
|
||||
if (authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) checkYieldPoints(ops)
|
||||
return ops
|
||||
}
|
||||
|
||||
private fun parseOp(authority: String, index: Int, o: JSONObject): ProviderOp {
|
||||
val where = "op $index"
|
||||
val kind = o.opt("op") as? String ?: throw invalid("$where has no op")
|
||||
val yieldAllowed = o.optBoolean("yieldAllowed", false)
|
||||
if (kind == "syncState") {
|
||||
val value = o.opt("value") as? String ?: throw invalid("$where: syncState needs a string value")
|
||||
return ProviderOp.SetSyncState(value, yieldAllowed)
|
||||
}
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
return when (kind) {
|
||||
"insert" -> ProviderOp.Insert(table, o.writeValues(table, where), o.refs(where), yieldAllowed)
|
||||
"update" -> {
|
||||
val values = o.writeValues(table, where)
|
||||
if (values.isEmpty()) throw invalid("$where: an update needs values")
|
||||
ProviderOp.Update(table, o.target(table, where), values, o.expectCount(where), yieldAllowed)
|
||||
}
|
||||
"delete" -> ProviderOp.Delete(table, o.target(table, where), o.expectCount(where), yieldAllowed)
|
||||
"assert" -> {
|
||||
val target = o.target(table, where)
|
||||
val values = o.assertValues(where)
|
||||
val expectCount = o.expectCount(where)
|
||||
if (values.isNullOrEmpty() && expectCount == null && target.id == null) {
|
||||
throw invalid("$where: an assert needs values, an expected count or an id")
|
||||
}
|
||||
ProviderOp.Assert(table, target, values?.takeIf { it.isNotEmpty() }, expectCount, yieldAllowed)
|
||||
}
|
||||
else -> throw invalid("$where: unknown op $kind")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state describes the rows of its batch, so it must commit with them:
|
||||
* as the last op, and not at a yield point, where the provider may commit
|
||||
* everything before it on its own.
|
||||
*/
|
||||
private fun checkSyncState(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.SetSyncState) return@forEachIndexed
|
||||
if (i != ops.lastIndex) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must be the last op of its batch")
|
||||
}
|
||||
if (op.yieldAllowed) throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must not be a yield point")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBackReferences(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.Insert) return@forEachIndexed
|
||||
for ((column, ref) in op.refs) {
|
||||
val target = ops.getOrNull(ref)
|
||||
if (ref >= i || target !is ProviderOp.Insert || !target.table.insertReturnsId) {
|
||||
throw invalid("op $i: $column refers to op $ref, which is not an earlier insert with a row id")
|
||||
}
|
||||
val expected = referencedTable(op.table, column, op.values)
|
||||
if (expected != null && target.table != expected) {
|
||||
throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: $column must refer to a ${expected.wire} insert, not ${target.table.wire}",
|
||||
)
|
||||
}
|
||||
}
|
||||
val parentColumn = op.table.parentColumn ?: return@forEachIndexed
|
||||
val named = parentColumn in op.refs || (op.values[parentColumn] ?: Cell.Null) != Cell.Null
|
||||
if (!named) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: a ${op.table.wire} insert must name its $parentColumn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkYieldPoints(ops: List<ProviderOp>) {
|
||||
var count = 0
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (++count >= CONTACTS_OPS_PER_YIELD_POINT) {
|
||||
throw invalid(
|
||||
"op $i: more than ${CONTACTS_OPS_PER_YIELD_POINT - 1} ops without a yield point " +
|
||||
"(ContactsProvider refuses the batch)",
|
||||
)
|
||||
}
|
||||
if (i > 0 && op.yieldAllowed) count = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers ────────────────────────────────────────────
|
||||
|
||||
private fun invalid(message: String) = ProviderRefusal(BatchFailure.PROVIDER, message)
|
||||
|
||||
private fun parseObject(json: String, what: String): JSONObject = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("$what is not a JSON object: ${e.message}")
|
||||
}
|
||||
|
||||
private fun JSONObject.requireString(key: String): String =
|
||||
opt(key) as? String ?: throw invalid("$key must be a string")
|
||||
|
||||
private fun JSONObject.optionalString(key: String): String? = when (val v = opt(key)) {
|
||||
null, JSONObject.NULL -> null
|
||||
is String -> v.takeIf { it.isNotBlank() }
|
||||
else -> throw invalid("$key must be a string")
|
||||
}
|
||||
|
||||
private fun JSONObject.target(table: ProviderTable, where: String): RowTarget {
|
||||
val id = when (val raw = opt("id")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: id") as? Cell.Integer)?.value?.takeIf { it >= 0 }
|
||||
?: throw invalid("$where: id must be a row id")
|
||||
}
|
||||
if (id != null && table.idColumn == null) throw invalid("$where: ${table.wire} rows have no id")
|
||||
val (sql, args) = whereArgs(where)
|
||||
return RowTarget(id, sql, args)
|
||||
}
|
||||
|
||||
private fun JSONObject.whereArgs(where: String): Pair<String?, List<String>> {
|
||||
val sql = optionalString("where")?.also { SqlFragments.check(it, "$where: where") }
|
||||
val args = when (val raw = opt("args")) {
|
||||
null, JSONObject.NULL -> emptyList()
|
||||
is JSONArray -> (0 until raw.length()).map { i ->
|
||||
scalar(raw.opt(i), "$where: args[$i]").text()
|
||||
?: throw invalid("$where: args[$i] must be a string or a number")
|
||||
}
|
||||
else -> throw invalid("$where: args must be an array")
|
||||
}
|
||||
if (sql == null && args.isNotEmpty()) throw invalid("$where: args without a where")
|
||||
return sql to args
|
||||
}
|
||||
|
||||
private fun JSONObject.expectCount(where: String): Int? = when (val raw = opt("expectCount")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: expectCount") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }?.toInt()
|
||||
?: throw invalid("$where: expectCount must be a count")
|
||||
}
|
||||
|
||||
private fun JSONObject.refs(where: String): Map<String, Int> {
|
||||
val raw = opt("refs") ?: return emptyMap()
|
||||
if (raw == JSONObject.NULL) return emptyMap()
|
||||
if (raw !is JSONObject) throw invalid("$where: refs must be an object")
|
||||
val out = LinkedHashMap<String, Int>()
|
||||
for (column in raw.keys()) {
|
||||
val index = (scalar(raw.opt(column), "$where: refs.$column") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }
|
||||
?: throw invalid("$where: refs.$column must be an op index")
|
||||
out[column] = index.toInt()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.writeValues(table: ProviderTable, where: String): Map<String, Cell> {
|
||||
val raw = opt("values") as? JSONObject ?: throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) {
|
||||
val v = raw.opt(column)
|
||||
out[column] = if (v is JSONObject) blob(table, column, v, where) else scalar(v, "$where: $column")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.assertValues(where: String): Map<String, Cell>? {
|
||||
val raw = opt("values") ?: return null
|
||||
if (raw == JSONObject.NULL) return null
|
||||
if (raw !is JSONObject) throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) out[column] = scalar(raw.opt(column), "$where: $column")
|
||||
return out
|
||||
}
|
||||
|
||||
/** Only photo rows carry bytes (`data.data15`); the provider stores and scales them itself. */
|
||||
private fun blob(table: ProviderTable, column: String, v: JSONObject, where: String): Cell.Blob {
|
||||
if (table != ProviderTable.DATA || column != "data15") {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$where: bytes are only accepted for data.data15, not ${table.wire}.$column")
|
||||
}
|
||||
val base64 = v.opt("b64") as? String ?: throw invalid("$where: $column needs { b64 }")
|
||||
return Cell.Blob(base64)
|
||||
}
|
||||
|
||||
private fun scalar(v: Any?, what: String): Cell = when (v) {
|
||||
null, JSONObject.NULL -> Cell.Null
|
||||
is String -> Cell.Text(v)
|
||||
is Boolean -> Cell.Integer(if (v) 1 else 0)
|
||||
is Number -> numberCell(v)
|
||||
else -> throw invalid("$what must be text, a number or null")
|
||||
}
|
||||
|
||||
/** org.json yields Integer, Long, Double, BigInteger or BigDecimal depending on the platform and the value. */
|
||||
internal fun numberCell(n: Number): Cell = when (n) {
|
||||
is Int, is Long, is Short, is Byte -> Cell.Integer(n.toLong())
|
||||
is BigInteger -> if (n.bitLength() < 64) Cell.Integer(n.toLong()) else Cell.Real(n.toDouble())
|
||||
is BigDecimal -> try {
|
||||
Cell.Integer(n.longValueExact())
|
||||
} catch (e: ArithmeticException) {
|
||||
Cell.Real(n.toDouble())
|
||||
}
|
||||
else -> {
|
||||
val d = n.toDouble()
|
||||
if (d.isFinite() && d == Math.rint(d) && abs(d) <= MAX_SAFE_INTEGER) Cell.Integer(d.toLong()) else Cell.Real(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Plans provider calls for one account: every selection gets the account
|
||||
* condition ANDed in, and every row an operation names by id, or points to
|
||||
* from a written column, is checked to belong to the account first
|
||||
* (docs/device-sync.md, "Native module API", Scoping).
|
||||
*
|
||||
* How a table is tied to the account ([TableScope]):
|
||||
* - raw contacts, groups, settings, calendars and colors carry it in their
|
||||
* `account_name`/`account_type` columns;
|
||||
* - contacts data rows are filtered by ContactsProvider itself through the
|
||||
* `account_name`/`account_type` URI parameters. Their account columns are
|
||||
* never selected on: Android 17 drops them from the Data view for apps
|
||||
* targeting more than API 36;
|
||||
* - events get `calendar_id IN (…)` of the account's calendars, not the
|
||||
* account columns: CalendarProvider updates events on the Events table,
|
||||
* which has none;
|
||||
* - attendees, reminders and extended properties get `event_id IN (…)` of the
|
||||
* account's events among those the operation touches. CalendarProvider
|
||||
* applies no account filter to them.
|
||||
*
|
||||
* Selections never use subqueries: the providers may compile them strictly.
|
||||
*/
|
||||
class ScopePlanner(private val account: AccountRef, private val lookup: ScopeLookup) {
|
||||
private val calendarIds: Set<Long> by lazy { lookup.ownCalendarIds() }
|
||||
|
||||
fun planQuery(query: ProviderQuery): PlannedQuery {
|
||||
val target = RowTarget(null, query.where, query.args)
|
||||
val selections = if (query.table.scope == TableScope.EVENT_IDS) {
|
||||
// Mostly a handful of events (the engine reads children per chunk
|
||||
// of events); a query over all of them is split so no selection
|
||||
// outgrows a Binder transaction. Rows are then ordered per chunk.
|
||||
ownEventsMatching(query.table, target)
|
||||
.sorted()
|
||||
.chunked(ID_LIST_CHUNK)
|
||||
.map { chunk -> selection(query.table, target, chunk) }
|
||||
} else {
|
||||
listOf(selection(query.table, target, null))
|
||||
}
|
||||
return PlannedQuery(query.table, query.columns, selections, query.orderBy)
|
||||
}
|
||||
|
||||
fun planBatch(ops: List<ProviderOp>): List<PlannedOp> {
|
||||
val memberships = membershipUpdates(ops)
|
||||
val owners = Owners(ownershipRequests(ops, memberships))
|
||||
return ops.mapIndexed { i, op -> plan(i, op, owners, memberships) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership rows among the data rows that updates by id write `data1`
|
||||
* of without restating the mimetype: their `data1` names a group, which
|
||||
* must be the account's like on insert.
|
||||
*/
|
||||
private fun membershipUpdates(ops: List<ProviderOp>): Set<Long> {
|
||||
val ids = ops.asSequence()
|
||||
.filterIsInstance<ProviderOp.Update>()
|
||||
.filter { it.table == ProviderTable.DATA && writesUntypedData1(it.values) }
|
||||
.mapNotNull { it.target.id }
|
||||
.toSet()
|
||||
if (ids.isEmpty()) return emptySet()
|
||||
return lookup.dataMimetypes(ids).filterValues { it == GROUP_MEMBERSHIP_MIMETYPE }.keys
|
||||
}
|
||||
|
||||
private fun writesUntypedData1(values: Map<String, Cell>) =
|
||||
MIMETYPE !in values && (values[DATA1] ?: Cell.Null) != Cell.Null
|
||||
|
||||
private fun plan(i: Int, op: ProviderOp, owners: Owners, memberships: Set<Long>): PlannedOp = when (op) {
|
||||
is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value)
|
||||
is ProviderOp.Insert -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkPhotoFile(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values - op.refs.keys, owners)
|
||||
val values = if (op.table.scope == TableScope.ACCOUNT_COLUMNS) op.values + accountCells() else op.values
|
||||
PlannedOp.Insert(op.table, values, op.refs, op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Update -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkPhotoFile(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values, owners)
|
||||
if (op.table == ProviderTable.DATA && writesUntypedData1(op.values)) {
|
||||
checkMembershipUpdate(i, op, owners, memberships)
|
||||
}
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
if (op.table == ProviderTable.EXTENDED_PROPERTIES) {
|
||||
// CalendarProvider updates extended properties only through
|
||||
// their item URI, which takes no selection: the id check above
|
||||
// is the scope.
|
||||
val id = op.target.id
|
||||
if (id == null || op.target.where != null) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: extended properties can only be updated by id")
|
||||
}
|
||||
PlannedOp.Update(op.table, Selection.NONE, id, op.values, op.expectCount ?: 1, op.yieldAllowed)
|
||||
} else {
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Update(op.table, selection, null, op.values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
is ProviderOp.Delete -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Delete(op.table, selection, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Assert -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
val values = op.values?.mapValues { (_, cell) -> cell.text() }
|
||||
PlannedOp.Assert(op.table, selection, values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
/** An op that names one row by id expects exactly that row: the engine relies on it to notice a vanished row. */
|
||||
private fun expectedCount(target: RowTarget, expectCount: Int?): Int? =
|
||||
expectCount ?: if (target.id != null) 1 else null
|
||||
|
||||
private fun accountCells() = mapOf(
|
||||
ACCOUNT_NAME to Cell.Text(account.name),
|
||||
ACCOUNT_TYPE to Cell.Text(account.type),
|
||||
)
|
||||
|
||||
/**
|
||||
* Account columns may only restate the account, on the tables that have
|
||||
* them; a data row, event or attendee follows its parent's account.
|
||||
*/
|
||||
private fun checkAccountColumns(i: Int, table: ProviderTable, values: Map<String, Cell>) {
|
||||
for (column in listOf(ACCOUNT_NAME, ACCOUNT_TYPE, DATA_SET)) {
|
||||
val cell = values[column] ?: continue
|
||||
val restatesAccount = table.scope == TableScope.ACCOUNT_COLUMNS && when (column) {
|
||||
ACCOUNT_NAME -> cell == Cell.Text(account.name)
|
||||
ACCOUNT_TYPE -> cell == Cell.Text(account.type)
|
||||
else -> cell == Cell.Null
|
||||
}
|
||||
if (!restatesAccount) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column would leave the account")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parents, masters and groups a written column names must be rows of the
|
||||
* account that still exist. (Back-references were checked by the parser:
|
||||
* they name inserts of the same batch.)
|
||||
*/
|
||||
private fun checkReferences(i: Int, table: ProviderTable, values: Map<String, Cell>, owners: Owners) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
if (cell == Cell.Null) {
|
||||
if (column == table.parentColumn) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column must name a ${referenced.wire} row")
|
||||
}
|
||||
continue
|
||||
}
|
||||
val id = cell.rowId()
|
||||
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: ${table.wire}.$column must be a row id")
|
||||
when (owners.of(referenced, id)) {
|
||||
Owner.OURS -> Unit
|
||||
Owner.FOREIGN -> throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id of another account",
|
||||
)
|
||||
// Deleted meanwhile, as far as the engine can tell: re-read and re-plan.
|
||||
Owner.MISSING -> throw ProviderRefusal(
|
||||
BatchFailure.ASSERT,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id, which no longer exists",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `data1` written by an update that doesn't restate the mimetype: on a
|
||||
* membership row it names a group. Rows chosen by a selection could be
|
||||
* memberships the lookup didn't see, so only updates by id may do it.
|
||||
*/
|
||||
private fun checkMembershipUpdate(i: Int, op: ProviderOp.Update, owners: Owners, memberships: Set<Long>) {
|
||||
val id = op.target.id
|
||||
?: throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data1 may only be written to a data row by id")
|
||||
if (id !in memberships) return
|
||||
val group = op.values[DATA1]?.rowId()
|
||||
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: a membership's data1 must be a group id")
|
||||
when (owners.of(ProviderTable.GROUPS, group)) {
|
||||
Owner.OURS -> Unit
|
||||
Owner.FOREIGN -> throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data1 names group $group of another account")
|
||||
Owner.MISSING -> throw ProviderRefusal(BatchFailure.ASSERT, "op $i: data.data1 names group $group, which no longer exists")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo row's file id (`data14`) is the provider's: it points into the
|
||||
* photo store, where another account's file id would let `readPhoto`
|
||||
* read that account's photo. Photos are written as bytes (`data15`).
|
||||
*/
|
||||
private fun checkPhotoFile(i: Int, table: ProviderTable, values: Map<String, Cell>) {
|
||||
if (table == ProviderTable.DATA && (values[DATA14] ?: Cell.Null) != Cell.Null) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data14 (a photo file id) is written by the provider only")
|
||||
}
|
||||
}
|
||||
|
||||
/** A row named by id must not belong to another account; a missing one fails on the expected count instead. */
|
||||
private fun checkTarget(i: Int, table: ProviderTable, target: RowTarget, owners: Owners) {
|
||||
val id = target.id ?: return
|
||||
if (owners.of(table, id) == Owner.FOREIGN) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire} $id belongs to another account")
|
||||
}
|
||||
}
|
||||
|
||||
/** For attendees, reminders and extended properties: the account's events the op may touch. */
|
||||
private fun eventScope(table: ProviderTable, target: RowTarget, owners: Owners): Collection<Long>? {
|
||||
if (table.scope != TableScope.EVENT_IDS) return null
|
||||
val id = target.id ?: return ownEventsMatching(table, target)
|
||||
return listOfNotNull(owners.eventOf(table, id))
|
||||
}
|
||||
|
||||
private fun ownEventsMatching(table: ProviderTable, target: RowTarget): Set<Long> {
|
||||
val candidates = lookup.childEventIdsMatching(table, target.where, target.args)
|
||||
if (candidates.isEmpty()) return emptySet()
|
||||
return lookup.eventCalendars(candidates).filterValues { it in calendarIds }.keys
|
||||
}
|
||||
|
||||
private fun selection(table: ProviderTable, target: RowTarget, eventIds: Collection<Long>?): Selection {
|
||||
val parts = ArrayList<String>(3)
|
||||
val args = ArrayList<String>()
|
||||
target.id?.let {
|
||||
parts += "${table.idColumn} = ?"
|
||||
args += it.toString()
|
||||
}
|
||||
target.where?.let {
|
||||
parts += it
|
||||
args += target.args
|
||||
}
|
||||
when (table.scope) {
|
||||
TableScope.ACCOUNT_COLUMNS -> {
|
||||
parts += "$ACCOUNT_NAME = ? AND $ACCOUNT_TYPE = ?"
|
||||
args += account.name
|
||||
args += account.type
|
||||
}
|
||||
TableScope.URI_PARAMETERS -> Unit
|
||||
TableScope.CALENDAR_IDS -> parts += idList("calendar_id", calendarIds)
|
||||
TableScope.EVENT_IDS -> parts += idList("event_id", eventIds.orEmpty())
|
||||
}
|
||||
return Selection(parts.takeIf { it.isNotEmpty() }?.joinToString(" AND ") { "($it)" }, args)
|
||||
}
|
||||
|
||||
/** Every row id the batch names, by table: op targets and referenced rows. */
|
||||
private fun ownershipRequests(ops: List<ProviderOp>, memberships: Set<Long>): Map<ProviderTable, Set<Long>> {
|
||||
val requests = HashMap<ProviderTable, MutableSet<Long>>()
|
||||
fun request(table: ProviderTable, id: Long) {
|
||||
requests.getOrPut(table) { HashSet() } += id
|
||||
}
|
||||
fun requestReferences(table: ProviderTable, values: Map<String, Cell>) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
cell.rowId()?.let { request(referenced, it) }
|
||||
}
|
||||
}
|
||||
for (op in ops) {
|
||||
when (op) {
|
||||
is ProviderOp.Insert -> requestReferences(op.table, op.values)
|
||||
is ProviderOp.Update -> {
|
||||
requestReferences(op.table, op.values)
|
||||
op.target.id?.let { request(op.table, it) }
|
||||
if (op.target.id in memberships) op.values[DATA1]?.rowId()?.let { request(ProviderTable.GROUPS, it) }
|
||||
}
|
||||
is ProviderOp.Delete -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.Assert -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.SetSyncState -> Unit
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
private enum class Owner { OURS, FOREIGN, MISSING }
|
||||
|
||||
/** Who owns the rows a batch names, resolved up front with as few lookups as the tables allow. */
|
||||
private inner class Owners(requests: Map<ProviderTable, Set<Long>>) {
|
||||
private val owners = HashMap<ProviderTable, Map<Long, Owner>>()
|
||||
private val childEvents = HashMap<ProviderTable, Map<Long, Long>>()
|
||||
|
||||
init {
|
||||
// Children lead to events, data rows to raw contacts; both are
|
||||
// added to their parent's lookup.
|
||||
val eventIds = HashSet(requests[ProviderTable.EVENTS].orEmpty())
|
||||
for (table in CHILD_TABLES) {
|
||||
val ids = requests[table] ?: continue
|
||||
val events = lookup.childEvents(table, ids)
|
||||
childEvents[table] = events
|
||||
eventIds += events.values
|
||||
}
|
||||
val rawContactIds = HashSet(requests[ProviderTable.RAW_CONTACTS].orEmpty())
|
||||
val dataParents = requests[ProviderTable.DATA]?.let { lookup.dataRawContacts(it) }.orEmpty()
|
||||
rawContactIds += dataParents.values
|
||||
|
||||
if (eventIds.isNotEmpty()) {
|
||||
owners[ProviderTable.EVENTS] = lookup.eventCalendars(eventIds)
|
||||
.mapValues { (_, calendarId) -> if (calendarId in calendarIds) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
for ((table, events) in childEvents) {
|
||||
// A child whose event is gone is an orphan, not ours.
|
||||
owners[table] = events.mapValues { (_, eventId) -> ownerOrForeign(ProviderTable.EVENTS, eventId) }
|
||||
}
|
||||
val roots = HashMap<ProviderTable, Set<Long>>()
|
||||
for (table in ROOT_TABLES) requests[table]?.let { roots[table] = it }
|
||||
if (rawContactIds.isNotEmpty()) roots[ProviderTable.RAW_CONTACTS] = rawContactIds
|
||||
for ((table, ids) in roots) {
|
||||
owners[table] = lookup.rootAccounts(table, ids)
|
||||
.mapValues { (_, owner) -> if (owner == account) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
if (dataParents.isNotEmpty()) {
|
||||
owners[ProviderTable.DATA] = dataParents.mapValues { (_, rawContactId) ->
|
||||
ownerOrForeign(ProviderTable.RAW_CONTACTS, rawContactId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownerOrForeign(table: ProviderTable, id: Long): Owner =
|
||||
owners[table]?.get(id)?.takeIf { it != Owner.MISSING } ?: Owner.FOREIGN
|
||||
|
||||
fun of(table: ProviderTable, id: Long): Owner = owners[table]?.get(id) ?: Owner.MISSING
|
||||
|
||||
/** The event of a child row, when both exist and are the account's. */
|
||||
fun eventOf(table: ProviderTable, id: Long): Long? =
|
||||
childEvents[table]?.get(id)?.takeIf { of(table, id) == Owner.OURS }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_NAME = "account_name"
|
||||
const val ACCOUNT_TYPE = "account_type"
|
||||
const val DATA_SET = "data_set"
|
||||
private const val MIMETYPE = "mimetype"
|
||||
private const val DATA1 = "data1"
|
||||
private const val DATA14 = "data14"
|
||||
|
||||
/** Ids per `IN (…)` list: a few KB of selection, far from SQLite's and Binder's limits. */
|
||||
const val ID_LIST_CHUNK = 500
|
||||
|
||||
private val CHILD_TABLES = ProviderTable.entries.filter { it.scope == TableScope.EVENT_IDS }
|
||||
private val ROOT_TABLES = ProviderTable.entries.filter { it.scope == TableScope.ACCOUNT_COLUMNS }
|
||||
|
||||
/** `column IN (1,2,3)`, or a condition no row meets. The ids are ours to inline: numbers we read. */
|
||||
fun idList(column: String, ids: Collection<Long>): String =
|
||||
if (ids.isEmpty()) "0" else ids.sorted().joinToString(",", "$column IN (", ")")
|
||||
}
|
||||
}
|
||||
|
||||
/** An Android account of our type: the scope of every provider call. */
|
||||
data class AccountRef(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* What [ScopePlanner] needs to know about rows beyond the operations
|
||||
* themselves. [ProviderIo] answers with provider queries: except for
|
||||
* [ownCalendarIds], these read rows of any account, but only their ids and
|
||||
* owners, to tell rows of another account from rows that are gone.
|
||||
*/
|
||||
interface ScopeLookup {
|
||||
/** `_id`s of the account's calendars. */
|
||||
fun ownCalendarIds(): Set<Long>
|
||||
|
||||
/** The account of each row of a root table that exists. */
|
||||
fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef>
|
||||
|
||||
/** The raw contact of each data row that exists. */
|
||||
fun dataRawContacts(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The mimetype of each data row that exists. */
|
||||
fun dataMimetypes(ids: Set<Long>): Map<Long, String>
|
||||
|
||||
/** The calendar of each event that exists. */
|
||||
fun eventCalendars(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The event of each attendee, reminder or extended property that exists. */
|
||||
fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The events of the rows of a child table that match [where]. */
|
||||
fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long>
|
||||
}
|
||||
|
||||
/** A selection with its `?` arguments; a null [sql] selects every row the URI allows. */
|
||||
data class Selection(val sql: String?, val args: List<String>) {
|
||||
companion object {
|
||||
val NONE = Selection(null, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider query, split into [selections] when its scope list is long; no selection means no rows. */
|
||||
data class PlannedQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val selections: List<Selection>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One ContentProviderOperation, ready to build on the account's sync-adapter URI. */
|
||||
sealed interface PlannedOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
val backReferences: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [itemId] set: an update through the row's own URI, without a selection. */
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val itemId: Long?,
|
||||
val values: Map<String, Cell>,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [values] compared as text, as ContentProviderOperation does. */
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val values: Map<String, String?>?,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class SetSyncState(val value: String) : PlannedOp {
|
||||
override val yieldAllowed: Boolean get() = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The result of one op, as `OpResult` of types.ts. */
|
||||
data class OpOutcome(val id: Long? = null, val count: Int? = null)
|
||||
|
||||
object ProviderResults {
|
||||
fun ok(results: List<OpOutcome>): String {
|
||||
val array = JSONArray()
|
||||
for (result in results) {
|
||||
array.put(JSONObject().apply {
|
||||
result.id?.let { put("id", it) }
|
||||
result.count?.let { put("count", it) }
|
||||
})
|
||||
}
|
||||
return JSONObject().put("ok", true).put("results", array).toString()
|
||||
}
|
||||
|
||||
fun failed(reason: BatchFailure, message: String?): String = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("reason", reason.wire)
|
||||
.put("message", message ?: reason.wire)
|
||||
.toString()
|
||||
|
||||
/**
|
||||
* An OperationApplicationException is an assert that did not hold or an
|
||||
* expected count that did not match (`assert`), except when an insert got
|
||||
* no row back or ContactsProvider refused the batch's yield points.
|
||||
*/
|
||||
fun failureOfOperationApplication(message: String?): BatchFailure = when {
|
||||
message == null -> BatchFailure.ASSERT
|
||||
message.startsWith("Insert into ") -> BatchFailure.PROVIDER
|
||||
message.startsWith("Too many content provider operations") -> BatchFailure.PROVIDER
|
||||
else -> BatchFailure.ASSERT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query results as `ProviderRows` JSON, built by hand: a chunk of contacts
|
||||
* can carry megabytes of shadows, and this is the hot path of every run.
|
||||
*/
|
||||
class ProviderRowsWriter(columns: List<String>) {
|
||||
private val out = StringBuilder(4096)
|
||||
private var rows = 0
|
||||
private var cells = 0
|
||||
|
||||
init {
|
||||
out.append("{\"columns\":[")
|
||||
columns.forEachIndexed { i, column ->
|
||||
if (i > 0) out.append(',')
|
||||
appendString(column)
|
||||
}
|
||||
out.append("],\"rows\":[")
|
||||
}
|
||||
|
||||
fun beginRow() {
|
||||
if (rows++ > 0) out.append(',')
|
||||
out.append('[')
|
||||
cells = 0
|
||||
}
|
||||
|
||||
fun endRow() {
|
||||
out.append(']')
|
||||
}
|
||||
|
||||
fun nullCell() = cell { out.append("null") }
|
||||
|
||||
fun longCell(value: Long) = cell { out.append(value) }
|
||||
|
||||
fun doubleCell(value: Double) = cell { if (value.isFinite()) out.append(value) else out.append("null") }
|
||||
|
||||
fun textCell(value: String) = cell { appendString(value) }
|
||||
|
||||
fun finish(): String = out.append("]}").toString()
|
||||
|
||||
private inline fun cell(write: () -> Unit) {
|
||||
if (cells++ > 0) out.append(',')
|
||||
write()
|
||||
}
|
||||
|
||||
private fun appendString(s: String) {
|
||||
out.append('"')
|
||||
for (c in s) {
|
||||
when {
|
||||
c == '"' -> out.append("\\\"")
|
||||
c == '\\' -> out.append("\\\\")
|
||||
c == '\n' -> out.append("\\n")
|
||||
c == '\r' -> out.append("\\r")
|
||||
c == '\t' -> out.append("\\t")
|
||||
c < ' ' || c.code == 0x2028 || c.code == 0x2029 ->
|
||||
out.append("\\u").append(Integer.toHexString(c.code).padStart(4, '0'))
|
||||
else -> out.append(c)
|
||||
}
|
||||
}
|
||||
out.append('"')
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes for [ProviderIo.readPhoto]: decode close to the target, then scale the longer side down to it. */
|
||||
object PhotoScaling {
|
||||
/** The largest power of two that keeps the decoded image at least [maxPx] on its longer side. */
|
||||
fun sampleSize(width: Int, height: Int, maxPx: Int): Int {
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
var sample = 1
|
||||
val longer = maxOf(width, height)
|
||||
while (longer / (sample * 2) >= maxPx) sample *= 2
|
||||
return sample
|
||||
}
|
||||
|
||||
fun targetSize(width: Int, height: Int, maxPx: Int): Pair<Int, Int> {
|
||||
val longer = maxOf(width, height)
|
||||
if (longer <= maxPx) return width to height
|
||||
val scale = maxPx.toDouble() / longer
|
||||
return maxOf(1, Math.round(width * scale).toInt()) to maxOf(1, Math.round(height * scale).toInt())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a push asks for (#34): syncs of the Android accounts that contacts and
|
||||
* calendar changes feed, and whether the mail push task should run at all.
|
||||
*
|
||||
* The relay forwards the JMAP StateChange's `changed` map verbatim (FCM data
|
||||
* key `changed`: JMAP account id → type → state; only its first account is
|
||||
* repeated as `accountId`), so every account key is looked at. The mail task
|
||||
* runs only for mail: when some account's map has a mail type, for anything
|
||||
* but a StateChange (an EmailPush), when `changed` is missing, unreadable or
|
||||
* names no type (older relays), or when an account's entry is unreadable, so
|
||||
* a contact change never ends in a mail notification. The push task's guard
|
||||
* (`carriesNoMail` in src/lib/push-background-task.ts) applies the same rules.
|
||||
*/
|
||||
object PushRouting {
|
||||
/** An Android account and the authorities it syncs, fed by one JMAP account. */
|
||||
data class Route(val accountName: String, val authorities: Set<String>)
|
||||
|
||||
data class Decision(
|
||||
/** (Android account name, authority) pairs to request a sync for. */
|
||||
val syncs: Set<Pair<String, String>>,
|
||||
val startMailTask: Boolean,
|
||||
)
|
||||
|
||||
private const val STATE_CHANGE_KIND = "jmap-state-change"
|
||||
|
||||
/**
|
||||
* Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`,
|
||||
* which new mail changes too. A subscription made by an older build
|
||||
* listens to them until the launch-time resync replaces its types, and a
|
||||
* server without `EmailDelivery` has only them. Keep in sync with
|
||||
* MAIL_TYPES in src/lib/push-background-task.ts.
|
||||
*/
|
||||
private val MAIL_TYPES = setOf("EmailDelivery", "Email", "Mailbox")
|
||||
private val CONTACT_TYPES = setOf("ContactCard", "AddressBook")
|
||||
private val CALENDAR_TYPES = setOf("CalendarEvent", "Calendar")
|
||||
|
||||
fun decide(data: Map<String, String>, routes: Map<String, List<Route>>): Decision {
|
||||
val changed = data["changed"]?.let(::parseChanged)
|
||||
// Only a StateChange can say it carries no mail.
|
||||
var mail = data["kind"] != STATE_CHANGE_KIND || changed == null
|
||||
val syncs = LinkedHashSet<Pair<String, String>>()
|
||||
for ((jmapAccountId, types) in changed.orEmpty()) {
|
||||
if (types == null) {
|
||||
// One account's entry is unreadable: it may be mail.
|
||||
mail = true
|
||||
continue
|
||||
}
|
||||
if (types.any { it in MAIL_TYPES }) mail = true
|
||||
val contacts = types.any { it in CONTACT_TYPES }
|
||||
val calendar = types.any { it in CALENDAR_TYPES }
|
||||
for (route in routes[jmapAccountId].orEmpty()) {
|
||||
if (contacts && DeviceSyncAccounts.CONTACTS_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||
}
|
||||
if (calendar && DeviceSyncAccounts.CALENDAR_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||
}
|
||||
}
|
||||
}
|
||||
return Decision(syncs, mail)
|
||||
}
|
||||
|
||||
/**
|
||||
* JMAP account id → its changed types (null for an entry that is not an
|
||||
* object). Null for a map that is unreadable or names no type at all,
|
||||
* which the relay sends as `{}` when it had no map.
|
||||
*/
|
||||
private fun parseChanged(json: String): Map<String, Set<String>?>? {
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
return null
|
||||
}
|
||||
val out = LinkedHashMap<String, Set<String>?>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val types = o.opt(jmapAccountId) as? JSONObject
|
||||
out[jmapAccountId] = types?.keys()?.asSequence()?.toSet()
|
||||
}
|
||||
return out.takeIf { entries -> entries.values.any { it == null || it.isNotEmpty() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ jmapAccountId: [{ accountName, authorities }] }` as JS stores it.
|
||||
* [strict] refuses anything malformed (IllegalArgumentException); otherwise
|
||||
* malformed entries are skipped, so a bad route never stops mail.
|
||||
*/
|
||||
fun parseRoutes(json: String?, strict: Boolean = false): Map<String, List<Route>> {
|
||||
if (json.isNullOrEmpty()) return emptyMap()
|
||||
fun bad(message: String): Nothing = throw IllegalArgumentException("Push routes: $message")
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
if (strict) bad("not a JSON object") else return emptyMap()
|
||||
}
|
||||
val out = LinkedHashMap<String, List<Route>>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val entries = o.opt(jmapAccountId) as? JSONArray
|
||||
?: if (strict) bad("$jmapAccountId must map to an array") else continue
|
||||
val routes = ArrayList<Route>()
|
||||
for (i in 0 until entries.length()) {
|
||||
val entry = entries.opt(i) as? JSONObject
|
||||
val name = entry?.opt("accountName") as? String
|
||||
val authorities = entry?.opt("authorities") as? JSONArray
|
||||
if (name.isNullOrEmpty() || authorities == null) {
|
||||
if (strict) bad("$jmapAccountId[$i] needs accountName and authorities") else continue
|
||||
}
|
||||
val known = (0 until authorities.length()).mapNotNull { j ->
|
||||
val authority = authorities.opt(j) as? String
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
if (strict) bad("$jmapAccountId[$i]: unknown authority $authority") else null
|
||||
} else {
|
||||
authority
|
||||
}
|
||||
}
|
||||
routes += Route(name, known.toSet())
|
||||
}
|
||||
out[jmapAccountId] = routes
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies [PushRouting] to incoming pushes (BulwarkMessagingService,
|
||||
* BulwarkUnifiedPushService) with the routes JS stored through
|
||||
* `setPushRoutes`.
|
||||
*/
|
||||
object DeviceSyncPushRouter {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
private const val PREFS = "bulwark_device_sync"
|
||||
private const val KEY_ROUTES = "pushRoutes"
|
||||
|
||||
/** Validates and stores the routes; they must survive the process, pushes arrive without JS. */
|
||||
fun saveRoutes(context: Context, json: String) {
|
||||
PushRouting.parseRoutes(json, strict = true)
|
||||
val saved = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_ROUTES, json)
|
||||
.commit()
|
||||
check(saved) { "Push routes could not be saved" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the syncs the push asks for, with constant (empty) extras so
|
||||
* repeated pushes coalesce into one pending sync. Returns whether the mail
|
||||
* push task should run; any failure here says yes, so mail never depends
|
||||
* on device sync.
|
||||
*/
|
||||
fun route(context: Context, data: Map<String, String>): Boolean = try {
|
||||
val routes = PushRouting.parseRoutes(
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ROUTES, null),
|
||||
)
|
||||
val decision = PushRouting.decide(data, routes)
|
||||
for ((accountName, authority) in decision.syncs) {
|
||||
try {
|
||||
DeviceSyncAccounts.requestSync(context, accountName, authority, SyncRequestFlags())
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Push: no sync requested for $authority", e)
|
||||
}
|
||||
}
|
||||
decision.startMailTask
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Push not routed", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.anonymous.bulwarkmobile.MainActivity
|
||||
import com.anonymous.bulwarkmobile.R
|
||||
|
||||
/**
|
||||
* The one notification per Android account that device sync needs the user
|
||||
* for (#34), e.g. "Sign in again" after the server rejected the credentials.
|
||||
* The engine posts it through `showSyncProblem`; the app clears it once the
|
||||
* problem is solved. Tapping it opens a `bulwarkmobile://` deep link in the
|
||||
* app.
|
||||
*/
|
||||
object SyncProblemNotifier {
|
||||
const val CHANNEL_ID = "bulwark_sync"
|
||||
private const val DEFAULT_CHANNEL_NAME = "Sync problems"
|
||||
private const val TAG_PREFIX = "bulwark-sync:"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val DEEP_LINK_SCHEME = "bulwarkmobile"
|
||||
|
||||
/**
|
||||
* Creates the channel, or renames it: JS passes the name in the app's
|
||||
* language. Nothing urgent, so default importance and no vibration.
|
||||
*/
|
||||
fun ensureChannel(context: Context, name: String?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
name?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { enableVibration(false) }
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun show(context: Context, accountName: String, title: String, text: String, uri: String, channelName: String?) {
|
||||
val link = Uri.parse(uri)
|
||||
require(link.scheme == DEEP_LINK_SCHEME) { "Only $DEEP_LINK_SCHEME:// links open from a sync notification" }
|
||||
ensureChannel(context, channelName)
|
||||
val tag = TAG_PREFIX + accountName
|
||||
// Explicit, so no other app can answer the tap.
|
||||
val intent = Intent(Intent.ACTION_VIEW, link, context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pending = PendingIntent.getActivity(
|
||||
context,
|
||||
tag.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
// Runs repeat the same problem until it is solved; say it once.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
context.getSystemService(NotificationManager::class.java).notify(tag, NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun clear(context: Context, accountName: String) {
|
||||
context.getSystemService(NotificationManager::class.java).cancel(TAG_PREFIX + accountName, NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.SyncResult
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a run's report (src/device-sync/types.ts `RunReport`) asks of the
|
||||
* framework's SyncResult. Kept free of Android classes so JVM tests cover it;
|
||||
* [SyncReports.apply] copies it onto the real SyncResult.
|
||||
*
|
||||
* SyncManager treats io errors as soft (retry with back-off), auth errors,
|
||||
* database errors and too many deletions as hard (no retry until something
|
||||
* changes), and `fullSyncRequested` as "schedule another sync now".
|
||||
*/
|
||||
data class SyncOutcome(
|
||||
val ioErrors: Long = 0,
|
||||
val authErrors: Long = 0,
|
||||
val databaseError: Boolean = false,
|
||||
val tooManyDeletions: Boolean = false,
|
||||
val inserts: Long = 0,
|
||||
val updates: Long = 0,
|
||||
val deletes: Long = 0,
|
||||
val entries: Long = 0,
|
||||
val skipped: Long = 0,
|
||||
/** Epoch seconds; 0 when unset. */
|
||||
val delayUntil: Long = 0,
|
||||
val fullSyncRequested: Boolean = false,
|
||||
)
|
||||
|
||||
object SyncReports {
|
||||
/**
|
||||
* Translates a report. [reportJson] is null when the run ended without
|
||||
* one ([endReason] says why: JS never answered, React failed to start, …),
|
||||
* which is a soft error so the framework retries.
|
||||
*/
|
||||
fun outcome(reportJson: String?, endReason: String?): SyncOutcome {
|
||||
if (reportJson == null) return SyncOutcome(ioErrors = 1)
|
||||
val report = try {
|
||||
JSONObject(reportJson)
|
||||
} catch (e: JSONException) {
|
||||
return SyncOutcome(ioErrors = 1)
|
||||
}
|
||||
val stats = report.optJSONObject("stats")
|
||||
val down = stats?.optJSONObject("downloaded")
|
||||
val up = stats?.optJSONObject("uploaded")
|
||||
fun count(side: JSONObject?, key: String) = side?.optLong(key, 0) ?: 0
|
||||
val base = SyncOutcome(
|
||||
inserts = count(down, "created") + count(up, "created"),
|
||||
updates = count(down, "updated") + count(up, "updated"),
|
||||
deletes = count(down, "deleted") + count(up, "deleted"),
|
||||
entries = stats?.optLong("entries", 0) ?: 0,
|
||||
skipped = stats?.optLong("skipped", 0) ?: 0,
|
||||
delayUntil = report.optLong("delayUntil", 0),
|
||||
fullSyncRequested = report.optBoolean("moreRecordsToGet", false),
|
||||
)
|
||||
// SyncManager retries a failed sync at once, without back-off, when it
|
||||
// made progress (inserts, updates, or deletes without
|
||||
// tooManyDeletions). A hard error must therefore report none, or an
|
||||
// auth failure after some work would loop.
|
||||
val hard = base.copy(inserts = 0, updates = 0, deletes = 0)
|
||||
return when (report.optString("outcome")) {
|
||||
"ok", "cancelled", "disabled" -> base
|
||||
"auth" -> hard.copy(authErrors = 1)
|
||||
"permission", "unsupported", "safetyAbort" -> hard.copy(databaseError = true)
|
||||
"tooManyDeletions" -> hard.copy(
|
||||
tooManyDeletions = true,
|
||||
// Shown in the system's "too many deletions" notification.
|
||||
deletes = report.optJSONObject("tooManyDeletions")?.optLong("count", 0) ?: 0,
|
||||
)
|
||||
// "io", "internal" and anything a newer JS might send: retry, at
|
||||
// once if some work got done, else with back-off.
|
||||
else -> base.copy(ioErrors = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for logcat: the report's outcome, its message and how many
|
||||
* items failed. The reason a run failed is otherwise only in the report,
|
||||
* and JS warnings from a headless run don't reach logcat.
|
||||
*/
|
||||
fun summary(reportJson: String?): String {
|
||||
if (reportJson == null) return "no report"
|
||||
val report = try {
|
||||
JSONObject(reportJson)
|
||||
} catch (e: JSONException) {
|
||||
return "unreadable report"
|
||||
}
|
||||
val parts = mutableListOf("outcome=" + report.optString("outcome", "?"))
|
||||
val items = report.optJSONArray("itemErrors")?.length() ?: 0
|
||||
if (items > 0) parts += "itemErrors=$items"
|
||||
val message = report.optString("message", "")
|
||||
if (message.isNotEmpty()) parts += "message=" + message.take(MAX_LOGGED_MESSAGE)
|
||||
return parts.joinToString(" ")
|
||||
}
|
||||
|
||||
private const val MAX_LOGGED_MESSAGE = 300
|
||||
|
||||
fun apply(outcome: SyncOutcome, result: SyncResult) {
|
||||
result.stats.numIoExceptions += outcome.ioErrors
|
||||
result.stats.numAuthExceptions += outcome.authErrors
|
||||
result.stats.numInserts += outcome.inserts
|
||||
result.stats.numUpdates += outcome.updates
|
||||
result.stats.numDeletes += outcome.deletes
|
||||
result.stats.numEntries += outcome.entries
|
||||
result.stats.numSkippedEntries += outcome.skipped
|
||||
if (outcome.databaseError) result.databaseError = true
|
||||
if (outcome.tooManyDeletions) result.tooManyDeletions = true
|
||||
if (outcome.delayUntil > 0) result.delayUntil = outcome.delayUntil
|
||||
if (outcome.fullSyncRequested) result.fullSyncRequested = true
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -1,7 +1,7 @@
|
||||
<resources>
|
||||
<!-- Splash background, as `splash.backgroundColor` in app.config.js; the dark one is in values-night. -->
|
||||
<color name="splashscreen_background">#ffffff</color>
|
||||
<color name="iconBackground">#09090b</color>
|
||||
<color name="iconBackground">#db2d54</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#09090b</color>
|
||||
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
|
||||
<dimen name="widget_radius">22dp</dimen>
|
||||
<dimen name="widget_radius">16dp</dimen>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
<string name="app_name">Bulwark Mobile</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<!-- Account type label of device sync (#34), shown in Settings → Accounts. -->
|
||||
<string name="device_sync_account_label" translatable="false">Bulwark</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The "Bulwark" account type of device sync (#34); see docs/device-sync.md. -->
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/device_sync_account_type"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:smallIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/device_sync_account_label" />
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
How Contacts apps show and edit contacts of the Bulwark account (#34). The
|
||||
EditSchema lists exactly the data kinds device sync maps to JSContact
|
||||
(docs/device-sync.md, "Contacts mapping"); apps offer nothing else.
|
||||
|
||||
AOSP Contacts drops the whole account type when this schema breaks its rules
|
||||
(BaseAccountType): every kind with a type column needs <Type> children and
|
||||
the others must have none, name needs maxOccurs="1" and every supports*
|
||||
attribute true (supportsDisplayName for older releases), and photo,
|
||||
nickname, organization, note and group_membership need maxOccurs="1".
|
||||
A Type's maxOccurs limits that type only; birthday gets none unless given.
|
||||
-->
|
||||
<ContactsAccountType xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<EditSchema>
|
||||
<DataKind kind="name" maxOccurs="1"
|
||||
supportsDisplayName="true"
|
||||
supportsPrefix="true"
|
||||
supportsMiddleName="true"
|
||||
supportsSuffix="true"
|
||||
supportsPhoneticFamilyName="true"
|
||||
supportsPhoneticMiddleName="true"
|
||||
supportsPhoneticGivenName="true" />
|
||||
<DataKind kind="photo" maxOccurs="1" />
|
||||
<DataKind kind="phone">
|
||||
<Type type="mobile" />
|
||||
<Type type="home" />
|
||||
<Type type="work" />
|
||||
<Type type="main" />
|
||||
<Type type="fax_work" />
|
||||
<Type type="fax_home" />
|
||||
<Type type="other_fax" />
|
||||
<Type type="pager" />
|
||||
<Type type="work_mobile" />
|
||||
<Type type="work_pager" />
|
||||
<Type type="tty_tdd" />
|
||||
<Type type="other" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="email">
|
||||
<Type type="home" />
|
||||
<Type type="work" />
|
||||
<Type type="other" />
|
||||
<Type type="mobile" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="nickname" maxOccurs="1" />
|
||||
<DataKind kind="postal" needsStructured="true">
|
||||
<Type type="home" />
|
||||
<Type type="work" />
|
||||
<Type type="other" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="organization" maxOccurs="1" />
|
||||
<!-- No <Type> allowed: websites are always OTHER in the editor. -->
|
||||
<DataKind kind="website" />
|
||||
<DataKind kind="event" dateWithTime="false">
|
||||
<Type type="birthday" maxOccurs="1" yearOptional="true" />
|
||||
<Type type="anniversary" yearOptional="true" />
|
||||
<Type type="other" yearOptional="true" />
|
||||
<Type type="custom" yearOptional="true" />
|
||||
</DataKind>
|
||||
<DataKind kind="relationship">
|
||||
<Type type="assistant" />
|
||||
<Type type="brother" />
|
||||
<Type type="child" />
|
||||
<Type type="domestic_partner" />
|
||||
<Type type="father" />
|
||||
<Type type="friend" />
|
||||
<Type type="manager" />
|
||||
<Type type="mother" />
|
||||
<Type type="parent" />
|
||||
<Type type="partner" />
|
||||
<Type type="referred_by" />
|
||||
<Type type="relative" />
|
||||
<Type type="sister" />
|
||||
<Type type="spouse" />
|
||||
<Type type="custom" />
|
||||
</DataKind>
|
||||
<DataKind kind="note" maxOccurs="1" />
|
||||
<DataKind kind="group_membership" maxOccurs="1" />
|
||||
</EditSchema>
|
||||
</ContactsAccountType>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Calendar sync adapter of device sync (#34); see docs/device-sync.md. -->
|
||||
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/device_sync_account_type"
|
||||
android:contentAuthority="com.android.calendar"
|
||||
android:supportsUploading="true"
|
||||
android:userVisible="true"
|
||||
android:allowParallelSyncs="false"
|
||||
android:isAlwaysSyncable="false" />
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Contacts sync adapter of device sync (#34); see docs/device-sync.md. -->
|
||||
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/device_sync_account_type"
|
||||
android:contentAuthority="com.android.contacts"
|
||||
android:supportsUploading="true"
|
||||
android:userVisible="true"
|
||||
android:allowParallelSyncs="false"
|
||||
android:isAlwaysSyncable="false" />
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncAccounts.EnsureAction
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceSyncAccountsTest {
|
||||
@Test
|
||||
fun `ensureAccount creates, keeps, adopts or refuses`() {
|
||||
assertEquals(EnsureAction.CREATE, DeviceSyncAccounts.ensureAction(false, null, "alice@example.org@mail"))
|
||||
assertEquals(EnsureAction.KEEP, DeviceSyncAccounts.ensureAction(true, "alice@example.org@mail", "alice@example.org@mail"))
|
||||
// An account whose setup never wrote a registry id.
|
||||
assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, null, "alice@example.org@mail"))
|
||||
assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, "", "alice@example.org@mail"))
|
||||
// Its rows and SyncState belong to another app account: never rewritten.
|
||||
assertEquals(EnsureAction.CONFLICT, DeviceSyncAccounts.ensureAction(true, "alice@example.org@old", "alice@example.org@mail"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceSyncRunsTest {
|
||||
private val contacts = "com.android.contacts"
|
||||
private val calendar = "com.android.calendar"
|
||||
|
||||
@Test
|
||||
fun `a report is accepted once, while the adapter waits`() {
|
||||
val run = DeviceSyncRuns.open("alice", contacts)
|
||||
assertTrue(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"io\"}"))
|
||||
assertFalse(run.end("no report before the deadline"))
|
||||
assertEquals(0L, run.done.count)
|
||||
assertEquals("{\"outcome\":\"ok\"}", run.report)
|
||||
assertEquals("reported", run.endReason)
|
||||
DeviceSyncRuns.close(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report after the run ended is refused whole`() {
|
||||
val run = DeviceSyncRuns.open("alice", contacts)
|
||||
assertTrue(run.end("no report before the deadline"))
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
assertNull(run.report)
|
||||
assertEquals("no report before the deadline", run.endReason)
|
||||
DeviceSyncRuns.close(run)
|
||||
assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runs nobody waits for, or that ended, count as cancelled`() {
|
||||
assertTrue(DeviceSyncRuns.isCancelled("unknown"))
|
||||
val run = DeviceSyncRuns.open("alice", calendar)
|
||||
assertFalse(DeviceSyncRuns.isCancelled(run.runId))
|
||||
run.end("task finished")
|
||||
assertTrue(DeviceSyncRuns.isCancelled(run.runId))
|
||||
DeviceSyncRuns.close(run)
|
||||
assertTrue(DeviceSyncRuns.isCancelled(run.runId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sync requested during a run flags that run only, and the flag outlives the close`() {
|
||||
val mine = DeviceSyncRuns.open("alice", contacts)
|
||||
val otherAuthority = DeviceSyncRuns.open("alice", calendar)
|
||||
val otherAccount = DeviceSyncRuns.open("bob", contacts)
|
||||
assertTrue(DeviceSyncRuns.flagSyncAgain("alice", contacts))
|
||||
DeviceSyncRuns.close(mine)
|
||||
assertTrue(mine.syncAgainRequested)
|
||||
assertFalse(otherAuthority.syncAgainRequested)
|
||||
assertFalse(otherAccount.syncAgainRequested)
|
||||
// Closed runs are not flagged any more.
|
||||
assertFalse(DeviceSyncRuns.flagSyncAgain("alice", contacts))
|
||||
DeviceSyncRuns.close(otherAuthority)
|
||||
DeviceSyncRuns.close(otherAccount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
private const val CONTACTS = "com.android.contacts"
|
||||
private const val CALENDAR = "com.android.calendar"
|
||||
private val ME = AccountRef("alice@example.org", "com.anonymous.bulwarkmobile.account")
|
||||
private val OTHER = AccountRef("bob@gmail.com", "com.google")
|
||||
|
||||
/** The provider as far as the planner asks: who owns which row; [me] is the account the lookup is bound to. */
|
||||
private class FakeLookup(private val me: AccountRef = ME) : ScopeLookup {
|
||||
val rawContacts = HashMap<Long, AccountRef>()
|
||||
val groups = HashMap<Long, AccountRef>()
|
||||
val calendars = HashMap<Long, AccountRef>()
|
||||
val colors = HashMap<Long, AccountRef>()
|
||||
/** data id → raw contact */
|
||||
val data = HashMap<Long, Long>()
|
||||
/** data id → mimetype */
|
||||
val mimetypes = HashMap<Long, String>()
|
||||
/** event id → calendar */
|
||||
val events = HashMap<Long, Long>()
|
||||
/** child table → child id → event */
|
||||
val children = HashMap<ProviderTable, HashMap<Long, Long>>()
|
||||
/** (child table, where) → events of the matching rows; a null where matches every row. */
|
||||
val matching = HashMap<Pair<ProviderTable, String?>, Set<Long>>()
|
||||
|
||||
override fun ownCalendarIds() = calendars.filterValues { it == me }.keys
|
||||
|
||||
override fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef> = when (table) {
|
||||
ProviderTable.RAW_CONTACTS -> rawContacts
|
||||
ProviderTable.GROUPS -> groups
|
||||
ProviderTable.CALENDARS -> calendars
|
||||
ProviderTable.COLORS -> colors
|
||||
else -> throw AssertionError("no root lookup for $table")
|
||||
}.filterKeys { it in ids }
|
||||
|
||||
override fun dataRawContacts(ids: Set<Long>) = data.filterKeys { it in ids }
|
||||
|
||||
override fun dataMimetypes(ids: Set<Long>) = mimetypes.filterKeys { it in ids }
|
||||
|
||||
override fun eventCalendars(ids: Set<Long>) = events.filterKeys { it in ids }
|
||||
|
||||
override fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long> =
|
||||
children[table].orEmpty().filterKeys { it in ids }
|
||||
|
||||
override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long> =
|
||||
matching[table to where] ?: if (where == null) children[table].orEmpty().values.toSet() else emptySet()
|
||||
|
||||
fun child(table: ProviderTable, id: Long, event: Long) {
|
||||
children.getOrPut(table) { HashMap() }[id] = event
|
||||
}
|
||||
}
|
||||
|
||||
class ProviderOpsTest {
|
||||
private val lookup = FakeLookup().apply {
|
||||
rawContacts[1] = ME
|
||||
rawContacts[2] = OTHER
|
||||
groups[10] = ME
|
||||
groups[11] = OTHER
|
||||
data[100] = 1
|
||||
data[101] = 2
|
||||
calendars[20] = ME
|
||||
calendars[21] = ME
|
||||
calendars[22] = OTHER
|
||||
colors[40] = ME
|
||||
events[30] = 20
|
||||
events[31] = 21
|
||||
events[32] = 22
|
||||
child(ProviderTable.ATTENDEES, 50, 30)
|
||||
child(ProviderTable.ATTENDEES, 51, 32)
|
||||
child(ProviderTable.REMINDERS, 60, 31)
|
||||
child(ProviderTable.EXTENDED_PROPERTIES, 70, 30)
|
||||
child(ProviderTable.EXTENDED_PROPERTIES, 71, 32)
|
||||
}
|
||||
private val planner = ScopePlanner(ME, lookup)
|
||||
|
||||
private fun ops(vararg op: String) = "[${op.joinToString(",")}]"
|
||||
|
||||
private fun plan(authority: String, vararg op: String) =
|
||||
planner.planBatch(ProviderOpParser.parseBatch(authority, ops(*op)))
|
||||
|
||||
private fun refused(reason: BatchFailure, block: () -> Unit): String {
|
||||
try {
|
||||
block()
|
||||
} catch (e: ProviderRefusal) {
|
||||
assertEquals(e.message, reason, e.reason)
|
||||
return e.message.orEmpty()
|
||||
}
|
||||
fail("expected a $reason refusal")
|
||||
throw AssertionError()
|
||||
}
|
||||
|
||||
// ── tables and parsing ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `tables are enumerated per authority and anything else is out of scope`() {
|
||||
assertEquals(ProviderTable.EXTENDED_PROPERTIES, ProviderTable.of(CALENDAR, "extended_properties"))
|
||||
assertEquals(ProviderTable.SETTINGS, ProviderTable.of(CONTACTS, "settings"))
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "events") }
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CALENDAR, "raw_contacts") }
|
||||
refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "contacts") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"instances","where":"1"}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers keep integers exact whatever org_json parsed them into`() {
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5))
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5.0))
|
||||
assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(BigDecimal("5.0")))
|
||||
assertEquals(Cell.Integer(1L shl 60), ProviderOpParser.numberCell(BigInteger.ONE.shiftLeft(60)))
|
||||
assertEquals(Cell.Integer(1_700_000_000_000), ProviderOpParser.numberCell(1_700_000_000_000L))
|
||||
assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(BigDecimal("1.5")))
|
||||
assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(1.5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `values keep text, numbers, booleans as 0 and 1, and nulls`() {
|
||||
val insert = ProviderOpParser.parseBatch(
|
||||
CONTACTS,
|
||||
ops("""{"op":"insert","table":"raw_contacts","values":{"sync1":"a","dirty":0,"starred":true,"sync2":null}}"""),
|
||||
).single() as ProviderOp.Insert
|
||||
assertEquals(
|
||||
mapOf("sync1" to Cell.Text("a"), "dirty" to Cell.Integer(0), "starred" to Cell.Integer(1), "sync2" to Cell.Null),
|
||||
insert.values,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed ops are provider failures`() {
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, "{}") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"upsert","table":"groups","values":{}}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{}}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"assert","table":"groups","where":"dirty = 1"}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"settings","id":1}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","args":["x"]}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","id":1.5}""") }
|
||||
refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","where":"a = ?","args":[null]}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bytes are only accepted for data_data15`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo","data15":{"b64":"AAEC"}}}""",
|
||||
).single() as PlannedOp.Insert
|
||||
assertEquals(Cell.Blob("AAEC"), planned.values["data15"])
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"raw_contacts","id":1,"values":{"sync2":{"b64":"AAEC"}}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data1":{"b64":"AA"}}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data15":{"bytes":"AA"}}}""")
|
||||
}
|
||||
}
|
||||
|
||||
// ── batch rules ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a syncState op must be the last op and not a yield point`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"update","table":"groups","id":10,"values":{"sync2":"x"},"yieldAllowed":true}""",
|
||||
"""{"op":"syncState","value":"{\"v\":1}"}""",
|
||||
)
|
||||
assertEquals(PlannedOp.SetSyncState("{\"v\":1}"), planned.last())
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"syncState","value":"{}"}""", """{"op":"delete","table":"groups","id":10}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"delete","table":"groups","id":10}""",
|
||||
"""{"op":"syncState","value":"{}","yieldAllowed":true}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletes(count: Int, yieldAt: Set<Int> = emptySet()) = (0 until count).joinToString(",", "[", "]") { i ->
|
||||
"""{"op":"delete","table":"groups","where":"sync1 = ?","args":["$i"]${if (i in yieldAt) ""","yieldAllowed":true""" else ""}}"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contacts batches stop at 499 ops between yield points, counted as ContactsProvider does`() {
|
||||
assertEquals(499, ProviderOpParser.parseBatch(CONTACTS, deletes(499)).size)
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500)) }
|
||||
// The yielding op still counts toward the ops before it.
|
||||
assertEquals(998, ProviderOpParser.parseBatch(CONTACTS, deletes(998, setOf(498))).size)
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(999, setOf(498))) }
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(600, setOf(499))) }
|
||||
// A yield on the first op is ignored, as the provider ignores it.
|
||||
refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500, setOf(0))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar batches have no yield point limit`() {
|
||||
val many = (0 until 600).joinToString(",", "[", "]") { """{"op":"delete","table":"colors","where":"color_index = '$it'"}""" }
|
||||
assertEquals(600, ProviderOpParser.parseBatch(CALENDAR, many).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back-references name earlier inserts, and parents name their parent table`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1"}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"vnd.android.cursor.item/name","data1":"A"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
assertEquals(mapOf("raw_contact_id" to 0), (planned[1] as PlannedOp.Insert).backReferences)
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"delete","table":"groups","id":10}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
// Settings rows have no id to refer to.
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"settings","values":{"ungrouped_visible":1}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"groups","values":{"title":"g"}}""",
|
||||
"""{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""",
|
||||
)
|
||||
}
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":null,"attendeeEmail":"a@b"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
// ── scope: inserts ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `root inserts are written into the account, whatever the op says`() {
|
||||
val planned = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1","account_name":"alice@example.org"}}""",
|
||||
).single() as PlannedOp.Insert
|
||||
assertEquals(Cell.Text(ME.name), planned.values["account_name"])
|
||||
assertEquals(Cell.Text(ME.type), planned.values["account_type"])
|
||||
val calendar = plan(CALENDAR, """{"op":"insert","table":"calendars","values":{"name":"c"}}""").single()
|
||||
assertEquals(Cell.Text(ME.type), (calendar as PlannedOp.Insert).values["account_type"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `account columns never move a row out of the account`() {
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"raw_contacts","values":{"account_name":"bob@gmail.com"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{"account_type":"com.google"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"groups","values":{"title":"g","data_set":"plugin"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"account_name":"alice@example.org"}}""")
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"account_name":"alice@example.org"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inserts must name a parent of the account that still exists`() {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x"}}""")
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"1","mimetype":"x"}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":2,"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":9,"mimetype":"x"}}""") }
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"me","mimetype":"x"}}""")
|
||||
}
|
||||
plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":21,"dtstart":0}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":22,"dtstart":0}}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":20,"original_id":32}}""")
|
||||
}
|
||||
plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":31,"minutes":10}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":32,"minutes":10}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":99}}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership row may only name a group of the account`() {
|
||||
val membership = """"mimetype":"vnd.android.cursor.item/group_membership""""
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":10}}""")
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"group_sourceid":"c/g"}}""")
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":11}}""")
|
||||
}
|
||||
// data1 of other kinds is plain data.
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x","data1":11}}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership row's group is checked on updates by id too`() {
|
||||
lookup.mimetypes[100] = GROUP_MEMBERSHIP_MIMETYPE
|
||||
plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":10}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""") }
|
||||
refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":12}}""") }
|
||||
// data1 by a selection could reach memberships the lookup never saw.
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CONTACTS, """{"op":"update","table":"data","where":"raw_contact_id = ?","args":[1],"values":{"data1":11}}""")
|
||||
}
|
||||
// data1 of other kinds is plain data.
|
||||
lookup.mimetypes[100] = "vnd.android.cursor.item/phone_v2"
|
||||
plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photo file ids are never written`() {
|
||||
val photo = """"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo""""
|
||||
plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":null,"data15":{"b64":"AAAA"}}}""")
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":5}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data14":5}}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selection can't close the scope's parentheses or query elsewhere`() {
|
||||
for (where in listOf("1) OR (1", "dirty = 1) OR (1 = 1", "(1", "1; DELETE FROM events", "1 -- x", "1 /* x */",
|
||||
"_id IN (SELECT _id FROM events)", "1 UNION ALL x", "title = 'open")) {
|
||||
val quoted = JSONObject.quote(where)
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","where":$quoted,"values":{"dirty":1}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","where":$quoted}""") }
|
||||
refused(BatchFailure.SCOPE) {
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":$quoted}""")
|
||||
}
|
||||
}
|
||||
refused(BatchFailure.SCOPE) {
|
||||
ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id); DROP TABLE x; --"}""")
|
||||
}
|
||||
// Parentheses and keywords inside quoted literals are data.
|
||||
plan(CALENDAR, """{"op":"update","table":"events","where":"title = '(x' OR title = 'select'","values":{"dirty":1}}""")
|
||||
plan(CALENDAR, """{"op":"assert","table":"events","where":"(dirty = 1 OR deleted = 1) AND _sync_id LIKE '~pending/%'","expectCount":0}""")
|
||||
ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id DESC"}""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a row to another parent is checked like an insert`() {
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":22}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"raw_contact_id":null}}""") }
|
||||
plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":21}}""")
|
||||
}
|
||||
|
||||
// ── scope: selections ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `root tables are selected by their account columns`() {
|
||||
val op = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"update","table":"raw_contacts","where":"dirty = ? AND deleted = 0","args":[1],"values":{"dirty":0}}""",
|
||||
).single() as PlannedOp.Update
|
||||
assertEquals(
|
||||
Selection("(dirty = ? AND deleted = 0) AND (account_name = ? AND account_type = ?)", listOf("1", ME.name, ME.type)),
|
||||
op.selection,
|
||||
)
|
||||
assertNull(op.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an op by id selects that row of the account and expects exactly one row`() {
|
||||
val op = plan(CONTACTS, """{"op":"delete","table":"groups","id":10}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(_id = ?) AND (account_name = ? AND account_type = ?)", listOf("10", ME.name, ME.type)), op.selection)
|
||||
assertEquals(1, op.expectedCount)
|
||||
val counted = plan(CONTACTS, """{"op":"assert","table":"raw_contacts","id":1,"expectCount":0}""").single()
|
||||
assertEquals(0, (counted as PlannedOp.Assert).expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an id of another account is refused, a vanished one is left to the expected count`() {
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":2}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":101,"values":{"data1":"x"}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"assert","table":"events","id":32,"values":{"dirty":0}}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","id":51}""") }
|
||||
refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"calendars","id":22,"values":{"name":"x"}}""") }
|
||||
val gone = plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":9}""").single() as PlannedOp.Delete
|
||||
assertEquals(1, gone.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data rows rely on the URI parameters and never select on the Data view's account columns`() {
|
||||
val op = plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":"x"}}""").single() as PlannedOp.Update
|
||||
assertEquals(Selection("(_id = ?)", listOf("100")), op.selection)
|
||||
val query = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"],"where":"raw_contact_id IN (1)"}"""))
|
||||
assertEquals(listOf(Selection("(raw_contact_id IN (1))", emptyList())), query.selections)
|
||||
val all = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"]}"""))
|
||||
assertEquals(listOf(Selection.NONE), all.selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events are selected by the account's calendars`() {
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id","title"],"where":"dirty = 1","orderBy":"_id"}"""),
|
||||
)
|
||||
assertEquals(listOf(Selection("(dirty = 1) AND (calendar_id IN (20,21))", emptyList())), query.selections)
|
||||
assertEquals("_id", query.orderBy)
|
||||
val update = plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"dirty":0}}""").single() as PlannedOp.Update
|
||||
assertEquals(Selection("(_id = ?) AND (calendar_id IN (20,21))", listOf("30")), update.selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account without calendars selects no events`() {
|
||||
val carol = AccountRef("carol", ME.type)
|
||||
val empty = ScopePlanner(carol, FakeLookup(carol).apply { calendars.putAll(lookup.calendars) })
|
||||
val query = empty.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id"]}"""))
|
||||
assertEquals(listOf(Selection("(0)", emptyList())), query.selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `children of events are selected by the account's events among those they touch`() {
|
||||
lookup.matching[ProviderTable.ATTENDEES to "event_id IN (30,32)"] = setOf(30, 32)
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["attendeeEmail"],"where":"event_id IN (30,32)"}"""),
|
||||
)
|
||||
assertEquals(listOf(Selection("(event_id IN (30,32)) AND (event_id IN (30))", emptyList())), query.selections)
|
||||
|
||||
val byId = plan(CALENDAR, """{"op":"delete","table":"attendees","id":50}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(Attendees._id = ?) AND (event_id IN (30))", listOf("50")), byId.selection)
|
||||
assertEquals(1, byId.expectedCount)
|
||||
|
||||
val gone = plan(CALENDAR, """{"op":"delete","table":"reminders","id":69}""").single() as PlannedOp.Delete
|
||||
assertEquals(Selection("(Reminders._id = ?) AND (0)", listOf("69")), gone.selection)
|
||||
|
||||
lookup.matching[ProviderTable.REMINDERS to "event_id = ?"] = setOf(31)
|
||||
val byWhere = plan(CALENDAR, """{"op":"delete","table":"reminders","where":"event_id = ?","args":[31]}""").single()
|
||||
assertEquals(Selection("(event_id = ?) AND (event_id IN (31))", listOf("31")), (byWhere as PlannedOp.Delete).selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a child query that touches none of the account's events reads nothing`() {
|
||||
lookup.matching[ProviderTable.ATTENDEES to "event_id = 32"] = setOf(32)
|
||||
val query = planner.planQuery(
|
||||
ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":"event_id = 32"}"""),
|
||||
)
|
||||
assertTrue(query.selections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long event lists are split into chunks`() {
|
||||
val many = (1000L until 1000L + 1200).toSet()
|
||||
for (id in many) lookup.events[id] = 20
|
||||
lookup.matching[ProviderTable.REMINDERS to null] = many
|
||||
val query = planner.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"reminders","columns":["minutes"]}"""))
|
||||
assertEquals(3, query.selections.size)
|
||||
assertTrue(query.selections.all { it.sql!!.startsWith("(event_id IN (") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extended properties are updated through their item URI, so only by id`() {
|
||||
val op = plan(CALENDAR, """{"op":"update","table":"extended_properties","id":70,"values":{"value":"v"}}""").single()
|
||||
assertEquals(PlannedOp.Update(ProviderTable.EXTENDED_PROPERTIES, Selection.NONE, 70, mapOf("value" to Cell.Text("v")), 1, false), op)
|
||||
refused(BatchFailure.SCOPE) {
|
||||
plan(CALENDAR, """{"op":"update","table":"extended_properties","id":71,"values":{"value":"v"}}""")
|
||||
}
|
||||
refused(BatchFailure.PROVIDER) {
|
||||
plan(CALENDAR, """{"op":"update","table":"extended_properties","where":"name = 'x'","values":{"value":"v"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asserts compare text and keep nulls`() {
|
||||
val op = plan(
|
||||
CONTACTS,
|
||||
"""{"op":"assert","table":"raw_contacts","id":1,"values":{"version":3,"dirty":0,"sync3":null,"sourceid":"c/1"}}""",
|
||||
).single() as PlannedOp.Assert
|
||||
assertEquals(mapOf("version" to "3", "dirty" to "0", "sync3" to null, "sourceid" to "c/1"), op.values)
|
||||
assertEquals(1, op.expectedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings are written by account`() {
|
||||
val insert = plan(CONTACTS, """{"op":"insert","table":"settings","values":{"ungrouped_visible":1,"should_sync":1}}""").single()
|
||||
assertEquals(Cell.Text(ME.name), (insert as PlannedOp.Insert).values["account_name"])
|
||||
val update = plan(CONTACTS, """{"op":"update","table":"settings","values":{"ungrouped_visible":1}}""").single()
|
||||
assertEquals(
|
||||
Selection("(account_name = ? AND account_type = ?)", listOf(ME.name, ME.type)),
|
||||
(update as PlannedOp.Update).selection,
|
||||
)
|
||||
}
|
||||
|
||||
// ── results ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `batch results are BatchResult JSON`() {
|
||||
val ok = JSONObject(ProviderResults.ok(listOf(OpOutcome(id = 7), OpOutcome(count = 1), OpOutcome())))
|
||||
assertEquals(true, ok.getBoolean("ok"))
|
||||
val results = ok.getJSONArray("results")
|
||||
assertEquals(7L, results.getJSONObject(0).getLong("id"))
|
||||
assertEquals(1, results.getJSONObject(1).getInt("count"))
|
||||
assertEquals(0, results.getJSONObject(2).length())
|
||||
val failed = JSONObject(ProviderResults.failed(BatchFailure.TOO_LARGE, "TransactionTooLargeException"))
|
||||
assertEquals(false, failed.getBoolean("ok"))
|
||||
assertEquals("tooLarge", failed.getString("reason"))
|
||||
assertEquals("TransactionTooLargeException", failed.getString("message"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an OperationApplicationException is an assert unless an insert got no row`() {
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Found value 4 when expected 3 for column version"))
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Expected 1 rows but actual 0"))
|
||||
assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("wrong number of rows: 0"))
|
||||
assertEquals(
|
||||
BatchFailure.PROVIDER,
|
||||
ProviderResults.failureOfOperationApplication("Insert into content://com.android.contacts/settings returned no result"),
|
||||
)
|
||||
assertEquals(
|
||||
BatchFailure.PROVIDER,
|
||||
ProviderResults.failureOfOperationApplication("Too many content provider operations between yield points."),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query rows are ProviderRows JSON with escaped text`() {
|
||||
val writer = ProviderRowsWriter(listOf("_id", "title", "x"))
|
||||
writer.beginRow()
|
||||
writer.longCell(9_007_199_254_740_991)
|
||||
writer.textCell("a \"quoted\"\\ line\nbreak\u0001 and \u2028")
|
||||
writer.nullCell()
|
||||
writer.endRow()
|
||||
writer.beginRow()
|
||||
writer.longCell(2)
|
||||
writer.doubleCell(1.5)
|
||||
writer.doubleCell(Double.NaN)
|
||||
writer.endRow()
|
||||
val parsed = JSONObject(writer.finish())
|
||||
assertEquals(JSONArray(listOf("_id", "title", "x")).toString(), parsed.getJSONArray("columns").toString())
|
||||
val rows = parsed.getJSONArray("rows")
|
||||
assertEquals(9_007_199_254_740_991L, rows.getJSONArray(0).getLong(0))
|
||||
assertEquals("a \"quoted\"\\ line\nbreak\u0001 and \u2028", rows.getJSONArray(0).getString(1))
|
||||
assertTrue(rows.getJSONArray(0).isNull(2))
|
||||
assertEquals(1.5, rows.getJSONArray(1).getDouble(1), 0.0)
|
||||
assertTrue(rows.getJSONArray(1).isNull(2))
|
||||
assertEquals("{\"columns\":[],\"rows\":[]}", ProviderRowsWriter(emptyList()).finish())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photos decode close to the target size and scale their longer side down to it`() {
|
||||
assertEquals(1, PhotoScaling.sampleSize(720, 720, 512))
|
||||
assertEquals(4, PhotoScaling.sampleSize(4000, 3000, 512))
|
||||
assertEquals(512 to 384, PhotoScaling.targetSize(1000, 750, 512))
|
||||
assertEquals(96 to 96, PhotoScaling.targetSize(96, 96, 512))
|
||||
assertEquals(1 to 512, PhotoScaling.targetSize(3, 4000, 512))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PushRoutingTest {
|
||||
private val contacts = "com.android.contacts"
|
||||
private val calendar = "com.android.calendar"
|
||||
|
||||
private val routes = PushRouting.parseRoutes(
|
||||
"""
|
||||
{
|
||||
"c": [{ "accountName": "alice@example.org", "authorities": ["$contacts", "$calendar"] }],
|
||||
"team": [
|
||||
{ "accountName": "alice@example.org", "authorities": ["$contacts"] },
|
||||
{ "accountName": "alice-work", "authorities": ["$calendar"] }
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
strict = true,
|
||||
)
|
||||
|
||||
private fun stateChange(changed: String) = mapOf(
|
||||
"kind" to "jmap-state-change",
|
||||
"accountLabel" to "alice",
|
||||
"accountId" to "c",
|
||||
"emailIds" to "[]",
|
||||
"changed" to changed,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a contact change requests a contacts sync and no mail task`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"c":{"ContactCard":"s1"}}"""), routes)
|
||||
assertEquals(setOf("alice@example.org" to contacts), decision.syncs)
|
||||
assertFalse(decision.startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar types request calendar syncs of the routed accounts only`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"team":{"Calendar":"s1","CalendarEvent":"s2"}}"""), routes)
|
||||
assertEquals(setOf("alice-work" to calendar), decision.syncs)
|
||||
assertFalse(decision.startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every account key is routed, not only the one repeated as accountId`() {
|
||||
val decision = PushRouting.decide(
|
||||
stateChange("""{"c":{"AddressBook":"s1"},"team":{"ContactCard":"s2","CalendarEvent":"s3"}}"""),
|
||||
routes,
|
||||
)
|
||||
// alice@example.org's contacts sync is requested once.
|
||||
assertEquals(setOf("alice@example.org" to contacts, "alice-work" to calendar), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mail anywhere in the map starts the mail task, next to the syncs`() {
|
||||
val decision = PushRouting.decide(
|
||||
stateChange("""{"team":{"CalendarEvent":"s1"},"c":{"EmailDelivery":"s2","Email":"s3"}}"""),
|
||||
routes,
|
||||
)
|
||||
assertTrue(decision.startMailTask)
|
||||
assertEquals(setOf("alice-work" to calendar), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an Email or Mailbox change is possible mail, as a subscription from an older build sends it`() {
|
||||
for (changed in listOf(
|
||||
"""{"c":{"Email":"s1","Mailbox":"s2"}}""",
|
||||
"""{"c":{"Email":"s1"}}""",
|
||||
"""{"c":{"Mailbox":"s1"}}""",
|
||||
)) {
|
||||
val decision = PushRouting.decide(stateChange(changed), routes)
|
||||
assertTrue("changed=$changed", decision.startMailTask)
|
||||
assertTrue("changed=$changed", decision.syncs.isEmpty())
|
||||
}
|
||||
val mixed = PushRouting.decide(stateChange("""{"team":{"CalendarEvent":"e1"},"c":{"Email":"s1"}}"""), routes)
|
||||
assertTrue(mixed.startMailTask)
|
||||
assertEquals(setOf("alice-work" to calendar), mixed.syncs)
|
||||
// Types that are neither mail nor device sync say nothing about mail.
|
||||
val other = PushRouting.decide(stateChange("""{"c":{"Thread":"t1","ContactCard":"s1"}}"""), routes)
|
||||
assertFalse(other.startMailTask)
|
||||
assertEquals(setOf("alice@example.org" to contacts), other.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email push always starts the mail task`() {
|
||||
val data = mapOf("kind" to "jmap-email-push", "accountId" to "c", "changed" to """{"c":{"EmailDelivery":"s"}}""")
|
||||
assertTrue(PushRouting.decide(data, routes).startMailTask)
|
||||
assertTrue(PushRouting.decide(mapOf("kind" to "jmap-email-push"), routes).startMailTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payloads without a readable map are legacy mail pushes`() {
|
||||
for (changed in listOf(null, "", "{}", "not json", "[1]", """{"c":{}}""")) {
|
||||
val data = stateChange(changed ?: "").let { if (changed == null) it - "changed" else it }
|
||||
val decision = PushRouting.decide(data, routes)
|
||||
assertTrue("changed=$changed", decision.startMailTask)
|
||||
assertTrue("changed=$changed", decision.syncs.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable account entry may be mail`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"c":"garbled","team":{"ContactCard":"s"}}"""), routes)
|
||||
assertTrue(decision.startMailTask)
|
||||
assertEquals(setOf("alice@example.org" to contacts), decision.syncs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads a push the same way as the push task`() {
|
||||
// kind (null: none), changed (null: none) → whether the mail task starts. The
|
||||
// same table as MAIL_CASES in src/lib/__tests__/push-background-task.test.ts,
|
||||
// whose carriesNoMail must agree.
|
||||
val state = "jmap-state-change"
|
||||
val emailPush = "jmap-email-push"
|
||||
val cases: List<Triple<String?, String?, Boolean>> = listOf(
|
||||
// No readable map: as before device sync.
|
||||
Triple(state, null, true),
|
||||
Triple(state, "", true),
|
||||
Triple(state, "not json", true),
|
||||
Triple(state, "[1]", true),
|
||||
Triple(state, "{}", true),
|
||||
// A map that names no type is read like a missing one.
|
||||
Triple(state, """{"c":{}}""", true),
|
||||
Triple(state, """{"c":{},"team":{}}""", true),
|
||||
// An account entry that can't be read may be mail.
|
||||
Triple(state, """{"c":null}""", true),
|
||||
Triple(state, """{"c":"garbled","team":{"ContactCard":"s"}}""", true),
|
||||
Triple(state, """{"c":[],"team":{"ContactCard":"s"}}""", true),
|
||||
Triple(state, """{"c":5}""", true),
|
||||
// Mail types anywhere in the map.
|
||||
Triple(state, """{"c":{"EmailDelivery":"s"}}""", true),
|
||||
Triple(state, """{"c":{"Email":"s"}}""", true),
|
||||
Triple(state, """{"c":{"Mailbox":"s"}}""", true),
|
||||
Triple(state, """{"c":{"ContactCard":"s1"},"team":{"EmailDelivery":"s2"}}""", true),
|
||||
// Only a StateChange can say it carries no mail.
|
||||
Triple(null, """{"c":{"ContactCard":"s"}}""", true),
|
||||
Triple(emailPush, """{"c":{"EmailDelivery":"s"}}""", true),
|
||||
Triple(emailPush, null, true),
|
||||
// Contact and calendar changes, and types that say nothing about mail.
|
||||
Triple(state, """{"c":{"ContactCard":"s1","AddressBook":"s2"}}""", false),
|
||||
Triple(state, """{"c":{"CalendarEvent":"e1"},"team":{"Calendar":"c1"}}""", false),
|
||||
Triple(state, """{"c":{"ContactCard":"s"},"team":{}}""", false),
|
||||
Triple(state, """{"elsewhere":{"ContactCard":"s"}}""", false),
|
||||
Triple(state, """{"c":{"Thread":"t1","ContactCard":"s1"}}""", false),
|
||||
)
|
||||
for ((kind, changed, mail) in cases) {
|
||||
val data = buildMap<String, String> {
|
||||
if (kind != null) put("kind", kind)
|
||||
put("accountLabel", "alice")
|
||||
put("accountId", "c")
|
||||
put("emailIds", "[]")
|
||||
if (changed != null) put("changed", changed)
|
||||
}
|
||||
assertEquals("kind=$kind changed=$changed", mail, PushRouting.decide(data, routes).startMailTask)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accounts without routes are ignored`() {
|
||||
val decision = PushRouting.decide(stateChange("""{"elsewhere":{"ContactCard":"s"}}"""), routes)
|
||||
assertTrue(decision.syncs.isEmpty())
|
||||
assertFalse(decision.startMailTask)
|
||||
assertTrue(PushRouting.decide(stateChange("""{"c":{"ContactCard":"s"}}"""), emptyMap()).syncs.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored routes are validated strictly and read leniently`() {
|
||||
val bad = listOf(
|
||||
"[]",
|
||||
"""{"c":{}}""",
|
||||
"""{"c":[{"authorities":["$contacts"]}]}""",
|
||||
"""{"c":[{"accountName":"a","authorities":["com.example.tasks"]}]}""",
|
||||
)
|
||||
for (json in bad) {
|
||||
try {
|
||||
PushRouting.parseRoutes(json, strict = true)
|
||||
throw AssertionError("accepted $json")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
assertEquals(emptyMap<String, List<PushRouting.Route>>(), PushRouting.parseRoutes("[]"))
|
||||
assertEquals(
|
||||
mapOf("c" to listOf(PushRouting.Route("a", setOf(contacts)))),
|
||||
PushRouting.parseRoutes("""{"c":[{"accountName":"a","authorities":["$contacts","com.example.tasks"]},{"x":1}]}"""),
|
||||
)
|
||||
assertEquals(emptyMap<String, List<PushRouting.Route>>(), PushRouting.parseRoutes(null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SyncReportsTest {
|
||||
private fun report(outcome: String, extra: String = "") = """
|
||||
{"v":1,"runId":"r","authority":"com.android.contacts","outcome":"$outcome",
|
||||
"startedAt":0,"durationMs":5,"conflicts":0,"itemErrors":[],
|
||||
"stats":{"downloaded":{"created":3,"updated":2,"deleted":1},
|
||||
"uploaded":{"created":1,"updated":4,"deleted":2},"entries":13,"skipped":1}$extra}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `a run without a report is a soft error`() {
|
||||
assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome(null, "task finished"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable report is a soft error`() {
|
||||
assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome("{not json", "reported"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ok sums both directions into the framework's stats`() {
|
||||
assertEquals(
|
||||
SyncOutcome(inserts = 4, updates = 6, deletes = 3, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report("ok"), "reported"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `io keeps the progress so the framework retries at once`() {
|
||||
val outcome = SyncReports.outcome(report("io"), "reported")
|
||||
assertEquals(1L, outcome.ioErrors)
|
||||
assertEquals(4L, outcome.inserts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth is hard and reports no progress, or the framework would retry it at once`() {
|
||||
assertEquals(
|
||||
SyncOutcome(authErrors = 1, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report("auth"), "reported"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `permission, unsupported and safety aborts are database errors without progress`() {
|
||||
for (outcome in listOf("permission", "unsupported", "safetyAbort")) {
|
||||
assertEquals(
|
||||
outcome,
|
||||
SyncOutcome(databaseError = true, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report(outcome), "reported"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too many deletions reports the pending deletions for the system notification`() {
|
||||
assertEquals(
|
||||
SyncOutcome(tooManyDeletions = true, deletes = 42, entries = 13, skipped = 1),
|
||||
SyncReports.outcome(report("tooManyDeletions", ""","tooManyDeletions":{"count":42,"threshold":20}"""), "reported"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delayUntil and moreRecordsToGet reach the framework`() {
|
||||
val outcome = SyncReports.outcome(report("ok", ""","delayUntil":1790000000,"moreRecordsToGet":true"""), "reported")
|
||||
assertEquals(1790000000L, outcome.delayUntil)
|
||||
assertEquals(true, outcome.fullSyncRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled and disabled runs are not errors`() {
|
||||
for (outcome in listOf("cancelled", "disabled")) {
|
||||
assertEquals(0L, SyncReports.outcome(report(outcome), "reported").ioErrors)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an outcome this build does not know is retried`() {
|
||||
assertEquals(1L, SyncReports.outcome(report("somethingNew"), "reported").ioErrors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the log summary names the outcome, failed items and the message`() {
|
||||
assertEquals("outcome=ok", SyncReports.summary(report("ok")))
|
||||
assertEquals(
|
||||
"outcome=internal message=Invalid column x",
|
||||
SyncReports.summary(report("internal", ",\"message\":\"Invalid column x\"")),
|
||||
)
|
||||
val withItems = report("ok").replace("\"itemErrors\":[]", "\"itemErrors\":[{\"ref\":\"a\"},{\"ref\":\"b\"}]")
|
||||
assertEquals("outcome=ok itemErrors=2", SyncReports.summary(withItems))
|
||||
assertEquals("no report", SyncReports.summary(null))
|
||||
assertEquals("unreadable report", SyncReports.summary("{"))
|
||||
val long = SyncReports.summary(report("io", ",\"message\":\"" + "m".repeat(400) + "\""))
|
||||
assertEquals("outcome=io message=".length + 300, long.length)
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,12 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
// mailto: lets Android/iOS offer the app for mail links in other apps.
|
||||
scheme: ['bulwarkmobile', 'mailto'],
|
||||
// mailto: lets Android/iOS offer the app for mail links in other apps;
|
||||
// bulwarkmail: is the webmail's sign-in link (bulwarkmail://pair?...).
|
||||
// Android's manifest is maintained by hand and lists the same schemes.
|
||||
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
orientation: 'default',
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
newArchEnabled: true,
|
||||
@@ -56,7 +58,7 @@ module.exports = {
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#09090b',
|
||||
backgroundColor: '#db2d54',
|
||||
},
|
||||
edgeToEdgeEnabled: true,
|
||||
predictiveBackGestureEnabled: false,
|
||||
@@ -97,6 +99,9 @@ module.exports = {
|
||||
],
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 32 KiB |
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [presetExpo],
|
||||
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1020,7 +1020,8 @@ ones moved to the N/A lists, and the wrong text corrected.
|
||||
| #46 email preview shows CSS | Not reproduced: HTML-only mail with `<style>` shows clean previews, and the viewer routes by type (`06742ef`). Ask for a sample, then close. |
|
||||
| #44, #48 push without FCM / UnifiedPush | Uncommitted work in this tree; not in any release. Committed in `ee04e95`; not released yet. |
|
||||
| #3 TLS client auth | Partial: Android has a certificate picker (Settings → Security); iOS doesn't. |
|
||||
| #58 encryption, #52 widgets, #51 IMAP/SMTP, #34 sync adapters, #1 settings sync | Feature requests; see open questions. |
|
||||
| #34 sync adapters | Done on Android: contacts and calendars sync both ways with the device's apps ([design](device-sync.md)). |
|
||||
| #58 encryption, #52 widgets, #51 IMAP/SMTP, #1 settings sync | Feature requests; see open questions. |
|
||||
|
||||
No open webmail issue is about the native app. The six mobile-titled ones (#813, #838, #915,
|
||||
#969, #999, #1020) concern the web and PWA.
|
||||
@@ -1042,8 +1043,7 @@ No open webmail issue is about the native app. The six mobile-titled ones (#813,
|
||||
for the page to be visible for some time, or apply only to the message that was actually
|
||||
opened? Decided in `5f3f584`: only the page on screen auto-sends, once per message per session.
|
||||
- **Settings sync (#1):** needs a server-side store, which neither client has today.
|
||||
- **Scope of the feature requests:** encryption (#58), widgets (#52), IMAP/SMTP (#51) and native
|
||||
sync adapters (#34).
|
||||
- **Scope of the feature requests:** encryption (#58), widgets (#52) and IMAP/SMTP (#51).
|
||||
|
||||
## Reproducing
|
||||
|
||||
|
||||