diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 12:56:26 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 12:56:26 +0200 |
| commit | a0793dd37030f7213ecb07a018b11e6a8adf9238 (patch) | |
| tree | 5de1046eae9b3747ac97a81652590fb6af99622b /packages/meshbay-hub | |
| parent | 044c585f73cfd548e300859e89eaf5dcb40d3d3c (diff) | |
| download | meshbay-a0793dd37030f7213ecb07a018b11e6a8adf9238.tar.gz | |
feat(android): back the phone's contacts up to a group of one's own
A Contacts backup section on the Android Sync page sends a dated .vcf into
<folder>/<account>-contacts once a day when the address book changed, only
to a group the account is alone in, checked again at every run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
20 files changed, 1016 insertions, 17 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js index 6285b3c..5145515 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js @@ -1,16 +1,21 @@ // The Android Sync page: what this phone sends to its groups, on the Android -// application only (docs/MESHBAY_DESIGN.md §9.12). Loaded on demand, like the -// sections it holds: no other client has it. +// application only (docs/MESHBAY_DESIGN.md §9.12, §9.13). Loaded on demand, +// like the sections it holds: no other client has it. import { html } from './vendor/htm-preact.js'; import { t } from './i18n.js'; +import * as platform from './platform.js'; import { PhotoSyncSection } from './photo-sync-settings.js'; +import { DocSyncSection } from './phone-sync-settings.js'; export function AndroidSyncPage({ user, groups }) { return html` <div> <h2>${t('androidsync.title')}</h2> - <${PhotoSyncSection} user=${user} groups=${groups || []} /> + ${platform.photoSync.available && html` + <${PhotoSyncSection} user=${user} groups=${groups || []} />`} + ${platform.contactSync.available && html` + <${DocSyncSection} kind="contacts" user=${user} groups=${groups || []} />`} </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 973729e..72c52ac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -463,7 +463,7 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, sh href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a> </div> `} - ${platform.photoSync.available && html` + ${PHONE_SYNC && html` <div class="sidebar-section"> <div class="sidebar-heading">${t('sidebar.phone')}</div> <a class="sidebar-item ${route === '/android-sync' ? 'active' : ''}" @@ -715,8 +715,9 @@ const SearchPage = lazy(() => import('./search-page.js'), 'SearchPage', // ── Lazy-loaded Android Sync page ──────────────────────────────────────────── // -// The Android application's photo backup (docs/MESHBAY_DESIGN.md §9.12): no -// browser or desktop ever fetches it. +// The Android application's backups of its photos, contacts and messages +// (docs/MESHBAY_DESIGN.md §9.12, §9.13): no browser or desktop ever fetches it. +const PHONE_SYNC = platform.photoSync.available || platform.contactSync.available; const AndroidSyncPage = lazy(() => import('./android-sync-page.js'), 'AndroidSyncPage', html`<div class="page-content"><p class="page-message"><span class="spinner"></span></p></div>`); @@ -942,6 +943,28 @@ function App() { return () => { gone = true; if (stop) stop(); }; }, [syncAccount]); + // Contacts backup (§9.13), the same way. + useEffect(() => { + if (!syncAccount || !platform.contactSync.available) return undefined; + let stop = null; + let gone = false; + import('./phone-sync.js').then((m) => { + if (gone) return; + m.startPhoneSync({ + hub: HUB, + hubFetch, + getUser: () => userRef.current, + getBundleKey: async () => { + const key = session.bundleKey || await _loadBundleKey(); + if (key) session.bundleKey = key; + return key; + }, + }); + stop = m.stopPhoneSync; + }).catch((e) => console.warn('[MeshBay] contacts backup did not load:', e.message)); + return () => { gone = true; if (stop) stop(); }; + }, [syncAccount]); + // Saving the queue is a shell-level action because the queue is: the player // bar outlives every page, and the account it belongs to is here. const [saveQueue, setSaveQueue] = useState(null); @@ -1441,7 +1464,7 @@ function App() { allowPublicGroups=${allowPublicGroups} token=${user.token} onGroupsChanged=${reloadGroups} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; - } else if (route === '/android-sync' && platform.photoSync.available) { + } else if (route === '/android-sync' && PHONE_SYNC) { page = html`<${AndroidSyncPage} user=${user} groups=${groups} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 9149f21..18cdb40 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1339,4 +1339,25 @@ export default { 'photosync.fail_not_member': "Foto-Sicherung angehalten: Sie sind kein Mitglied dieser Gruppe mehr.", 'photosync.fail_permission': "Foto-Sicherung angehalten: MeshBay hat keinen Zugriff mehr auf die Fotos.", 'photosync.fail_other': "Foto-Sicherung angehalten.", + 'contactsync.heading': "Kontakte-Sicherung", + 'contactsync.intro': "Einmal täglich, wenn sie sich geändert haben, eine Kopie der Kontakte dieses Telefons in einen Ordner einer Gruppe senden, deren einziges Mitglied du bist. Jede Kopie ist eine .vcf-Datei, die jedes Telefon importieren kann.", + 'contactsync.permission_needed': "MeshBay braucht Zugriff auf die Kontakte dieses Telefons.", + 'contactsync.confirm': "Eine Kopie deiner Kontakte wird an {group} in {dir} gesendet, danach eine neue an jedem Tag, an dem sie sich ändern.\n\nDu bist das einzige Mitglied dieser Gruppe. Tritt jemand bei, endet die Sicherung.", + 'phonesync.state_sending': "Wird gesendet…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Gesendete Kopien: {n}", + 'phonesync.last_file': "Letzte Kopie: {name}", + 'phonesync.checking_groups': "Suche nach Gruppen, deren einziges Mitglied du bist…", + 'phonesync.no_private_group': "Du hast keine eigene Gruppe. Erstelle eine Gruppe, deren einziges Mitglied du bist, und komm dann hierher zurück.", + 'phonesync.no_folder': "Diese Gruppe hat keinen Ordner, in den du hochladen kannst.", + 'phonesync.destination': "Kopien landen in {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Gestoppt: Der Datenträger des Nodes ist voll.", + 'phonesync.fail_read_only': "Gestoppt: Der Ordner nimmt keine Uploads mehr an.", + 'phonesync.fail_no_folder': "Gestoppt: Der Ordner existiert in der Gruppe nicht mehr.", + 'phonesync.fail_unavailable': "Gestoppt: Das Laufwerk des Ordners ist nicht mit dem Node verbunden.", + 'phonesync.fail_not_member': "Gestoppt: Du bist kein Mitglied dieser Gruppe mehr.", + 'phonesync.fail_not_private': "Gestoppt: Jemand anderes ist der Gruppe beigetreten. Wähle eine Gruppe, deren einziges Mitglied du bist.", + 'phonesync.fail_permission': "Gestoppt: MeshBay hat keinen Zugriff mehr.", + 'phonesync.fail_other': "Gestoppt.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 259d728..8f8af0d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1320,4 +1320,25 @@ export default { 'photosync.fail_not_member': "Photo backup stopped: you are no longer a member of this group.", 'photosync.fail_permission': "Photo backup stopped: MeshBay no longer has access to the photos.", 'photosync.fail_other': "Photo backup stopped.", + 'contactsync.heading': "Contacts backup", + 'contactsync.intro': "Send a copy of this phone's contacts to a folder of a group only you belong to, once a day when they have changed. Each copy is a .vcf file any phone can import.", + 'contactsync.permission_needed': "MeshBay needs access to this phone's contacts.", + 'contactsync.confirm': "A copy of your contacts will be sent to {group} into {dir}, then a new one each day they change.\n\nYou are the only member of this group. If somebody joins it, the backup stops.", + 'phonesync.state_sending': "Sending…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copies sent: {n}", + 'phonesync.last_file': "Last copy: {name}", + 'phonesync.checking_groups': "Looking for groups only you belong to…", + 'phonesync.no_private_group': "You have no group of your own. Create a group that only you belong to, then come back here.", + 'phonesync.no_folder': "This group has no folder you can add to.", + 'phonesync.destination': "Copies go into {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Stopped: the node's disk is full.", + 'phonesync.fail_read_only': "Stopped: the folder no longer accepts uploads.", + 'phonesync.fail_no_folder': "Stopped: the folder no longer exists in the group.", + 'phonesync.fail_unavailable': "Stopped: the folder's drive is not connected to the node.", + 'phonesync.fail_not_member': "Stopped: you are no longer a member of this group.", + 'phonesync.fail_not_private': "Stopped: somebody else has joined the group. Choose a group only you belong to.", + 'phonesync.fail_permission': "Stopped: MeshBay no longer has access.", + 'phonesync.fail_other': "Stopped.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index b1b1328..586ff59 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1333,4 +1333,25 @@ export default { 'photosync.fail_not_member': "La copia de fotos se detuvo: ya no eres miembro de este grupo.", 'photosync.fail_permission': "La copia de fotos se detuvo: MeshBay ya no tiene acceso a las fotos.", 'photosync.fail_other': "La copia de fotos se detuvo.", + 'contactsync.heading': "Copia de seguridad de contactos", + 'contactsync.intro': "Enviar una copia de los contactos de este teléfono a una carpeta de un grupo del que solo tú eres miembro, una vez al día si han cambiado. Cada copia es un archivo .vcf que cualquier teléfono puede importar.", + 'contactsync.permission_needed': "MeshBay necesita acceso a los contactos de este teléfono.", + 'contactsync.confirm': "Se enviará una copia de tus contactos a {group}, en {dir}, y luego una nueva cada día que cambien.\n\nEres el único miembro de este grupo. Si alguien se une, la copia de seguridad se detiene.", + 'phonesync.state_sending': "Enviando…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copias enviadas: {n}", + 'phonesync.last_file': "Última copia: {name}", + 'phonesync.checking_groups': "Buscando grupos de los que solo tú eres miembro…", + 'phonesync.no_private_group': "No tienes ningún grupo propio. Crea un grupo del que solo tú seas miembro y vuelve aquí.", + 'phonesync.no_folder': "Este grupo no tiene ninguna carpeta en la que puedas añadir archivos.", + 'phonesync.destination': "Las copias van a {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Detenida: el disco del node está lleno.", + 'phonesync.fail_read_only': "Detenida: la carpeta ya no acepta envíos.", + 'phonesync.fail_no_folder': "Detenida: la carpeta ya no existe en el grupo.", + 'phonesync.fail_unavailable': "Detenida: la unidad de la carpeta no está conectada al node.", + 'phonesync.fail_not_member': "Detenida: ya no eres miembro de este grupo.", + 'phonesync.fail_not_private': "Detenida: otra persona se ha unido al grupo. Elige un grupo del que solo tú seas miembro.", + 'phonesync.fail_permission': "Detenida: MeshBay ya no tiene acceso.", + 'phonesync.fail_other': "Detenida.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 01b028b..a234a45 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1348,4 +1348,25 @@ export default { 'photosync.fail_not_member': "Sauvegarde des photos arrêtée : vous n'êtes plus membre de ce groupe.", 'photosync.fail_permission': "Sauvegarde des photos arrêtée : MeshBay n'a plus accès aux photos.", 'photosync.fail_other': "Sauvegarde des photos arrêtée.", + 'contactsync.heading': "Sauvegarde des contacts", + 'contactsync.intro': "Envoyer une copie des contacts de ce téléphone dans un dossier d'un groupe dont vous êtes le seul membre, une fois par jour s'ils ont changé. Chaque copie est un fichier .vcf que tout téléphone sait importer.", + 'contactsync.permission_needed': "MeshBay a besoin d'accéder aux contacts de ce téléphone.", + 'contactsync.confirm': "Une copie de vos contacts sera envoyée dans {group}, dans {dir}, puis une nouvelle chaque jour où ils changent.\n\nVous êtes le seul membre de ce groupe. Si quelqu'un le rejoint, la sauvegarde s'arrête.", + 'phonesync.state_sending': "Envoi…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copies envoyées : {n}", + 'phonesync.last_file': "Dernière copie : {name}", + 'phonesync.checking_groups': "Recherche des groupes dont vous êtes le seul membre…", + 'phonesync.no_private_group': "Vous n'avez aucun groupe à vous seul. Créez un groupe dont vous êtes le seul membre, puis revenez ici.", + 'phonesync.no_folder': "Ce groupe n'a aucun dossier où vous pouvez ajouter des fichiers.", + 'phonesync.destination': "Les copies vont dans {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Arrêtée : le disque du node est plein.", + 'phonesync.fail_read_only': "Arrêtée : le dossier n'accepte plus d'envois.", + 'phonesync.fail_no_folder': "Arrêtée : le dossier n'existe plus dans le groupe.", + 'phonesync.fail_unavailable': "Arrêtée : le disque du dossier n'est pas branché au node.", + 'phonesync.fail_not_member': "Arrêtée : vous n'êtes plus membre de ce groupe.", + 'phonesync.fail_not_private': "Arrêtée : quelqu'un d'autre a rejoint le groupe. Choisissez un groupe dont vous êtes le seul membre.", + 'phonesync.fail_permission': "Arrêtée : MeshBay n'y a plus accès.", + 'phonesync.fail_other': "Arrêtée.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 19e0c6d..9019007 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1347,4 +1347,25 @@ export default { 'photosync.fail_not_member': "Backup delle foto interrotto: non sei più membro di questo gruppo.", 'photosync.fail_permission': "Backup delle foto interrotto: MeshBay non ha più accesso alle foto.", 'photosync.fail_other': "Backup delle foto interrotto.", + 'contactsync.heading': "Backup dei contatti", + 'contactsync.intro': "Invia una copia dei contatti di questo telefono in una cartella di un gruppo di cui sei l'unico membro, una volta al giorno se sono cambiati. Ogni copia è un file .vcf che qualsiasi telefono può importare.", + 'contactsync.permission_needed': "MeshBay ha bisogno di accedere ai contatti di questo telefono.", + 'contactsync.confirm': "Una copia dei tuoi contatti verrà inviata a {group}, in {dir}, poi una nuova ogni giorno in cui cambiano.\n\nSei l'unico membro di questo gruppo. Se qualcuno si unisce, il backup si ferma.", + 'phonesync.state_sending': "Invio…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copie inviate: {n}", + 'phonesync.last_file': "Ultima copia: {name}", + 'phonesync.checking_groups': "Ricerca dei gruppi di cui sei l'unico membro…", + 'phonesync.no_private_group': "Non hai un gruppo tutto tuo. Crea un gruppo di cui sei l'unico membro, poi torna qui.", + 'phonesync.no_folder': "Questo gruppo non ha cartelle in cui puoi aggiungere file.", + 'phonesync.destination': "Le copie vanno in {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Interrotto: il disco del node è pieno.", + 'phonesync.fail_read_only': "Interrotto: la cartella non accetta più caricamenti.", + 'phonesync.fail_no_folder': "Interrotto: la cartella non esiste più nel gruppo.", + 'phonesync.fail_unavailable': "Interrotto: l'unità della cartella non è collegata al node.", + 'phonesync.fail_not_member': "Interrotto: non sei più membro di questo gruppo.", + 'phonesync.fail_not_private': "Interrotto: qualcun altro si è unito al gruppo. Scegli un gruppo di cui sei l'unico membro.", + 'phonesync.fail_permission': "Interrotto: MeshBay non ha più accesso.", + 'phonesync.fail_other': "Interrotto.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 07dcac4..043586f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1331,4 +1331,25 @@ export default { 'photosync.fail_not_member': "写真のバックアップを停止しました: このグループのメンバーではなくなりました。", 'photosync.fail_permission': "写真のバックアップを停止しました: MeshBay が写真にアクセスできなくなりました。", 'photosync.fail_other': "写真のバックアップを停止しました。", + 'contactsync.heading': "連絡先のバックアップ", + 'contactsync.intro': "このスマートフォンの連絡先のコピーを、あなただけがメンバーのグループのフォルダーに、変更があった日に1日1回送ります。各コピーはどのスマートフォンでも読み込める .vcf ファイルです。", + 'contactsync.permission_needed': "MeshBay にこのスマートフォンの連絡先へのアクセスを許可してください。", + 'contactsync.confirm': "連絡先のコピーを {group} の {dir} に送り、その後は変更があった日ごとに新しいコピーを送ります。\n\nこのグループのメンバーはあなただけです。誰かが参加すると、バックアップは停止します。", + 'phonesync.state_sending': "送信中…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "送信したコピー: {n}", + 'phonesync.last_file': "最後のコピー: {name}", + 'phonesync.checking_groups': "あなただけがメンバーのグループを探しています…", + 'phonesync.no_private_group': "あなた専用のグループがありません。あなただけがメンバーのグループを作成してから、ここに戻ってください。", + 'phonesync.no_folder': "このグループには、ファイルを追加できるフォルダーがありません。", + 'phonesync.destination': "コピーの保存先: {dir}", + 'phonesync.notice': "{what}。{reason}", + 'phonesync.fail_disk_full': "停止しました: node のディスクがいっぱいです。", + 'phonesync.fail_read_only': "停止しました: フォルダーがアップロードを受け付けなくなりました。", + 'phonesync.fail_no_folder': "停止しました: フォルダーがグループにもうありません。", + 'phonesync.fail_unavailable': "停止しました: フォルダーのドライブが node に接続されていません。", + 'phonesync.fail_not_member': "停止しました: このグループのメンバーではなくなりました。", + 'phonesync.fail_not_private': "停止しました: 他の人がグループに参加しました。あなただけがメンバーのグループを選んでください。", + 'phonesync.fail_permission': "停止しました: MeshBay はアクセスできなくなりました。", + 'phonesync.fail_other': "停止しました。", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index ab7abbd..5c1a0d2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1349,4 +1349,25 @@ export default { 'photosync.fail_not_member': "Fotoback-up gestopt: je bent geen lid meer van deze groep.", 'photosync.fail_permission': "Fotoback-up gestopt: MeshBay heeft geen toegang meer tot de foto's.", 'photosync.fail_other': "Fotoback-up gestopt.", + 'contactsync.heading': "Back-up van contacten", + 'contactsync.intro': "Stuur eenmaal per dag, als ze veranderd zijn, een kopie van de contacten op deze telefoon naar een map van een groep waarvan alleen jij lid bent. Elke kopie is een .vcf-bestand dat elke telefoon kan importeren.", + 'contactsync.permission_needed': "MeshBay heeft toegang nodig tot de contacten op deze telefoon.", + 'contactsync.confirm': "Een kopie van je contacten wordt naar {group} gestuurd, in {dir}, en daarna een nieuwe op elke dag dat ze veranderen.\n\nJij bent het enige lid van deze groep. Als iemand lid wordt, stopt de back-up.", + 'phonesync.state_sending': "Bezig met verzenden…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Verzonden kopieën: {n}", + 'phonesync.last_file': "Laatste kopie: {name}", + 'phonesync.checking_groups': "Zoeken naar groepen waarvan alleen jij lid bent…", + 'phonesync.no_private_group': "Je hebt geen eigen groep. Maak een groep waarvan alleen jij lid bent en kom dan hier terug.", + 'phonesync.no_folder': "Deze groep heeft geen map waaraan je bestanden kunt toevoegen.", + 'phonesync.destination': "Kopieën gaan naar {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Gestopt: de schijf van de node is vol.", + 'phonesync.fail_read_only': "Gestopt: de map accepteert geen uploads meer.", + 'phonesync.fail_no_folder': "Gestopt: de map bestaat niet meer in de groep.", + 'phonesync.fail_unavailable': "Gestopt: de schijf van de map is niet aangesloten op de node.", + 'phonesync.fail_not_member': "Gestopt: je bent geen lid meer van deze groep.", + 'phonesync.fail_not_private': "Gestopt: iemand anders is lid geworden van de groep. Kies een groep waarvan alleen jij lid bent.", + 'phonesync.fail_permission': "Gestopt: MeshBay heeft geen toegang meer.", + 'phonesync.fail_other': "Gestopt.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 6539e0d..86d1d34 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1375,4 +1375,25 @@ export default { 'photosync.fail_not_member': "Kopia zdjęć zatrzymana: nie jesteś już członkiem tej grupy.", 'photosync.fail_permission': "Kopia zdjęć zatrzymana: MeshBay nie ma już dostępu do zdjęć.", 'photosync.fail_other': "Kopia zdjęć zatrzymana.", + 'contactsync.heading': "Kopia kontaktów", + 'contactsync.intro': "Wysyłaj kopię kontaktów z tego telefonu do folderu grupy, której jesteś jedynym członkiem, raz dziennie, jeśli się zmieniły. Każda kopia to plik .vcf, który zaimportuje każdy telefon.", + 'contactsync.permission_needed': "MeshBay potrzebuje dostępu do kontaktów w tym telefonie.", + 'contactsync.confirm': "Kopia Twoich kontaktów zostanie wysłana do {group}, do {dir}, a potem nowa każdego dnia, w którym się zmienią.\n\nJesteś jedynym członkiem tej grupy. Jeśli ktoś do niej dołączy, kopia zostanie zatrzymana.", + 'phonesync.state_sending': "Wysyłanie…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Wysłane kopie: {n}", + 'phonesync.last_file': "Ostatnia kopia: {name}", + 'phonesync.checking_groups': "Szukanie grup, których jesteś jedynym członkiem…", + 'phonesync.no_private_group': "Nie masz własnej grupy. Utwórz grupę, której jesteś jedynym członkiem, i wróć tutaj.", + 'phonesync.no_folder': "Ta grupa nie ma folderu, do którego możesz dodawać pliki.", + 'phonesync.destination': "Kopie trafiają do {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Zatrzymano: dysk node'a jest pełny.", + 'phonesync.fail_read_only': "Zatrzymano: folder nie przyjmuje już plików.", + 'phonesync.fail_no_folder': "Zatrzymano: folder nie istnieje już w grupie.", + 'phonesync.fail_unavailable': "Zatrzymano: dysk folderu nie jest podłączony do node'a.", + 'phonesync.fail_not_member': "Zatrzymano: nie jesteś już członkiem tej grupy.", + 'phonesync.fail_not_private': "Zatrzymano: ktoś inny dołączył do grupy. Wybierz grupę, której jesteś jedynym członkiem.", + 'phonesync.fail_permission': "Zatrzymano: MeshBay nie ma już dostępu.", + 'phonesync.fail_other': "Zatrzymano.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index ac122c0..e499de2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1334,4 +1334,25 @@ export default { 'photosync.fail_not_member': "Backup de fotos parado: você não é mais membro deste grupo.", 'photosync.fail_permission': "Backup de fotos parado: o MeshBay não tem mais acesso às fotos.", 'photosync.fail_other': "Backup de fotos parado.", + 'contactsync.heading': "Backup de contatos", + 'contactsync.intro': "Enviar uma cópia dos contatos deste celular para uma pasta de um grupo do qual só você é membro, uma vez por dia se tiverem mudado. Cada cópia é um arquivo .vcf que qualquer celular consegue importar.", + 'contactsync.permission_needed': "O MeshBay precisa de acesso aos contatos deste celular.", + 'contactsync.confirm': "Uma cópia dos seus contatos será enviada para {group}, em {dir}, e depois uma nova a cada dia em que mudarem.\n\nVocê é o único membro deste grupo. Se alguém entrar, o backup para.", + 'phonesync.state_sending': "Enviando…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Cópias enviadas: {n}", + 'phonesync.last_file': "Última cópia: {name}", + 'phonesync.checking_groups': "Procurando grupos dos quais só você é membro…", + 'phonesync.no_private_group': "Você não tem nenhum grupo só seu. Crie um grupo do qual só você seja membro e volte aqui.", + 'phonesync.no_folder': "Este grupo não tem nenhuma pasta onde você possa adicionar arquivos.", + 'phonesync.destination': "As cópias vão para {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Parado: o disco do node está cheio.", + 'phonesync.fail_read_only': "Parado: a pasta não aceita mais envios.", + 'phonesync.fail_no_folder': "Parado: a pasta não existe mais no grupo.", + 'phonesync.fail_unavailable': "Parado: o disco da pasta não está conectado ao node.", + 'phonesync.fail_not_member': "Parado: você não é mais membro deste grupo.", + 'phonesync.fail_not_private': "Parado: outra pessoa entrou no grupo. Escolha um grupo do qual só você seja membro.", + 'phonesync.fail_permission': "Parado: o MeshBay não tem mais acesso.", + 'phonesync.fail_other': "Parado.", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 14664c4..14595b1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1320,4 +1320,25 @@ export default { 'photosync.fail_not_member': "照片备份已停止:您已不再是此群组的成员。", 'photosync.fail_permission': "照片备份已停止:MeshBay 已无法访问照片。", 'photosync.fail_other': "照片备份已停止。", + 'contactsync.heading': "联系人备份", + 'contactsync.intro': "在联系人有变动的日子里,每天一次将本机联系人的副本发送到一个只有你一个成员的群组的文件夹。每份副本都是任何手机都能导入的 .vcf 文件。", + 'contactsync.permission_needed': "MeshBay 需要访问本机的联系人。", + 'contactsync.confirm': "你的联系人副本将发送到 {group} 的 {dir},之后每逢联系人有变动的日子再发送一份新的。\n\n你是这个群组的唯一成员。如果有人加入,备份将停止。", + 'phonesync.state_sending': "正在发送…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "已发送副本:{n}", + 'phonesync.last_file': "最近一份副本:{name}", + 'phonesync.checking_groups': "正在查找只有你一个成员的群组…", + 'phonesync.no_private_group': "你没有自己专属的群组。请创建一个只有你一个成员的群组,然后回到这里。", + 'phonesync.no_folder': "这个群组没有你可以添加文件的文件夹。", + 'phonesync.destination': "副本保存到 {dir}", + 'phonesync.notice': "{what}。{reason}", + 'phonesync.fail_disk_full': "已停止:node 的磁盘已满。", + 'phonesync.fail_read_only': "已停止:该文件夹不再接受上传。", + 'phonesync.fail_no_folder': "已停止:该文件夹在群组中已不存在。", + 'phonesync.fail_unavailable': "已停止:该文件夹所在的磁盘未连接到 node。", + 'phonesync.fail_not_member': "已停止:你已不再是这个群组的成员。", + 'phonesync.fail_not_private': "已停止:有其他人加入了群组。请选择一个只有你一个成员的群组。", + 'phonesync.fail_permission': "已停止:MeshBay 已无法访问。", + 'phonesync.fail_other': "已停止。", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js new file mode 100644 index 0000000..7df83b9 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js @@ -0,0 +1,210 @@ +// The contacts section of the Android Sync page, on the Android application +// only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other client has it. + +import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { ask } from './ask.js'; +import { + KINDS, subscribe, runDocSync, privateGroups, probeWritable, failureText, +} from './phone-sync.js'; + +// What each kind says in its own words; everything else is shared. +const TEXT = { + contacts: { + intro: 'contactsync.intro', + permission: 'contactsync.permission_needed', + confirm: 'contactsync.confirm', + }, +}; + +function stateLine(st, status) { + switch (st.phase) { + case 'connecting': return t('photosync.state_connecting'); + case 'sending': return t('phonesync.state_sending'); + case 'offline': return t('photosync.state_offline'); + case 'interrupted': return t('photosync.state_interrupted'); + case 'up_to_date': return t('photosync.state_up_to_date'); + default: return status && status.lastCompleted ? t('photosync.state_up_to_date') : ''; + } +} + +export function DocSyncSection({ kind, user, groups }) { + const { sync, heading } = KINDS[kind]; + const [status, setStatus] = useState(null); + const [run, setRun] = useState({ phase: 'idle' }); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(''); + + const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]); + useEffect(() => { refresh(); }, [refresh]); + useEffect(() => subscribe(kind, (st) => { + setRun(st); + if (st.phase !== 'sending' && st.phase !== 'connecting') refresh(); + }), [kind, refresh]); + + if (!status) return null; + const cfg = status.config; + const busy = run.phase === 'sending' || run.phase === 'connecting'; + + const backUpNow = async () => { + setError(''); + try { await runDocSync(kind, { force: true }); } catch (e) { setError(e.message); } + }; + + const turnOff = async () => { + setError(''); + try { setStatus(await sync.configure(null)); } catch (e) { setError(e.message); } + }; + + return html` + <div class="settings-section"> + <h3 class="settings-heading">${t(heading)}</h3> + ${!cfg && !editing && html` + <p class="settings-hint">${t(TEXT[kind].intro)}</p> + <button class="admin-btn" onClick=${() => setEditing(true)}>${t('photosync.setup')}</button> + `} + ${cfg && !editing && html` + <div class="settings-row"> + <span class="settings-label settings-name"> + ${t('phonesync.summary', { group: cfg.groupName, dir: status.dir })} + </span> + </div> + ${cfg.account !== user.username && html` + <p class="settings-hint">${t('photosync.other_account', { account: cfg.account })}</p>`} + <p class="settings-hint"> + ${t('phonesync.copies_sent', { n: status.sent })} + ${' · '} + ${status.lastCompleted + ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() }) + : t('photosync.never')} + </p> + ${status.lastSent && html` + <p class="settings-hint settings-name">${t('phonesync.last_file', { name: status.lastSent })}</p>`} + ${status.failure + ? html`<p class="error-msg">${failureText(status.failure)}</p>` + : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`} + <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap"> + <button class="admin-btn" disabled=${busy} onClick=${backUpNow}>${t('photosync.now')}</button> + <button class="btn-secondary" onClick=${() => setEditing(true)}>${t('photosync.change')}</button> + <button class="btn-secondary" onClick=${turnOff}>${t('photosync.turn_off')}</button> + </div> + `} + ${editing && html` + <${Setup} kind=${kind} user=${user} groups=${groups} status=${status} + onPermission=${setStatus} + onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runDocSync(kind); } }} />`} + ${error && html`<p class="error-msg">${error}</p>`} + </div> + `; +} + +function Setup({ kind, user, groups, status, onPermission, onDone }) { + const { sync, suffix } = KINDS[kind]; + const cfg = status.config && status.config.account === user.username ? status.config : null; + const [candidates, setCandidates] = useState(null); // null while the hub is asked + const [groupId, setGroupId] = useState(cfg ? cfg.groupId : ''); + const [folders, setFolders] = useState(null); + const [probe, setProbe] = useState(''); // '', 'checking', 'offline' + const [folder, setFolder] = useState(cfg ? cfg.folder : ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const permitted = status.permission === 'granted'; + + // Only groups this account is alone in: what is sent is readable by every + // member, and an address book or a conversation is not the group's. + useEffect(() => { + let gone = false; + privateGroups(groups).then((list) => { + if (gone) return; + setCandidates(list); + setGroupId((g) => (list.some((x) => x.id === g) ? g : '')); + }); + return () => { gone = true; }; + }, [groups]); + + useEffect(() => { + setFolders(null); + if (!groupId) return undefined; + let gone = false; + setProbe('checking'); + probeWritable(groupId).then((r) => { + if (gone) return; + setProbe(''); + setFolders(r.folders); + setFolder((f) => (r.folders.includes(f) ? f : (r.folders[0] || ''))); + }).catch(() => { if (!gone) setProbe('offline'); }); + return () => { gone = true; }; + }, [groupId]); + + const allow = async () => { + setError(''); + try { onPermission(await sync.permit()); } catch (e) { setError(e.message); } + }; + + const dir = folder ? `${folder}/${user.username}-${suffix}` : ''; + + const start = async () => { + const group = (candidates || []).find((g) => g.id === groupId); + if (!group || !folder) return; + setBusy(true); + setError(''); + try { + if (!cfg || cfg.groupId !== groupId || cfg.folder !== folder) { + const yes = await ask(t(TEXT[kind].confirm, { group: group.name, dir })); + if (!yes) { setBusy(false); return; } + } + onDone(await sync.configure({ + account: user.username, groupId, groupName: group.name, + owner: group.owner_username || '', folder, + })); + } catch (e) { + setError(e.message); + setBusy(false); + } + }; + + return html` + <div> + ${!permitted && html` + <p class="settings-hint">${t(TEXT[kind].permission)}</p> + <div class="settings-row" style="gap:8px; justify-content:flex-start"> + <button class="admin-btn" onClick=${allow}>${t('photosync.permission_allow')}</button> + <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button> + </div> + `} + ${permitted && html` + ${candidates === null && html`<p class="settings-hint">${t('phonesync.checking_groups')}</p>`} + ${candidates && !candidates.length && html` + <p class="settings-hint">${t('phonesync.no_private_group')}</p>`} + ${candidates && candidates.length > 0 && html` + <div class="settings-row"> + <span class="settings-label">${t('photosync.group')}</span> + <select class="settings-select" value=${groupId} + onChange=${(e) => setGroupId(e.target.value)}> + <option value="">${t('photosync.choose_group')}</option> + ${candidates.map((g) => html`<option key=${g.id} value=${g.id}>${g.name}</option>`)} + </select> + </div> + `} + ${probe === 'checking' && html`<p class="settings-hint">${t('photosync.checking_group')}</p>`} + ${probe === 'offline' && html`<p class="settings-hint">${t('photosync.group_offline')}</p>`} + ${folders && !folders.length && html`<p class="settings-hint">${t('phonesync.no_folder')}</p>`} + ${folders && folders.length > 0 && html` + <div class="settings-row"> + <span class="settings-label">${t('photosync.folder')}</span> + <select class="settings-select" value=${folder} onChange=${(e) => setFolder(e.target.value)}> + ${folders.map((f) => html`<option key=${f} value=${f}>${f}</option>`)} + </select> + </div> + <p class="settings-hint settings-name">${t('phonesync.destination', { dir })}</p> + `} + <div class="settings-row" style="gap:8px; justify-content:flex-start"> + <button class="admin-btn" onClick=${start} + disabled=${busy || !groupId || !folder}>${t('photosync.start')}</button> + <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button> + </div> + `} + ${error && html`<p class="error-msg">${error}</p>`} + </div> + `; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js new file mode 100644 index 0000000..964f76c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js @@ -0,0 +1,249 @@ +// Contacts backup: a copy of this phone's address book sent to a folder of a +// group only its owner belongs to, once a day when it changed +// (docs/MESHBAY_DESIGN.md §9.13). +// +// The same split as photos (photo-sync.js): the phone writes what is new into +// one file and remembers what the node took; this module decides when a run +// is due and does the sending. Loaded only where the bridge offers it, the +// Android application. +// +// Additive by construction, like photos: every copy is a new dated file, and +// nothing here deletes, renames or replaces anything on the node. +// `test_phone_sync.py` reads this file for the operations that would. + +import { ConnectionPool } from './connection-pool.js'; +import * as platform from './platform.js'; +import { t } from './i18n.js'; +import { destinationProblem, makeDirs, upload } from './photo-sync.js'; + +const DAY_MS = 24 * 3600 * 1000; +// A run cut short by something passing is tried again at the next chance, +// but not more often than this. +const RETRY_MS = 15 * 60 * 1000; + +/** + * Each kind of data: the bridge object that reads it, its heading, and the + * folder it goes into under the chosen one (`<account>-<suffix>`, as the + * phone names it in DocPlan.kt). + */ +export const KINDS = { + contacts: { sync: platform.contactSync, heading: 'contactsync.heading', suffix: 'contacts' }, +}; + +// Refusals that will hold at the next run too: said once (a notification, +// from the phone) and retried a day later, never at every opening. +const LASTING = { + disk_full: 'phonesync.fail_disk_full', + root_read_only: 'phonesync.fail_read_only', + no_writable_root: 'phonesync.fail_read_only', + no_such_root: 'phonesync.fail_no_folder', + no_such_directory: 'phonesync.fail_no_folder', + root_unavailable: 'phonesync.fail_unavailable', + not_a_member: 'phonesync.fail_not_member', + not_private: 'phonesync.fail_not_private', + permission: 'phonesync.fail_permission', +}; + +export function failureText(code) { + return t(LASTING[code] || 'phonesync.fail_other'); +} + +// ── What the sections show ────────────────────────────────────────────────── + +const states = {}; +const listeners = {}; + +function setState(kind, patch) { + states[kind] = { ...(states[kind] || { phase: 'idle' }), ...patch }; + for (const fn of [...(listeners[kind] || [])]) { + try { fn(states[kind]); } catch (e) { console.error('[MeshBay] backup listener threw:', e); } + } +} + +export function subscribe(kind, fn) { + (listeners[kind] = listeners[kind] || new Set()).add(fn); + fn(states[kind] || { phase: 'idle' }); + return () => listeners[kind].delete(fn); +} + +// ── When it runs ──────────────────────────────────────────────────────────── + +let ctx = null; // { hub, hubFetch, getUser, getBundleKey } +const running = {}; +const timers = {}; +const lastAttempt = {}; + +function available() { + return Object.keys(KINDS).filter((k) => KINDS[k].sync.available); +} + +/** True when `status` says a scheduled run should go now. */ +export function isDue(status, now = Date.now()) { + if (!status || !status.config) return false; + if (status.failure && status.failureAt && now - status.failureAt < DAY_MS) return false; + const last = status.lastCompleted; + return last == null || now - last >= DAY_MS || now < last; +} + +/** Checked at start, on coming back to the foreground, and once the next run falls due. */ +export function startPhoneSync(context) { + if (ctx || !available().length) return; + ctx = context; + document.addEventListener('visibilitychange', onVisible); + for (const kind of available()) runDocSync(kind); +} + +export function stopPhoneSync() { + if (!ctx) return; + ctx = null; + for (const kind of Object.keys(timers)) clearTimeout(timers[kind]); + document.removeEventListener('visibilitychange', onVisible); +} + +function onVisible() { + if (document.visibilityState !== 'visible') return; + for (const kind of available()) runDocSync(kind); +} + +function scheduleNext(kind, status) { + clearTimeout(timers[kind]); + if (!ctx || !status || !status.config) return; + const base = Math.max(status.lastCompleted || 0, status.failure ? (status.failureAt || 0) : 0); + const wait = base + DAY_MS - Date.now(); + if (wait > 0) timers[kind] = setTimeout(() => runDocSync(kind), Math.min(wait, DAY_MS)); +} + +/** A run of one kind, if one is due; `force` is "Back up now". */ +export function runDocSync(kind, { force = false } = {}) { + if (!ctx || !KINDS[kind]) return Promise.resolve(); + if (running[kind]) return running[kind]; + const sync = KINDS[kind].sync; + running[kind] = (async () => { + let status = null; + try { + status = await sync.status(); + await run(kind, sync, status, force); + status = await sync.status(); + } catch (e) { + console.warn(`[MeshBay] ${kind} backup:`, e.message); + setState(kind, { phase: 'interrupted', detail: e.message }); + } finally { + running[kind] = null; + scheduleNext(kind, status); + } + })(); + return running[kind]; +} + +async function run(kind, sync, status, force) { + const cfg = status.config; + const user = ctx && ctx.getUser(); + if (!cfg) { setState(kind, { phase: 'off' }); return; } + if (!user || cfg.account !== user.username) { setState(kind, { phase: 'other_account' }); return; } + if (status.permission !== 'granted') { await lasting(kind, sync, 'permission'); return; } + if (!force && !isDue(status)) { + setState(kind, { phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' }); + return; + } + if (!force && Date.now() - (lastAttempt[kind] || 0) < RETRY_MS) return; + lastAttempt[kind] = Date.now(); + + // Asked of the hub at every run, not only at set-up: somebody who joins the + // group later would otherwise read every copy from then on. + setState(kind, { phase: 'connecting' }); + let alone; + try { + alone = await isPrivate(cfg.groupId, user); + } catch (e) { + if (/not a member|not found/i.test(e.message || '')) { await lasting(kind, sync, 'not_a_member'); return; } + setState(kind, { phase: 'offline' }); + return; + } + if (!alone) { await lasting(kind, sync, 'not_private'); return; } + + const { item } = await sync.plan(); + if (!item) { + await sync.completed(); + setState(kind, { phase: 'up_to_date' }); + return; + } + + const pool = new ConnectionPool(ctx.hub); + let conn; + try { + const bundleKey = await ctx.getBundleKey(); + conn = await pool.connect(cfg.groupId, user.token, bundleKey, user.username, user.userId); + } catch (e) { + pool.closeAll(); + if (e.reason === 'not_a_member') { await lasting(kind, sync, 'not_a_member'); return; } + setState(kind, { phase: 'offline' }); + return; + } + + try { + const index = await conn.transport.fetchIndex(); + const problem = destinationProblem(cfg.folder, index); + if (problem) { await lasting(kind, sync, problem); return; } + const dirs = new Set(index.dirs || []); + for (const r of index.roots || []) dirs.add(r.name); + setState(kind, { phase: 'sending', count: item.count }); + try { + await makeDirs(conn.transport, cfg.folder, item.dir, dirs); + const ack = await upload(conn.transport, item, (token, name) => sync.file(token, name)); + await sync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name); + } catch (e) { + if (e.code && LASTING[e.code]) { await lasting(kind, sync, e.code); return; } + throw e; + } + await sync.completed(); + setState(kind, { phase: 'up_to_date' }); + } finally { + pool.closeAll(); + } +} + +async function lasting(kind, sync, code) { + setState(kind, { phase: 'failed', reason: code }); + const text = t('phonesync.notice', { what: t(KINDS[kind].heading), reason: failureText(code) }); + await sync.failed(code, text).catch(() => {}); +} + +// ── For the sections ──────────────────────────────────────────────────────── + +/** True when this account is the group's one member and nobody is invited. */ +export async function isPrivate(groupId, user) { + const r = await ctx.hubFetch(`/v1/groups/${groupId}/members`, { token: user.token }); + const members = r.members || []; + return members.length === 1 && members[0].username === user.username + && !(r.invited || []).length; +} + +/** The groups among `groups` this account is alone in; a failed look leaves one out. */ +export async function privateGroups(groups) { + const user = ctx && ctx.getUser(); + if (!user) return []; + const alone = await Promise.all(groups.map((g) => isPrivate(g.id, user).catch(() => false))); + return groups.filter((_, i) => alone[i]); +} + +/** Every folder a member can add to: on a writable root whose drive is there. */ +export function writableFolders(index) { + const roots = new Set((index.roots || []) + .filter((r) => r.writable && r.available !== false).map((r) => r.name)); + const all = new Set([...(index.dirs || []), ...roots]); + return [...all].filter((d) => roots.has(d.split('/')[0])).sort((a, b) => a.localeCompare(b)); +} + +/** A one-off look at a group, for choosing a folder: connected, read, closed. */ +export async function probeWritable(groupId) { + const user = ctx && ctx.getUser(); + if (!user) throw new Error('Not signed in'); + const pool = new ConnectionPool(ctx.hub); + try { + const bundleKey = await ctx.getBundleKey(); + const conn = await pool.connect(groupId, user.token, bundleKey, user.username, user.userId); + return { folders: writableFolders(await conn.transport.fetchIndex()) }; + } finally { + pool.closeAll(); + } +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js index 74084b8..77d050e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js @@ -229,8 +229,11 @@ async function send(transport, cfg, items, metered) { setState({ phase: 'up_to_date', done, total: items.length }); } -/** One photo, under a slot the node granted — the backup queues like anyone. */ -async function upload(transport, item) { +/** + * One photo, under a slot the node granted: the backup queues like anyone. + * `file` is how its bytes come from the phone; contacts and messages pass theirs. + */ +export async function upload(transport, item, file = platform.photoSync.file) { const lease = transport.openTransfer({ kind: 'upload', bytes: item.size }); try { let timeout; @@ -240,8 +243,8 @@ async function upload(transport, item) { timeout = setTimeout(() => reject(new Error('The node is busy')), LEASE_WAIT_MS); }), ]).finally(() => clearTimeout(timeout)); - const file = await platform.photoSync.file(item.token, item.name); - return await transport.uploadFile(file, { + const bytes = await file(item.token, item.name); + return await transport.uploadFile(bytes, { dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr, }); } finally { @@ -250,7 +253,7 @@ async function upload(transport, item) { } /** `<folder>/YYYY/YYYY-MM`, one level at a time; a level that exists is fine. */ -async function makeDirs(transport, folder, dir, dirs) { +export async function makeDirs(transport, folder, dir, dirs) { let at = folder; for (const name of dir.slice(folder.length + 1).split('/').filter(Boolean)) { const next = `${at}/${name}`; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index c5ae9ee..1715d5f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -255,6 +255,33 @@ export const photoSync = { }, }; +/** + * Backing up one kind of this phone's data (the contacts) to a group of + * which the person is the only member (docs/MESHBAY_DESIGN.md §9.13). Android + * only, like `photoSync`. See phone-sync.js. + */ +function docSync(name) { + const b = bridge && bridge[name]; + return { + available: Boolean(b), + status() { return b.status(); }, + permit() { return b.permit(); }, + configure(settings) { return b.configure(settings); }, + plan() { return b.plan(); }, + sent(token, dir, n) { return b.sent(token, dir, n); }, + completed() { return b.completed(); }, + failed(code, text) { return b.failed(code, text); }, + /** The file the last plan wrote, as a File named for the node. */ + async file(token, fileName) { + const r = await fetch(`/phonesync/${encodeURIComponent(token)}`, { cache: 'no-store' }); + if (!r.ok) throw new Error('This file is no longer on the phone'); + return new File([await r.blob()], fileName); + }, + }; +} + +export const contactSync = docSync('contactSync'); + /** Pick a directory to add as a group root. Returns { path, name } or null. */ export const rootPicker = { available: Boolean(bridge && bridge.rootPicker), diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index 5262aae..b92cfef 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -138,12 +138,16 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each(): SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt", SRC / "photos" / "PhotoChannels.kt"): native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M)) + # Contacts and messages share one class, its channels named by a prefix. + docs = set(re.findall(r'^\s*"(\w+)" ->', _read(SRC / "phonesync" / "DocChannels.kt"), flags=re.M)) + for prefix in re.findall(r'DocChannels\("(\w+)"', _read(SRC / "MainActivity.kt")): + native |= {f"{prefix}:{name}" for name in docs} shim = _shim_channels() assert shim, "no channel found in the shim" - # A phone has a push distributor to talk to and a camera roll to back up, - # and a desktop has neither; those families are the ones the desktop lacks + # A phone has a push distributor to talk to and a camera roll and an + # address book to back up, and a desktop has none of them; those families are the ones the desktop lacks # rather than the ones it shares. - phone_only = {c for c in shim if c.startswith(("push:", "photosync:"))} + phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "contactsync:"))} assert any(c.startswith("push:") for c in phone_only), "the push channels are gone" assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone" assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}" diff --git a/packages/meshbay-hub/tests/test_first_load_is_lean.py b/packages/meshbay-hub/tests/test_first_load_is_lean.py index 324ba0c..2e24a6c 100644 --- a/packages/meshbay-hub/tests/test_first_load_is_lean.py +++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py @@ -24,8 +24,9 @@ ON_DEMAND = { "chat-app-settings.js", "video-app-settings.js", "music-app-settings.js", "photos-app-settings.js", "helloworld-app-settings.js", "video-player.js", "group-settings.js", "search-page.js", - # The Android application's photo backup: no browser or desktop has it. + # The Android application's backups: no browser or desktop has them. "photo-sync.js", "photo-sync-settings.js", "android-sync-page.js", + "phone-sync.js", "phone-sync-settings.js", } diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 124bfb8..6dddcc3 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -54,8 +54,9 @@ STATIC_FILES = [ "menu.js", "playlist-menu.js", # The page's own confirm/alert, mounted outside the app tree. "ask.js", - # The Android application's photo backup, on its own page loaded on demand. + # The Android application's backups, on their own page loaded on demand. "settings-page.js", "android-sync-page.js", "photo-sync-settings.js", + "phone-sync-settings.js", ] pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") diff --git a/packages/meshbay-hub/tests/test_phone_sync.py b/packages/meshbay-hub/tests/test_phone_sync.py new file mode 100644 index 0000000..7e5a88e --- /dev/null +++ b/packages/meshbay-hub/tests/test_phone_sync.py @@ -0,0 +1,266 @@ +""" +The phone's contacts backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.13). + +Run under Node against the real `phone-sync.js`, with what it talks to +replaced: the Android bridge (`window.meshbay.contactSync`, which writes the +file and remembers what the node took), the hub's member list, and the group +connection (`connection-pool.js`, swapped in the copy). What is pinned is what +the person was promised: once a day, one dated file into +`<folder>/<account>-contacts`, only while they are alone in the group, nothing +ever deleted on the node, and a lasting refusal said once. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SYNC = STATIC / "phone-sync.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not SYNC.exists(), + reason="node or the SPA sources are not available") + +DAY = 24 * 3600 * 1000 + +FAKE_POOL = r""" +export class ConnectionPool { + constructor(hub) { globalThis.fake.pools.push(hub); } + async connect(groupId) { + if (globalThis.fake.refuseConnect) { + const e = new Error('refused'); e.reason = globalThis.fake.refuseConnect; throw e; + } + globalThis.fake.log.push(['connect', groupId]); + return { transport: globalThis.fake.transport, ack: {} }; + } + closeAll() { globalThis.fake.log.push(['close']); } +} +""" + +PRELUDE = r""" +const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null, + members: [{ username: 'bob' }], invited: [], membersError: null }; +const win = new EventTarget(); +const doc = new EventTarget(); +doc.visibilityState = 'visible'; +globalThis.window = win; +globalThis.document = doc; +globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} }; +globalThis.fetch = async (url) => { + fake.log.push(['fetch', url]); + return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) }; +}; + +fake.status = { + permission: 'granted', sent: 0, now: Date.now(), + config: { account: 'bob', groupId: 'g1', groupName: 'Mine', owner: 'bob', folder: 'Backups' }, + dir: 'Backups/bob-contacts', + lastCompleted: null, lastSent: null, failure: null, failureAt: null, +}; +fake.item = { token: 't1', name: 'contacts-2026-10-10-0900.vcf', dir: 'Backups/bob-contacts', + size: 4, count: 12 }; +fake.index = { + roots: [{ name: 'Backups', writable: true, available: true }], + dirs: [], + entries: [], +}; +fake.transport = { + connected: true, + async fetchIndex() { return fake.index; }, + async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; }, + openTransfer({ kind, bytes }) { + fake.log.push(['lease', kind, bytes]); + return { tr: 'tr-' + bytes, acquire: async () => {}, + release: () => fake.log.push(['release', bytes]) }; + }, + async uploadFile(file, opts) { + fake.log.push(['upload', opts.dir, file.name, opts.tr]); + if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; } + return { stored_as: file.name, dir: opts.dir }; + }, +}; +const hubFetch = async (path) => { + fake.log.push(['hub', path]); + if (fake.membersError) throw new Error(fake.membersError); + return { members: fake.members, invited: fake.invited }; +}; +win.meshbay = { contactSync: { + status: async () => fake.status, + plan: async () => { fake.log.push(['plan']); return { item: fake.item }; }, + sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; }, + completed: async () => { + fake.log.push(['completed']); + fake.status.lastCompleted = Date.now(); + return fake.status; + }, + failed: async (code, text) => { fake.log.push(['failed', code, text]); return true; }, +} }; +""" + + +def _run(body, tmp_path): + root = tmp_path / "static" + shutil.copytree(STATIC, root) + (root / "package.json").write_text('{"type":"module"}', encoding="utf-8") + (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8") + script = tmp_path / "case.mjs" + script.write_text( + PRELUDE + + f"const m = await import('{(root / 'phone-sync.js').as_uri()}');\n" + + "const ctx = { hub: 'https://hub.invalid', hubFetch, getUser: () => ({ username: 'bob', " + "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n" + + body + # The next run's timer would keep Node alive for a day. + + "\nm.stopPhoneSync();\nconsole.log(JSON.stringify(fake.log));\n", + encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + encoding="utf-8", timeout=30) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def _start(extra=""): + return extra + "\nm.startPhoneSync(ctx);\nawait m.runDocSync('contacts');\n" + + +def _of(log, kind): + return [e[1:] for e in log if e[0] == kind] + + +# ── a run ─────────────────────────────────────────────────────────────────── + +def test_a_due_run_sends_one_dated_file_into_the_account_folder(tmp_path): + log = _run(_start(), tmp_path) + assert _of(log, "mkdir") == [["Backups/bob-contacts"]] + assert _of(log, "upload") == [["Backups/bob-contacts", "contacts-2026-10-10-0900.vcf", "tr-4"]] + assert _of(log, "fetch") == [["/phonesync/t1"]] + assert _of(log, "sent") == [["t1", "Backups/bob-contacts", "contacts-2026-10-10-0900.vcf"]] + assert ["completed"] in log + assert _of(log, "release") == [[4]] + + +def test_a_folder_already_there_is_not_made_again(tmp_path): + log = _run(_start("fake.index.dirs = ['Backups/bob-contacts'];"), tmp_path) + assert _of(log, "mkdir") == [] + assert len(_of(log, "upload")) == 1 + + +def test_nothing_new_finishes_the_run_without_connecting(tmp_path): + log = _run(_start("fake.item = null;"), tmp_path) + assert ["completed"] in log + assert _of(log, "connect") == [] + + +def test_nothing_happens_before_a_day_has_passed(tmp_path): + log = _run(_start("fake.status.lastCompleted = Date.now() - 3600 * 1000;"), tmp_path) + assert _of(log, "plan") == [] and _of(log, "hub") == [] + + +def test_a_day_later_it_runs(tmp_path): + log = _run(_start(f"fake.status.lastCompleted = Date.now() - {DAY + 1000};"), tmp_path) + assert len(_of(log, "upload")) == 1 + + +def test_back_up_now_runs_whenever(tmp_path): + log = _run(""" +fake.status.lastCompleted = Date.now() - 1000; +m.startPhoneSync(ctx); +await m.runDocSync('contacts'); +await m.runDocSync('contacts', { force: true }); +""", tmp_path) + assert len(_of(log, "upload")) == 1 + + +# ── alone in the group, at every run ──────────────────────────────────────── + +def test_the_group_is_asked_about_at_every_run(tmp_path): + log = _run(_start(), tmp_path) + assert _of(log, "hub") == [["/v1/groups/g1/members"]] + + +@pytest.mark.parametrize("setup", [ + "fake.members = [{ username: 'bob' }, { username: 'carol' }];", + "fake.invited = [{ username: 'carol' }];", +]) +def test_somebody_else_in_the_group_stops_it_before_anything_is_read(tmp_path, setup): + log = _run(_start(setup), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["not_private"] + assert _of(log, "plan") == [] and _of(log, "upload") == [] + + +def test_a_member_removed_from_the_group_is_told_once(tmp_path): + log = _run(_start("fake.membersError = 'Not a member';"), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["not_a_member"] + + +def test_an_unreachable_hub_is_not_a_lasting_refusal(tmp_path): + log = _run(_start("fake.membersError = 'Failed to fetch';"), tmp_path) + assert _of(log, "failed") == [] and _of(log, "plan") == [] + + +def test_only_groups_this_account_is_alone_in_are_offered(tmp_path): + log = _run(""" +m.startPhoneSync(ctx); +const real = ctx.hubFetch; +ctx.hubFetch = async (path) => path.includes('/g2/') + ? { members: [{ username: 'bob' }, { username: 'carol' }] } + : path.includes('/g3/') ? Promise.reject(new Error('offline')) : real(path); +fake.log.push(['offered', (await m.privateGroups( + [{ id: 'g1', name: 'Mine' }, { id: 'g2', name: 'Family' }, { id: 'g3', name: 'Far' }])) + .map((g) => g.id)]); +""", tmp_path) + assert _of(log, "offered") == [[["g1"]]] + + +# ── refusals ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("code", ["disk_full", "root_read_only"]) +def test_a_lasting_refusal_stops_and_is_said(tmp_path, code): + log = _run(_start(f"fake.uploadError = '{code}';"), tmp_path) + failed = _of(log, "failed") + assert [e[0] for e in failed] == [code] + assert failed[0][1], "the phone is given nothing to say" + assert ["completed"] not in log + + +def test_a_folder_gone_is_found_before_sending(tmp_path): + log = _run(_start("fake.index.roots = [];"), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["no_such_root"] + assert _of(log, "upload") == [] + + +def test_without_the_permission_it_stops_and_says_so(tmp_path): + log = _run(_start("fake.status.permission = 'denied';"), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["permission"] + + +def test_another_account_on_the_phone_sends_nothing(tmp_path): + log = _run(_start("fake.status.config.account = 'alice';"), tmp_path) + assert _of(log, "plan") == [] and _of(log, "hub") == [] + + +# ── folders offered ───────────────────────────────────────────────────────── + +def test_every_writable_folder_is_offered_not_only_photo_ones(tmp_path): + log = _run(""" +fake.log.push(['folders', m.writableFolders( + { roots: [{ name: 'Media', writable: true, available: true }, + { name: 'Archive', writable: false, available: true }, + { name: 'Ejected', writable: true, available: false }], + dirs: ['Media/Films', 'Media/Photos', 'Archive/Old', 'Ejected/Stuff'] })]); +""", tmp_path) + assert _of(log, "folders") == [[["Media", "Media/Films", "Media/Photos"]]] + + +# ── nothing is ever removed ───────────────────────────────────────────────── + +def test_the_backup_has_no_way_to_remove_anything_on_the_node(): + """Every copy is a new file; the property is the absence of a call.""" + src = SYNC.read_text(encoding="utf-8") + for op in ("deleteFile", "deleteDirectory", "removeRoot", "file_delete", "dir_delete", + "root_remove", "rename"): + assert not re.search(rf"\b{op}\b", src), f"phone-sync.js reaches {op}" |