Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
db07acc37c | ||
|
|
39628d84dc | ||
|
|
78901f2375 | ||
|
|
a4985a63a8 | ||
|
|
f7ec09942d | ||
|
|
c165fef588 | ||
|
|
4052523ba9 | ||
|
|
e30749a50e |
@@ -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
|
||||
@@ -57,7 +59,12 @@ jobs:
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
# The action installs the legacy `tools` package by default, which the
|
||||
# runner's cmdline-tools 16 no longer carries ("Failed to find package
|
||||
# 'tools'"). Gradle fetches the platforms and build-tools it needs.
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
|
||||
- name: Cache Android NDK / CMake
|
||||
uses: actions/cache@v4
|
||||
@@ -205,9 +212,12 @@ jobs:
|
||||
set -euo pipefail
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
apksigner verify --verbose --print-certs "$APK"
|
||||
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
||||
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
||||
signers() {
|
||||
apksigner verify --print-certs "$@" "$APK" \
|
||||
| sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p'
|
||||
| sed -n -E 's/^(Signer #[0-9]+|V[0-9.]+ Signer:) certificate SHA-256 digest: //p' \
|
||||
| sort -u
|
||||
}
|
||||
MODERN=$(signers --min-sdk-version 28)
|
||||
LEGACY=$(signers --max-sdk-version 27)
|
||||
@@ -241,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=""
|
||||
@@ -258,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 }}
|
||||
|
||||
@@ -18,6 +18,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,
|
||||
@@ -58,10 +59,11 @@ 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 { prefetchMessage } from './src/lib/email-detail-cache';
|
||||
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 { toast } from './src/stores/toast-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
@@ -70,7 +72,18 @@ import { ToastHost } from './src/components/ToastHost';
|
||||
import { UndoSnackbar } from './src/components/UndoSnackbar';
|
||||
import { AppIconBadge } from './src/components/AppIconBadge';
|
||||
import { getEmails } from './src/api/email';
|
||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
||||
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
||||
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||
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';
|
||||
@@ -83,6 +96,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;
|
||||
|
||||
@@ -131,16 +148,27 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise<voi
|
||||
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
|
||||
// Android share-sheet payloads all end up here once the navigator is ready.
|
||||
async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
await handleDeepLink(link, {
|
||||
const opened = await handleDeepLink(link, {
|
||||
navigation: navigationRef,
|
||||
resolveThreadId: async (emailId) => {
|
||||
resolveThreadId: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
const [email] = await getEmails([emailId]);
|
||||
const [email] = await getEmails([emailId], jmapAccountId);
|
||||
return email?.threadId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
openDraft: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
// Cache first, so a draft read before still opens offline.
|
||||
const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId);
|
||||
if (!navigationRef.isReady()) return false;
|
||||
navigationRef.navigate('Compose', { draft });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
switchAccount: async (accountId) => {
|
||||
const auth = useAuthStore.getState();
|
||||
if (auth.activeAccountId === accountId) return true;
|
||||
@@ -149,6 +177,9 @@ async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
return useAuthStore.getState().activeAccountId === accountId;
|
||||
},
|
||||
});
|
||||
// A widget or a link elsewhere brought the app up for this; say it failed
|
||||
// rather than leave the user wondering why the app just opened.
|
||||
if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this"));
|
||||
}
|
||||
|
||||
// Toasts and the undo bar show on whichever stack screen is up, so a failure
|
||||
@@ -382,9 +413,20 @@ 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();
|
||||
}, []);
|
||||
|
||||
// Home-screen widgets follow the signed-in data; once the last account is
|
||||
// gone they are wiped so no mail stays visible on the launcher.
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) return startWidgetSync();
|
||||
if (hasRestoredSession) void signOutWidgets();
|
||||
return undefined;
|
||||
}, [isAuthenticated, hasRestoredSession]);
|
||||
|
||||
// When the network flips back on while we're authenticated-but-offline
|
||||
// (no live JMAP session), retry the session so the user lands back on
|
||||
// live data without needing to relaunch.
|
||||
@@ -541,6 +583,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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -22,11 +22,12 @@ 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
|
||||
@@ -45,4 +46,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"/>
|
||||
@@ -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,6 +74,7 @@
|
||||
</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"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
@@ -66,5 +82,254 @@
|
||||
</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">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||
</receiver>
|
||||
<!-- END WIDGETS -->
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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,8 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkFcmModule(reactContext))
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json.
|
||||
// react-native-android-widget names a widget after its provider class, so each
|
||||
// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx)
|
||||
// renders by that name.
|
||||
package com.anonymous.bulwarkmobile.widget
|
||||
|
||||
import com.reactnativeandroidwidget.RNWidgetProvider
|
||||
|
||||
class InboxWidget : RNWidgetProvider()
|
||||
class TriageWidget : RNWidgetProvider()
|
||||
class UnreadCountWidget : RNWidgetProvider()
|
||||
class LatestMessageWidget : RNWidgetProvider()
|
||||
class FolderCountsWidget : RNWidgetProvider()
|
||||
class MailShortcutsWidget : RNWidgetProvider()
|
||||
class AllAccountsWidget : RNWidgetProvider()
|
||||
class StarredWidget : RNWidgetProvider()
|
||||
class FavouritePeopleWidget : RNWidgetProvider()
|
||||
class MailSearchWidget : RNWidgetProvider()
|
||||
class OutboxWidget : RNWidgetProvider()
|
||||
class TagWidget : RNWidgetProvider()
|
||||
class LastDraftWidget : RNWidgetProvider()
|
||||
class AgendaWidget : RNWidgetProvider()
|
||||
class WeekWidget : RNWidgetProvider()
|
||||
class MonthWidget : RNWidgetProvider()
|
||||
class TodayEventsWidget : RNWidgetProvider()
|
||||
class NextEventWidget : RNWidgetProvider()
|
||||
class DateNextWidget : RNWidgetProvider()
|
||||
class UpNextWidget : RNWidgetProvider()
|
||||
class InvitationsWidget : RNWidgetProvider()
|
||||
class FreeTimeWidget : RNWidgetProvider()
|
||||
class TasksWidget : RNWidgetProvider()
|
||||
class TaskProgressWidget : RNWidgetProvider()
|
||||
class CountdownWidget : RNWidgetProvider()
|
||||
class BirthdaysWidget : RNWidgetProvider()
|
||||
class TodayHubWidget : RNWidgetProvider()
|
||||
class MailAndNextWidget : RNWidgetProvider()
|
||||
class RecentFilesWidget : RNWidgetProvider()
|
||||
class AttachmentsWidget : RNWidgetProvider()
|
||||
class VacationWidget : RNWidgetProvider()
|
||||
class StorageWidget : RNWidgetProvider()
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Home-screen widget edge, drawn over the widget by layout/rn_widget.xml. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/widget_frame" />
|
||||
<corners android:radius="@dimen/widget_radius" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Home-screen widget root (layout/rn_widget.xml): its rounded outline is what the widget clips to. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_surface" />
|
||||
<corners android:radius="@dimen/widget_radius" />
|
||||
</shape>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Night twin of layout/rn_widget.xml, which explains the override. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_surface"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/widget_frame"
|
||||
tools:targetApi="31">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_light"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_dark"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="visible" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_clickable_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_collection_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Overrides react-native-android-widget's rn_widget.xml (same ids and children;
|
||||
layout-night/rn_widget.xml is its night twin).
|
||||
|
||||
The library draws each widget into one bitmap sized from the size the
|
||||
launcher reports, and pins it to the top left without scaling. When the real
|
||||
frame is smaller (after a rotation, or on a launcher that over-reports), the
|
||||
bitmap's bottom edge is cut off and takes the rounded corners and the border
|
||||
with it, so the bottom corners fall back to whatever the launcher clips to.
|
||||
This root always has the real size, so it owns the shape instead: it clips to
|
||||
a rounded outline (Android 12+; the launcher then leaves the corners to us),
|
||||
fills in where the bitmap falls short, and draws the frame on top.
|
||||
-->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_surface"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/widget_frame"
|
||||
tools:targetApi="31">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_light"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_dark"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="gone" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_clickable_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_collection_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
|
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 |
@@ -3,4 +3,6 @@
|
||||
<color name="splashscreen_background">#09090b</color>
|
||||
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
|
||||
<color name="window_background">#09090b</color>
|
||||
<!-- DARK.bg in src/widgets/theme.ts. -->
|
||||
<color name="widget_surface">#0a0a0a</color>
|
||||
</resources>
|
||||
@@ -1,9 +1,13 @@
|
||||
<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). -->
|
||||
<color name="window_background">#ffffff</color>
|
||||
<!-- Home-screen widget surface (LIGHT.bg in src/widgets/theme.ts); it only shows where the drawn widget falls short of the launcher's frame. -->
|
||||
<color name="widget_surface">#ffffff</color>
|
||||
<!-- Home-screen widget edge, one colour for both schemes: the app can force a theme the launcher's night mode doesn't know about. -->
|
||||
<color name="widget_frame">#4d808080</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
|
||||
<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,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<resources>
|
||||
<string name="widgetprovider_inboxwidget_label">Inbox</string>
|
||||
<string name="widgetprovider_inboxwidget_description">Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.</string>
|
||||
<string name="widgetprovider_triagewidget_label">Triage</string>
|
||||
<string name="widgetprovider_triagewidget_description">One unread message at a time with archive, delete and reply buttons.</string>
|
||||
<string name="widgetprovider_unreadcountwidget_label">Unread count</string>
|
||||
<string name="widgetprovider_unreadcountwidget_description">Unread count for the inbox, with the latest senders.</string>
|
||||
<string name="widgetprovider_latestmessagewidget_label">Latest message</string>
|
||||
<string name="widgetprovider_latestmessagewidget_description">Only the newest unread message.</string>
|
||||
<string name="widgetprovider_foldercountswidget_label">Folder counts</string>
|
||||
<string name="widgetprovider_foldercountswidget_description">Unread counts for up to four folders.</string>
|
||||
<string name="widgetprovider_mailshortcutswidget_label">Shortcuts</string>
|
||||
<string name="widgetprovider_mailshortcutswidget_description">Compose, calendar, contacts and files.</string>
|
||||
<string name="widgetprovider_allaccountswidget_label">All accounts</string>
|
||||
<string name="widgetprovider_allaccountswidget_description">The unified inbox across every signed-in account.</string>
|
||||
<string name="widgetprovider_starredwidget_label">Starred</string>
|
||||
<string name="widgetprovider_starredwidget_description">Starred messages, newest first.</string>
|
||||
<string name="widgetprovider_favouritepeoplewidget_label">Favourite people</string>
|
||||
<string name="widgetprovider_favouritepeoplewidget_description">The people you hear from most, with their unread count.</string>
|
||||
<string name="widgetprovider_mailsearchwidget_label">Search mail</string>
|
||||
<string name="widgetprovider_mailsearchwidget_description">Search bar with your recent searches.</string>
|
||||
<string name="widgetprovider_outboxwidget_label">Scheduled and outbox</string>
|
||||
<string name="widgetprovider_outboxwidget_description">Scheduled messages and anything waiting in the outbox.</string>
|
||||
<string name="widgetprovider_tagwidget_label">Tag</string>
|
||||
<string name="widgetprovider_tagwidget_description">Unread count for your busiest tag.</string>
|
||||
<string name="widgetprovider_lastdraftwidget_label">Last draft</string>
|
||||
<string name="widgetprovider_lastdraftwidget_description">Back into the draft you left.</string>
|
||||
<string name="widgetprovider_agendawidget_label">Agenda</string>
|
||||
<string name="widgetprovider_agendawidget_description">Upcoming events by day with time, colour and place.</string>
|
||||
<string name="widgetprovider_weekwidget_label">This week</string>
|
||||
<string name="widgetprovider_weekwidget_description">The week at a glance from 8:00 to 18:00.</string>
|
||||
<string name="widgetprovider_monthwidget_label">Month</string>
|
||||
<string name="widgetprovider_monthwidget_description">Month grid with a dot per calendar and the next two events.</string>
|
||||
<string name="widgetprovider_todayeventswidget_label">Today</string>
|
||||
<string name="widgetprovider_todayeventswidget_description">Today\'s date with the rest of today and tomorrow.</string>
|
||||
<string name="widgetprovider_nexteventwidget_label">Next event</string>
|
||||
<string name="widgetprovider_nexteventwidget_description">What\'s next, when and where.</string>
|
||||
<string name="widgetprovider_datenextwidget_label">Date and next two</string>
|
||||
<string name="widgetprovider_datenextwidget_description">Today\'s date with the next two events.</string>
|
||||
<string name="widgetprovider_upnextwidget_label">Up next</string>
|
||||
<string name="widgetprovider_upnextwidget_description">Countdown to the next event with join and running-late buttons.</string>
|
||||
<string name="widgetprovider_invitationswidget_label">Invitations</string>
|
||||
<string name="widgetprovider_invitationswidget_description">Pending invitations with accept, maybe and decline.</string>
|
||||
<string name="widgetprovider_freetimewidget_label">Free time</string>
|
||||
<string name="widgetprovider_freetimewidget_description">When you\'re free this week.</string>
|
||||
<string name="widgetprovider_taskswidget_label">Tasks</string>
|
||||
<string name="widgetprovider_taskswidget_description">Open tasks with tick boxes that work from the home screen.</string>
|
||||
<string name="widgetprovider_taskprogresswidget_label">Task progress</string>
|
||||
<string name="widgetprovider_taskprogresswidget_description">How many tasks are done.</string>
|
||||
<string name="widgetprovider_countdownwidget_label">Countdown</string>
|
||||
<string name="widgetprovider_countdownwidget_description">Days until your next all-day event.</string>
|
||||
<string name="widgetprovider_birthdayswidget_label">Birthdays</string>
|
||||
<string name="widgetprovider_birthdayswidget_description">Upcoming birthdays from your contacts.</string>
|
||||
<string name="widgetprovider_todayhubwidget_label">Today overview</string>
|
||||
<string name="widgetprovider_todayhubwidget_description">Next event, newest unread mail and tasks due, in one widget.</string>
|
||||
<string name="widgetprovider_mailandnextwidget_label">Mail and next event</string>
|
||||
<string name="widgetprovider_mailandnextwidget_description">Unread mail on one side, the next event on the other.</string>
|
||||
<string name="widgetprovider_recentfileswidget_label">Recent files</string>
|
||||
<string name="widgetprovider_recentfileswidget_description">Recently changed items in Files.</string>
|
||||
<string name="widgetprovider_attachmentswidget_label">Recent attachments</string>
|
||||
<string name="widgetprovider_attachmentswidget_description">The newest attachments from your mail.</string>
|
||||
<string name="widgetprovider_vacationwidget_label">Vacation responder</string>
|
||||
<string name="widgetprovider_vacationwidget_description">Vacation responder status with an on/off switch.</string>
|
||||
<string name="widgetprovider_storagewidget_label">Storage</string>
|
||||
<string name="widgetprovider_storagewidget_description">How much of your mailbox quota is used.</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,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="250dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_agendawidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/agendawidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_allaccountswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/allaccountswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_attachmentswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/attachmentswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_birthdayswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/birthdayswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_countdownwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/countdownwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_datenextwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/datenextwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_favouritepeoplewidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/favouritepeoplewidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_foldercountswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/foldercountswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_freetimewidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/freetimewidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_inboxwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/inboxwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||