mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
On Android, Settings > Contacts and Settings > Calendar get a "Sync to
this device" section with a row per signed-in account. This makes device
sync reachable:
- The switch asks for the contacts or calendar permission after a short
explanation (denied: sync stays off and says why, with a link to the
app's system settings once Android stops asking), creates the
account's Bulwark account in Android and syncs right away. Turning it
off uploads what the device changed first and asks before anything
that could not go up is lost. The switch shows Android's own state, so
sync turned off in Android Settings shows as paused.
- Which address books or calendars sync: the personal account's are on
by default, shared ones and the Trusted Senders book off; read-only
ones are marked, and a note says when another signed-in account
already syncs the same shared one.
- The address book new contacts from other apps go to, who reminds of
synced events (the calendar app or Bulwark, asked when calendar sync
is turned on while Bulwark's reminders are on), and how often Android
syncs on its own (every 15 minutes to every 6 hours, or not at all).
- "Sync now", the last sync with its result, or the error with what to
do about it ("Sign in again", "Grant access", "Review deletions"), and
a link to the account in Android Settings.
- Accounts the app signed out while they synced are listed with "Sign
in again" and "Remove from this device".
The settings search finds both sections. iOS, and Android builds without
the native module, show nothing new.
Checked: the collection listing and status tests, the settings search
and i18n tests, `npm run typecheck`, `npm test` and `npm run i18n:check`
on this commit, and on the API 32 emulator with usera on the local
server: the section, turning contacts sync on (Android account created,
152 contacts on the device, one per card) and the status after a run.
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
// Android's side of one account's device sync for the settings: whether the
|
|
// Android account exists, its automatic sync for the authority (the switch),
|
|
// the device's master auto-sync, whether a sync runs or waits, and whether
|
|
// the runtime permissions are still granted. Re-read on mount, when the app
|
|
// returns to the foreground, when accounts of our type change, and every few
|
|
// seconds while a sync runs or waits.
|
|
import React from 'react';
|
|
import { AppState } from 'react-native';
|
|
import { getSyncSettings, listAndroidAccounts, onAccountsChanged } from '../../../device-sync/native';
|
|
import { hasSyncPermissions } from '../../../device-sync/app/permissions';
|
|
import type { Authority } from '../../../device-sync/types';
|
|
|
|
export interface AndroidSyncView {
|
|
/** The Android account exists. */
|
|
exists: boolean;
|
|
/** Automatic sync for the authority: the toggle in Settings → Accounts. */
|
|
automatic: boolean;
|
|
/** The device's "Auto-sync data". */
|
|
masterAutomatic: boolean;
|
|
active: boolean;
|
|
pending: boolean;
|
|
}
|
|
|
|
const POLL_MS = 3000;
|
|
|
|
export function useAndroidSync(accountName: string | undefined, authority: Authority): {
|
|
view: AndroidSyncView | null;
|
|
permitted: boolean | null;
|
|
refresh: () => Promise<void>;
|
|
} {
|
|
const [view, setView] = React.useState<AndroidSyncView | null>(null);
|
|
const [permitted, setPermitted] = React.useState<boolean | null>(null);
|
|
const alive = React.useRef(true);
|
|
React.useEffect(() => {
|
|
alive.current = true;
|
|
return () => { alive.current = false; };
|
|
}, []);
|
|
// Read at call time: turning sync on creates the account while the
|
|
// caller still holds the refresh of the render before.
|
|
const nameRef = React.useRef(accountName);
|
|
nameRef.current = accountName;
|
|
// Only the latest read may land; an older one resolving late is stale.
|
|
const reads = React.useRef(0);
|
|
|
|
const refresh = React.useCallback(async () => {
|
|
const read = ++reads.current;
|
|
const accountName = nameRef.current;
|
|
const granted = await hasSyncPermissions(authority);
|
|
let next: AndroidSyncView = { exists: false, automatic: false, masterAutomatic: true, active: false, pending: false };
|
|
if (accountName) {
|
|
try {
|
|
const accounts = await listAndroidAccounts();
|
|
if (accounts.some((a) => a.name === accountName)) {
|
|
const settings = await getSyncSettings(accountName);
|
|
const own = settings.authorities?.[authority];
|
|
next = {
|
|
exists: true,
|
|
automatic: !!own?.automatic,
|
|
masterAutomatic: settings.masterAutomatic !== false,
|
|
active: !!own?.active,
|
|
pending: !!own?.pending,
|
|
};
|
|
}
|
|
} catch {
|
|
// Shown as off until the next read.
|
|
}
|
|
}
|
|
if (!alive.current || read !== reads.current) return;
|
|
setPermitted(granted);
|
|
setView(next);
|
|
}, [authority]);
|
|
|
|
React.useEffect(() => {
|
|
void refresh();
|
|
}, [refresh, accountName]);
|
|
|
|
React.useEffect(() => {
|
|
const subscription = AppState.addEventListener('change', (state) => {
|
|
if (state === 'active') void refresh();
|
|
});
|
|
let unsubscribe: () => void = () => undefined;
|
|
try {
|
|
unsubscribe = onAccountsChanged(() => { void refresh(); });
|
|
} catch {
|
|
// Foreground reads still catch up.
|
|
}
|
|
return () => {
|
|
subscription.remove();
|
|
unsubscribe();
|
|
};
|
|
}, [refresh]);
|
|
|
|
const busy = !!view?.active || !!view?.pending;
|
|
React.useEffect(() => {
|
|
if (!busy) return undefined;
|
|
const timer = setInterval(() => { void refresh(); }, POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [busy, refresh]);
|
|
|
|
return { view, permitted, refresh };
|
|
}
|