From cd326c0ae6313d315a82b927d711c85eb22d6f29 Mon Sep 17 00:00:00 2001 From: Iu Date: Fri, 16 Oct 2026 23:18:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(android):=20=D0=B4=D0=B2=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BD=D0=BD=D1=8F=D1=8F=20=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=B1=D0=BE=D0=BC=D0=B0=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=B0=20=D1=81=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Связанная пара «альбом устройства → серверный альбом» становится миррором: серверные фото материализуются на устройстве (оригинал в режиме копий, превью/ постер в переносе), состав меняется в обе стороны. - Признак происхождения (OWN/MIRROR) в индексе и метке провенанса; БД v8. - Материализация серверных фото (MirrorManager) с датой съёмки и меткой. - Дедуп, материализация ровно в один бакет при n:1, baseline «только новое»/«всё». - Двустороннее удаление с учётом прав; для зеркал намерением считается только явное удаление в приложении (надгробие), пропавшая локальная копия материализуется заново — иначе терялись серверные оригиналы. - Неудачное чтение серверного списка не считается пустым альбомом. - Устойчивое выключение синхронизации (syncDisabledAt): восстановление происхождения не возвращает связь. - Очередь загрузки — одно дренирующее задание вместо задания на файл (JobScheduler переставал запускать остаток на больших альбомах). - Дебаунс наблюдателя MediaStore и кэш sha256 по (size, dateModified). Покрыто unit-тестами и instrumented-тестами (режимы A/B/C и переключения, двусторонний миррор, объём 100 фото + 20 видео на тест). --- .../app/src/androidTest/assets/video-01.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-02.mp4 | Bin 0 -> 1632 bytes .../app/src/androidTest/assets/video-03.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-04.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-05.mp4 | Bin 0 -> 1638 bytes .../app/src/androidTest/assets/video-06.mp4 | Bin 0 -> 1646 bytes .../app/src/androidTest/assets/video-07.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-08.mp4 | Bin 0 -> 1634 bytes .../app/src/androidTest/assets/video-09.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-10.mp4 | Bin 0 -> 1638 bytes .../app/src/androidTest/assets/video-11.mp4 | Bin 0 -> 1640 bytes .../app/src/androidTest/assets/video-12.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-13.mp4 | Bin 0 -> 1639 bytes .../app/src/androidTest/assets/video-14.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-15.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-16.mp4 | Bin 0 -> 1636 bytes .../app/src/androidTest/assets/video-17.mp4 | Bin 0 -> 1639 bytes .../app/src/androidTest/assets/video-18.mp4 | Bin 0 -> 1640 bytes .../app/src/androidTest/assets/video-19.mp4 | Bin 0 -> 1641 bytes .../app/src/androidTest/assets/video-20.mp4 | Bin 0 -> 1641 bytes .../DeviceAlbumGridInstrumentedTest.kt | 116 +++++++ .../galo/android/OffloadInstrumentedTest.kt | 8 +- .../java/com/galo/android/SyncModesE2ETest.kt | 307 ++++++++++++++++++ .../com/galo/android/SyncVolumeE2ETest.kt | 236 ++++++++++++++ .../java/com/galo/android/TestMedia.kt | 117 +++++++ .../com/galo/android/TwoWayMirrorE2ETest.kt | 306 +++++++++++++++++ .../com/galo/android/data/db/GaloDatabase.kt | 42 ++- .../com/galo/android/data/db/dao/SyncDao.kt | 21 ++ .../android/data/db/entity/SyncEntities.kt | 33 ++ .../galo/android/data/repo/SyncRepository.kt | 70 +++- .../galo/android/data/sync/ApiSyncRemote.kt | 87 ++++- .../android/data/sync/DeviceMediaObserver.kt | 20 +- .../android/data/sync/LocalAssetProvider.kt | 20 +- .../galo/android/data/sync/MirrorManager.kt | 174 ++++++++++ .../galo/android/data/sync/OffloadManager.kt | 1 + .../galo/android/data/sync/ProvenanceStamp.kt | 4 + .../galo/android/data/sync/SyncCoordinator.kt | 195 ++++++++++- .../com/galo/android/data/sync/SyncPlan.kt | 24 ++ .../galo/android/data/sync/SyncRecovery.kt | 6 + .../com/galo/android/data/sync/SyncRemote.kt | 29 ++ .../android/data/upload/UploadRepository.kt | 28 +- .../galo/android/data/upload/UploadWorker.kt | 111 ++++--- .../java/com/galo/android/di/AppContainer.kt | 21 +- .../com/galo/android/domain/model/Sync.kt | 21 ++ .../ui/devicealbum/DeviceAlbumScreen.kt | 50 ++- .../ui/devicealbum/DeviceAlbumViewModel.kt | 54 ++- .../com/galo/android/data/db/MigrationTest.kt | 115 +++++++ .../com/galo/android/data/db/SyncDaoTest.kt | 41 +++ .../android/data/repo/SyncRepositoryTest.kt | 14 + .../android/data/sync/ApiSyncRemoteTest.kt | 43 +++ .../android/data/sync/ProvenanceStampTest.kt | 24 ++ .../android/data/sync/SyncCoordinatorTest.kt | 292 ++++++++++++++++- .../galo/android/data/sync/SyncPlannerTest.kt | 96 ++++++ .../android/data/sync/SyncRecoveryTest.kt | 58 ++++ .../devicealbum/DeviceAlbumOriginalsTest.kt | 16 + .../ui/devicealbum/MirrorScopeDialogTest.kt | 54 +++ 56 files changed, 2766 insertions(+), 88 deletions(-) create mode 100644 android/app/src/androidTest/assets/video-01.mp4 create mode 100644 android/app/src/androidTest/assets/video-02.mp4 create mode 100644 android/app/src/androidTest/assets/video-03.mp4 create mode 100644 android/app/src/androidTest/assets/video-04.mp4 create mode 100644 android/app/src/androidTest/assets/video-05.mp4 create mode 100644 android/app/src/androidTest/assets/video-06.mp4 create mode 100644 android/app/src/androidTest/assets/video-07.mp4 create mode 100644 android/app/src/androidTest/assets/video-08.mp4 create mode 100644 android/app/src/androidTest/assets/video-09.mp4 create mode 100644 android/app/src/androidTest/assets/video-10.mp4 create mode 100644 android/app/src/androidTest/assets/video-11.mp4 create mode 100644 android/app/src/androidTest/assets/video-12.mp4 create mode 100644 android/app/src/androidTest/assets/video-13.mp4 create mode 100644 android/app/src/androidTest/assets/video-14.mp4 create mode 100644 android/app/src/androidTest/assets/video-15.mp4 create mode 100644 android/app/src/androidTest/assets/video-16.mp4 create mode 100644 android/app/src/androidTest/assets/video-17.mp4 create mode 100644 android/app/src/androidTest/assets/video-18.mp4 create mode 100644 android/app/src/androidTest/assets/video-19.mp4 create mode 100644 android/app/src/androidTest/assets/video-20.mp4 create mode 100644 android/app/src/androidTest/java/com/galo/android/DeviceAlbumGridInstrumentedTest.kt create mode 100644 android/app/src/androidTest/java/com/galo/android/SyncModesE2ETest.kt create mode 100644 android/app/src/androidTest/java/com/galo/android/SyncVolumeE2ETest.kt create mode 100644 android/app/src/androidTest/java/com/galo/android/TestMedia.kt create mode 100644 android/app/src/androidTest/java/com/galo/android/TwoWayMirrorE2ETest.kt create mode 100644 android/app/src/main/java/com/galo/android/data/sync/MirrorManager.kt create mode 100644 android/app/src/test/java/com/galo/android/data/db/MigrationTest.kt create mode 100644 android/app/src/test/java/com/galo/android/data/sync/ApiSyncRemoteTest.kt create mode 100644 android/app/src/test/java/com/galo/android/data/sync/SyncRecoveryTest.kt create mode 100644 android/app/src/test/java/com/galo/android/ui/devicealbum/MirrorScopeDialogTest.kt diff --git a/android/app/src/androidTest/assets/video-01.mp4 b/android/app/src/androidTest/assets/video-01.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..71d90c42e9d23457ca02ed8cd2befab60133ac39 GIT binary patch literal 1641 zcmaJ>L66%+6rN;DwW^RPQc-(Yq^T%U4~^GOLX=(A)t03q5+E%SRHy=_6MMY2Vtc$Z zlkBEkg2VwKjtHT0=@EVbT#)v_0WMsjh(ozldiBTD2bw(PaDaL`96>X2PwKp=Q#*DEv8^uHc z3DvQWk=0@d=lgb8)?8a5&a+YN6LO-?6YEY9bGwYYBtvU}He_mSX(@L)&s*lI{gs7@ zfRAV4$#=gD^JVt#4`pN{4pTkP!Ny4_7nt--tW{6sVJeAJ`!Y-N2;kf}E0eBn`%+l> zHbO0lLqh&%M>^#duDxgMAO-%;ShF|~;(Seq22HY>v$9O_+C4xTB()db1i#}tEzj}X zCQp;WQM1#ooaKCBHiLG++O55Md}j6sJHHS8+j{5tHJm#;MbqBZ-rZ@9or-^c?nkiC z7Qz(JRG-GVfz|Woo`;C9xN_{7DYk5UrHmXZqLi#SVak63`H4C zFTl{@2p#r1=&+0RdZZ<;M*ZImEa-PmKQo){{eI)m?;qapef!0gU-|u){{DxKfj4-d zwB#P3Zh3B(w>;@>MO$$691u3&+Pm?3qs_11+yyg~0UW!kn4~hYyy?2FM$>J!V2rIT zwl_Bq4-Xx>m?{;yqxAkJRdHgQrQoQ-CMq}EJP-pB_%6rg^F1EQL8^kG?{4GAMK02b zk+kBO^zmVEGa3N7yf72cG52-odyWeVCY~inGVD_>is);R?@Qn7@F3PI6Mckw+-jMo zi2LDwlrKyW)8~62|rmGfWBu%@Z6HeHBGUQjm=`0*v3{sZv7` zW6@vOJZ{n?z*ZIpm*=`H5hR(gw2dUUQl|nW*azuIi%CCFSs}^|p*Q$LN^+zCQ48Wk zT4a*;eJGRt*cL!TIf2`6zOsOQeCfXDaudj01|!N<##2wUG%?mX@BQsJ!nw9l(O}lv*75oh=xQm4)BtNrA57?9lW!PUygQ*%bGzD?j;o`}t2$iTY&x;&I6t0<#Aq2q)k>s#}l0?WM)6+dO4!cV_Bw2$4{$LLpM1)RFclCBN-Cey^ zHM2X(Wdb<}ibs)n$x$zQR0LrU9y|%&jCjzCL`3})Cg1D%k#UK>>8khDd*6Gn`c?HX z#&~SUWolH;Se=o^Xo*qifXZ0)sNsS#r=%STBBy&`Lm;D5wwjGia*uLaCU)>oe} zpN8t#$Ixmqfb%^&sA{gQ5a-#b_7OQ!=c#okh`Ck8U67GAK$E+` zb6Y%1`vzH}?!WMuVL-wrV_J#W~3+6}WgGIuMdY{5ovm%(S$DU*XH}-JbhM z!CwDhrV0P=K&??m+j4krmMI=Cab{dvWz(aT&BIOX~en8tLrzy!5$Ml<-_6 zs$hBnh7QN*u-8S0U93;UTHCoktJ&P&-*@O@s!SA)(z{Kn;v_cDz)_`5RbjSxDEcDwU5?A=dpwf;OoapA-NKKH zLS$njX~lEtik()YSNOti{HfKZQH zEwd~&U~C+0M4<%;_j1gU6wz%}NWbM>@;n|3V}sHR(h@=Q1bbzm;@C(EvWZ53@!LF8 zY9JCUf`!fFCQCzXWnpl6p{o*MnhQ(YNDC`muAO(n8 z5GU3mm$dJFneHaG1RBUO+pk!J>O*$+_R>9|K8T~KOVB{ zAH6w~Z~pw^zn?D=a{StTe&z0mGjw%{Zk>8SpC7!FC3>(#hfDP90)5Jshyy(aPuOqE literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-03.mp4 b/android/app/src/androidTest/assets/video-03.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..060d923442991fb5f1e667231c6782c14295bf24 GIT binary patch literal 1641 zcmaJ>&x;&I6t3BgVF`i}BFSNe5+mf0>Fu5whuI}98`t293VYBXL7CKaS8q4d)zw>7 zGrJQnBX|(;C?fHaqy7bY5%%CgFJ3j`A$kb$pyI{r_`RMV8JCbZQ}w=j?|bi6zp8G= z7*E`&%&b<7tuWFU4Ka)YRki}gR$o_I4;f>*8m2MylB_;}{{gEp`ahKaEnq&e-hRk@ z6k5SP2F{2+obS4RRdaQTILk)0PsoWnkE}aJ%*`t9qKusdT9>(Xm8IJ0EN_*o_NR(7 z0UuZK$!EU=^CkB7cU5FPjdL^0!G>8Z=a}?$to5!a;#?A^_PNT61mN6IRY{k(eKDT; z)?*`yLqh&12PWrJTzkj5UJm??b#{Ir#QBmA4Vq**=hQOBWA6geAgMj`2Ka5?YX)B6 zH+Y`)jvDRO)LG3JCKG56tex7+$7d$LvGe=TzpS;7U%|PvQ#9>e*}Xlku~X@fPkj&e z*;3dNn(EUy*K=mp-1D&0U>B^b8V^`;&M-;^E}n-Dgk&DS4qG%eEo|Ud_zHZtWz(k!wEVZw9#Q7>$Sv4T#c1~6#>(Z$rcC_HWUH>ir2I+cT?OPA@wZt+O;L=^fQmoE%>EPJ_*`k}vt9~Xtl zM^@5`D;eU&;C9dh@_A`TpkwZuI1D@=6ii%YM>6hGE{f z^UQ+LIqJlb1Beb3=2(jOmM&!22rdT!PlR>d()P0wL9+x0WmhMOl@#Psg8&;gd9HO| zq*!$4wt(9_i?Efs!RLjkN<^6wj<%5%PMTbR1bZ(Z7%}QbT9u;O5PE|TQdS@Zh(-`6 zF+xe&_o2-8Q&$4@+nX?0C(E$Kvl3m!*`etzoX|n&voY>jM}G9-*3%#3d;ZTc z+y1@7)?WKzDnGq9c+CGEVozVa&0qT9lPTI>pqpp!(0d5)c!BOL(D4G@o1-t-0&$@K E049TPl>h($ literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-04.mp4 b/android/app/src/androidTest/assets/video-04.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..c12b721c7d17c53940e45a7d6ab904c927763237 GIT binary patch literal 1641 zcmaJ>&x;&I6t0<#VF`i}5|YCT6^sxh)7#xM4zo)-Y+Qo_D(pdnh%%|^uHJ5@tGlb)D()bIwOtYi&5yQvgI+h_L?e+5o0Vc{ZwjLQd3qY~2ZBZdY-aWn>M|hRlquEY(iud8=HtKUI_p z__&HrzWWuJuduhit0Egol<9d6HcBJ8z@%?sU35hrWs*3x&sCbo0Ov-kO1ir3%hAlY z5ot*r67oMi)ES@Q+S|tVGvIHGHH!lw&X;s(&?KulXO;Ii zd6xE%{B~>Rtmcc;DYOIDZtdmcGt=MLg?;E>*4xLg;N00Mn)a@B?@ns$RPy69--CU& z6sClx`ZUhLpXMea-7N7a^H2E4d3;fFecWP z+nbw*hldVb%pw!HQ|SFos^TP8W#AO0O^e)Y^HB6f7`PmlFYtII`&khV0(TofE^?8L zjiePC3*<;xYaVt zQUgZks1t=2AUsf*BPpUgMJ@y1yXtv77RL5UGe}DW%@Z7yy&{f{q#&DU1el=7v!WP? z1dHCn=5dpyA-1wGxIEWYi7-{d(l*lEN}UOiVDD!`EylgDP^G9ggx=r}Db0}rL@kID zYoR3V`%tF)i7kN!atwFizqEioeCdJbaudp2hC|9##ZynTGzr!^3_=}qh3`aCE3pXt zrsMJhDj&E`8?dB2l@>=qdkY3*Wf`=1TB55sJ2bt86FLZ7Ho-mX$d5kUe)eNlKkps0 zoqs!Q{ly<<^3!|6Cp-@jd-}#*e*J?_W@u-LZlAqJ-yyt{CAzmn$4m5Jfxci%#DV?; D$@6aK literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-05.mp4 b/android/app/src/androidTest/assets/video-05.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..21924c896fadc81deb612fff0d14aaa49a49c6bf GIT binary patch literal 1638 zcmaJ>&x;&I6t0<#A%uVtB8jj3VYBHM48ldS8q4d)zw>7 zGrJQnBYF_=;zcB0a@4;-FTx%?c=Bo@9^ysfK}GThtl#VTk#Pxm(^c=Q_rCXD^{eV( zjPb;c%gk!USe=o^Xo^u3sIna}w(_RZdc+vZ)hLajmt^H3{0~`;(eqgTw}AP``s!om zlTaP|7&;>caK7&bRn3(p;w&50J|aizJhAQsF}JF?^D=f8XjSIcRhDX}v%FQV+FvTp z1bke@N8kMd%oo_ZKUR^AG|tT|2ODLvoMX~Au-08s#JMC+?JJcP3BZ|=s*)~m`+Pk0 zt;R+YhlKp6h9>7zT)SyqKL`HaIy*lQ;(SAg22HY@b84C3wYz{cNNUf&1%Ai(T7eh% zO`d1{{br{2PY4IXJ>bye?8GTcpc}?j?uJzxqD|)W5?2;pZy8! z)1|N_G}WhZrti$Gxo2-OMuVNRu4+7B#W~3+6}WgGIuMe1{3>kG)U>dHU*XH}-JbbK z!CwDjstG@JpjM&8N4Y4XXGF0h!=S^X)MzDo2o1P1 zGS4#$#`^wx965k!S7DB&h;QpchRxt&5b#7;*DLKHD-kqHuv_+Yl2}PWE;R_SVTdF~1xT>>^Pv&rUZhnistutx_(RGHqyW(f z;v_~WN&DWD*-q+8pn)929X2n`VGmz=82H>qvXIe`a#iuv6C-VkwTZ&WBwXP;k<>~o z!e-0!`7V_YeXj#pQl3hS{jjqEgLSeD+dM1LRh%7~-o^>-hCZ9%o?VikezN)8XRQ9h zzxN(6{_(3*`C$0VQyvODe(esw^3ms0bajDloxV#SA-t0Xy1PIJ3-n-)zGe%=f&K$L C{c4v0 literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-06.mp4 b/android/app/src/androidTest/assets/video-06.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1e70c24b7bdbdcb618bc0eafb3456be40b73622a GIT binary patch literal 1646 zcmZ`(&u<$=6dpUJN~9VgP;Yi^V z7-Kvz!#vVi!dRJ+#;S_G=OlUEVQlH`B+L4Yu{i06KJ3>#mM09jkJuZJ znNLGy?4xIt=puN-bc>!#3&d$P>U~6x^m%IE5pr%8c^9Q`G|-BSwJAKsPN#K?TBW}e zKN5)X#6SA(m*Bp{KDb|GRzg2k(;BQF`ErIyUx%HwMC!+qIHfyDlm-B2`bklAaoZRD zNo>Vek~kz3Kh;w)pWxDm+H_*z@3hgg10l}WbZF2di!~>n5nj6mNQg-h;^+~XJo*$2)EwHvqZycT+|H01fK>xbjID8Z5&W_Pz`$}tnRAR@%A7A(Z z?9;i>IW+aBb*5v~bhzj4Fh+}=*QOXeV8J=bs1&$(A36|{S^f%q-o!QcfnVS&h~1jT zNWove4gLHCll{zvUS5Vc33@Gk0^RBPMnHd*$U;->mBoX_Wl$o|Y4&reM3dX|+BYVn zf){*IB-0BpOgO-Vodzas!(I-Q#MLN2O2C4C|M+v`@%C@m{yO;cLF?Pwmw(|8Uj64^ zItE_hUZx~>09DPg*Lcm5&U&zpK+6JQ_1*1jZ&m7i^ZFK;zVr~-%JN|>1H-GfU8_{> zY7NfNn7p~VIv5Nrx|mrkQY%wCtJK8`O%j8X$-Lm ze_W&@9%@MzPo#?vgX>-g$mY2of{tff`L1Kxpy0uiXixfWszn)XCDI+~It}iHDoaEg zsSYe`0zmFS56dwCRbc!M+NI^+)NE0PUDdN3d+gLs;i;^4>mH_Drh!6rfUF02-t#D4?N*Ad1kSP(YN1*7@H0N6ab8^Ul0)-uvErvu|e2 z7~_#07l~0BV^u~Pqb^2)mlaKqv9-4|rACagG#kYs^op!Kg8vb#FnXTJ{}wPGSzmp| zd>*P|A499f0M3u>pscyJLY!x#+DGI_o#)n_Am&yXcTtAc0By+B*wRw&be^}&Rrx0i z69FI3!lUng3Fb@e{U6K7S{$Z&o`a2&P%bd(n^>!^$iq|;r}9;n(jck4@(%bd*J*f; z=hk_e^bhK-=FC~n=cZF=2dwSN^}`d>KiSz`=-<~{hi~HC*)f`Su5|BBD(qPN%c-Bh zK3xb?KvR7hXZlvpn|l^^8tk00W#a)$&Iv}Tz{UH}fsicXH((29rlk%15?_Ju_S{Db z_UaciP56ZawE{&!5Y$?`1Kl5nRzQE4$x@T={OZBtGH6j0)cd8>lG*Kf?OUrT;j5u2 zgXskrIvkrvf+bjh~6*f`1>F_}GMc}&}m(TZjDEp}j2ENVQCvlZlz8INU-Ig&;u*dZNv*`f zuQwc*?@@W*by|Q0X6vVZp2@Et4`1*+MC|cv_xKxkKcAtiOLXh>efkdJoh;G)B|2Q9hYR#ITOtnh E6vf&x;&I6t0<#Aq2q)k>s#Ki4k(h^mfgR!|all#5K5}!X7k;sFRxR>g{H_x_hf? zW_O~85j=?KRU|6psDD9n5%%E0lUI#+(2K-_=1-V>ujfa`CHkhT-dFE^@4f0*)x#L$ zu^X4EEi_|wMjE3jN1@PVTQIivnl6eFV=U97B!XU%wfpejXEjF8Bl*_?=40zCkC=}` zb?js4j2yuEt{YS}*H(!0Y*hQ09INx#x)a3QsNycD$XTFum04F=s-4dBR=H|_>nN4* zaUC6h_lq!JWN-aYMK+QsGxHp5ltyZSNngjh*pYdZDdN<=)M*|AoE_;Z>FTyGL^I!d zWE62o$p7TfWPFBeZ(G;TfbUvo7Y9O|Z|TsWNmg^tEEBx;4j>JZ+B2_%-|@Yc@Pyyw zS=v8rcG@#%HJ_hOp*^s+YcC(2p8mnk?Lq%~s&n)T&Yhj0Y4`HZtx1iYNPc?iN3hS7 z(w5LvpT^m~GxO%2>oP`zowu%PJYdB+%_tSPcpo|tl12PFY~jqbw1HpY%kbTt`$)lF z|9qwi|L;Jpu%I9aYOQ?$-5*9yLcgO`rOEf)>cQeN7+IFo`=vC}+3k7ZJ6BM`XCqk! z(+e2OajYJ{22Y;a5cpg8ahc0( zY!$7zRslW?Zijs!pOQ?Ar8!c7Xe4oB zBekM^AEobI`sCxSr$1%&t~mPe y>q@>E{`}s5Kkp;xL66%+6rN;DwNwa-RMZ|8X%I!~q4C;Dh_b61by+GR0n#Etg`g;%*yFVokL{hA zWH;p!Bn}91LR*NB=H|(&ixwb-_XQSFDCOb3C`n z(`0be?6fOqIbWE~pdGMwYcC(4nf=br??eB(-Z_2+=gv;iw0EUPrt7vEO}E*CF}AkY z-rPJqJap({=Bdb>T<>pE6(_bT1t%|Tl4oX{2Vx)s-{rV`zQ;p3Nb_LmyW9A2k%@F- zB(1oTK0XX?Mgt(17iI!F=DrSn&v8M)#8q-6!#?Grh`tutzVy8g4`Q7w(MPDqt(Iw; z7%;j=-7v5K!GXdYN)g`5GwC<96zD3>4oz?4gbsX{O>xh<^5c)TpZ+k(o$&x;&I6t0Fu7G47-TzoWL%caK7ONRn3(p;w&50J|QRSJhbivF}JF?iz;#!Xia6-RhDX}v%FQV+8=q8 z3ix;)oqYF8FkfP?eOpB~k|;B?9Bhu11UTW9A7LYyz?(4a|{b51Q2yml9m21)ISm%#7%UMuhd zzsa+-f7tA_r_O4=aC{8yfwfb6?&!?%uk8FD^iQWdN6+Kj*(y!D+ub{p8e2`if9yN3 z&z8cL&{UtsxxO>A=AOUF7!7v8x~lPj73U12RN&%$=s-y3@oTWjscB&Yzrx$_-JbbK z!CwDpstNz^KrN{$2!dKGZ$tNokrU9b=c>}=dusV$aT$y#OX~eX8tL@*JoBZ~l<>(& zRKfHD3>}Wq;h=*K`&gfjjl$Ka|DA&c{pzVlj*oYLzW)0+zuoJ8_3`B&`MszA{ELo( zH@MVB@c_`Y0)Kx>+1&x2OeEaor%KJW^bLUc!|q1aCGTXUDz!yMPJC!=eT@fz$4Yqv>b%~7Jgh5 zA{$#pE1s(m9|pI>K9J8#I|dzd&qQJ1`JiCpd3va#9_6Blo)N{K3WE-piP5>}AvEC5 zs4Pn@7#oKhk#qoZKgS#?5#82>3Y)>@AmFjEu2&o23$4nHzjwn5u+KbKz(kY2lQ~1W2&=v!M~=p453Mstutx_(MtyqyW(f z;>1SeiuQe=(!Io$Km#>~J8WK=!ydl$Fz~sRs!(!BxvF^TiBUGe+Q?9vnCJLT6txnI zu-Wo_zE9;t-|GODl&8|-Fzjr?V4W(%Hcv}*6=#R0w{b%Iq0c6`XIIqw?`}Q*0jsaR zawI40z3tB``F!}poB#d1g}_tS?(k>d`EZJ^F3_#Bcj+U9cd|ft7wBk#ewm|B*#dE( Fe*iV0Y@h%D literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-11.mp4 b/android/app/src/androidTest/assets/video-11.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..fe1e3a150235e0c68b42bd470d14c5ea196c5284 GIT binary patch literal 1640 zcmaJ>O^6&t6t3BgF$BRCBFSL`C5ezjrnh@$oa`=XNnC>iD(pdnh%l+?uHJ5@tGl+U zW_BlDCg4HDTR*w=dt{60rRo-wa3gS zp%v_7;Ed?Q`GMjF~#JXd|+^XU(%Gg<;b(vdNS*o4R@>aQOf2%kX z@NpF%fA>o;Ut;h6P({|$I5)E#Y?#Gzj!ECdy6lJ|&LwebpR24$0L~0mm2`RA7vrgK zJvNd!B;eD#Wb7t1uvngXV*g5N}#sgNI(~MGqi}#@eA(_Xo!xl|V3mfC!d*2c7MI`=l6d+=zR0p<)8V33;+B}$H416 zDvjg;plJsFCT|8Z*h;qG=y)J(yuEwlje3h;zqtcuEF(B}NPK8&t(hoyx%}wad!FZu3a=L=^fQmoE%>EPHtw^+SIfKQ0Q9 zkF2B>S2Dzh!R??2u#AOR#K2l4FYV~3S*xrJ{I;q1J&osJH BbKn2~ literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-12.mp4 b/android/app/src/androidTest/assets/video-12.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..730de2bc669fc6a21c226c337ad35b01f8716913 GIT binary patch literal 1641 zcmaJ>&x;&I6t0<#VF`gmh$M#Jj9E{gL?Biey`_8#wFxUSG}*^``&xiud0VJ z#v?l}5~DK4s*E&7U5o-RE1Di-Yj0*sjTmETHi|>&6|6^8R^gNOOEnq&jzW#*y zG*rbthE|I{oFCYJS#xcLIL}74kIAt*Ppvya%don z0zRIF$KU-j%$M1_-6_kO$i=XZZR?0ob2m0$S7m;U~Tj)B*B zptR&3pl*0>n>Re^ZADvfbQ};i-`>0VMy<(rZta2@$^edCRg6;^SzdSDMy>AF8!*P! z7TcSf2L}fZT}+jV+);Xeld3qe%~EhwVH1^`Z61i82z;00^7$SQWiM4h-*>n1<02R7 z*hpIOO#1jRxEc0@H9_pW#zkA$(^!t|2@LGuKIqN}3FND8vCMu71fJXNYM zVl27~o5xL>1lY>L;PPCTC4wXqmbQ`PR_auM1bZ(XYBBBxDl0^}A@l}+NJ)+qAZkIJ zNQ+F;z7J%wAKL<`FUN5E_16}#i!a^xTy6rH%V0>k%6RIDmL|qp2Y#R(*i6gPpQSB-`aw~SXuZ@o)qXR&JIm);)Dji%O<#IZF%>T?dR{Y>gmA| z+j*tU)?fW`CO>~Te8%$#u_t%#^J^b}Iz!i&==RwM^c})GS)vC^bhJc|7U)a1L>%Zp Dcc*Sg literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-13.mp4 b/android/app/src/androidTest/assets/video-13.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..3fd19b0367547713240ab36f5e76a8c34018b270 GIT binary patch literal 1639 zcmaJ>zmFS56dwD+=@cQ6NI^+)ND~pH6!G4!ZLGUf79|`JmH_Drh!6tpjdy2nSKirO z&y0QN&=`pVAu0-lLX#5y0%(w~pn!%Jf+#|RLIE`N=6mZOaZXX5cjkTb-uK>{eKTvu z7?16^OiiH}t25FVO)&~RRkl6G*4|J>F=C8mYLrCKE3)M46uFV52mW3rzYp)>L!I#%uDxq)F9ZJ0ShF|~;(Seq22HY>b7q;~wYz{cNNUf&34X_QTAt&% zO`fH_<7THlb5`@E=@i-lYp?d|$=T^2?BXHxZyTMH*KqFa3{CsjyLTrwb|(4xxgWtk zR|-=?Q+*m2dREVyd+~rV8tjs>RpS9G&RIsOz{UH}fsicXH(?8Brlk%13SWos&fG@| z_WI{DP57w;wZejeAgHzWA#`sTSpof?l9eXk<<*15WzeE5srO51q_f-e(l@rCgfB#* z3Z@re=x~eFvZjaC99Iw%*#m^?IYtZ{FSmGm;@3dqp|UWNdlUbz6<5+iby@ zSX=IHZ5aGqgoP?awITEde@JPL6d-Cr zoLCDbY2QaOJxpu~)R$wp{pQOH7~o6yJ(rtM<}w^ot}32-qNPc&)}bHjm@9lIl3Iy{ z-)uQ9A5eMUbvl40<*Bqd_B%T;7%NM^&C?QH#o3|hZJf}+ci9B@tRp}9c=!CLtbTrQ zvcAnWF8?%>Up^Q<<#~X}^_zG3D<6F}LpPS_;M_g>3gMkB(Y+-)S)vCE^c7no4)i}X C5pBEx literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-14.mp4 b/android/app/src/androidTest/assets/video-14.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..e5c55c79fe431ee6c825f8b9b13dd9a2ec520b53 GIT binary patch literal 1641 zcmaJ>L66%+6rN;DwW^RPQlUj!q(Ky^hsJ9sA;3L#KBvBzsGw#Pd& z$!^LeNE{I2h!8559)Ujq7oXr>MHyNHv>{VtOG~-adEPQt?XN6M z1bjRTPrmylm@l!nzAqyiahU3P4mM6gxxl2aW37534^v5;+UHr4M*!!>S($Wo+ZV&i zw-IVd91`+BKGG?#aP4hl2PyEk#+t=}5a&xeG-#65oRwvY*X{w*AgMj|I`|#eX?c$4 zHhG#1j+&iz{R^I6F-1` zwh*R(rusC_4XmCw_dM)0*ac(D#sij|GmKJ!i}#@eAz8$4z!p@dr49TNUxDxD+(!!b z`lpp9{K$b?fubM?YOTEwJs5>nK);j8Qj_nw)q}-l(4r`)_e*Ic)$MuVE2}8sv!N)1 z=>-@%9HGNr2OV~?UXQfI)u`Xkz=D4B^kcKx-f!3b{O*rCy>C9d{0qPH{NMl3G4KWt zl$P8B)Gg2L@|GvPt!N95o&&<>8++GYZM6B->$_lvGJs=O6_Zp(mN#9u)o8lS7L2jA z#rEdr;o+e}7gMDoca+}Wq$*BqvlJXv*hJ-Kn+IYb0^jAhe7?s+IY?D7^xbXzxX491 zF_Kn1lRiESZbkzjmltLNI_AC(ea~@0!NjxVNQQmNMG<{1@_p%h9UjD5WulKzk6SI% zG%;XwkGf%C0fK`Jb0|f4Q{~ccdY3(qN5a^CVTMV8pm~CWqOYRJND8vCMu72KJXLBa zVl4U#o5xL>1lY>L;PPCTC4wXqmbQ`PR_auM1p6QzX));sDl0^}A@l}+NJ)+qAZkIJ zNQ+F;z7J)xAKLGe=zq18{v9j>nJSossoE@6p#t9wxE}P<>b>&ANZa?`kt3Unk zG28jN%hsRyv67$P8$IH=i`dgwZ}FEt_@qKROLXJxZTb%3oi5SsB|2WBy9@LMTOtnh EAN>e#LjV8( literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-15.mp4 b/android/app/src/androidTest/assets/video-15.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..8a4e8209d05bb751c4fff280f017a5e7751f796f GIT binary patch literal 1641 zcmaJ>&x;&I6t0<#VF^MaL=ut}N{o<0rnh@$9A=laBw2$4D(pdnh|;O)uHJ5@tGlWnl-Q;b4Sm2Ho)wbxWpj2L5?8YL0*imW|^{~@a}dLGID7BC-MUwg!S z9I9gb7q;~wR?axNNUf%4t~dVTAt&% zO`fIw!)B*Fb5`^D=@i-lYo~VO=+yK#c5V;)m-WukD>!#{f~MW8y}OedJCXc&`g^d? zl){wIRG-G#zSZ;Qo`anRJ8x{&c)*HticuB)SK+%e_mP6V z{^?8;e&Rr_u%I9aYOTEw-5*9)K)<78rOEgF>cQeNXi=8b`=vC}+3k7pD_c;)=OR%B z(+ei_mvDE}!r5NcOWL9Qf`weq7`t z8yiV0uB49-gPUO=$mOLOgN`}Sk?%P!D44iP4`mckE{X`W$oHi0b$FQQLWuyO9=BR% zS!%%O9(JS90)+btb0kG{r^uz>^saawkA<;8X$EPDpm~D*GAQEMND8uvMu72KJS&QU zNU#VNHjkSu4Y8Gl!R5KGN`$ErmbQ`RR_aWE1baUlYB3JNLY1Q05PE|@q%=ng5Vasq ztc8-a?*p0cCAI__$T8f0^Q8q0@TL2n%S|YA84f8|6;D0U(j-{x&<}OY6}}Tmt;E7_ zwj7u5Q+eNYI)EkRskAusJ6kXqD@(u4(-K|9*`eueoY21SvI*{4SAO*2_A?)|`o({a z*o_Nawto7Dnf&zL@CnZY#GbrZx E1F-CFumAu6 literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-16.mp4 b/android/app/src/androidTest/assets/video-16.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..616b6aaef60b93a831e8896d606dc5ac036af1d5 GIT binary patch literal 1636 zcmaJ>&x;&I6t0<#Aq2q)k>s#Ki4k(h^mfmT!|sxn#5Fje!X7k;2$P!b>g{H_x_YZ> zW_RLc1P>yD2SFqr%uz316+xJT2Tz`jc+iVPM8&^g{9ezGj7!Lyu6kd+_r3S3UsVrd zj3;hfW>zc4>Wnl-Q;ecOmF1m+^KCb%YHq9%7ul%x2{}>cp>-#SxmCqolCiTun=-eqvQ#@=??hj)ppx;um(&T$){a|q!j3`U${Yo0y{PsNerPGw~=~z_3 z^a2bWPSD|?gAV&xpGl0w)u{igz=D4L^kcKx{x3KF`0n?6-LF5n@)N)J?BD;;G4KYD zw2?djG_Anj=B+>mJIM|lT@Qq<*Y?#x=a^#mq(&6qR{8Kd||+2+0S(}2>o6BxF|$E zwvtv{$q*j~x5GY=&r3T79dpmbVc_|oVB#t}mT`}AQAE#(;y{K$hexT=O7svKaA#zm zXBLd@f1jb@k|kR`uZLI!n+^N(X|!2b?z>Gg78jO=L5Lhh6s?(!A%uVtBFSNe3P#8w)7w2W8FrVnY+Qo_D(pdnh%j5zUA^5*cUNy! z&FoIRjNn1UlY+>4$x$y}6+xJT2Tz`jc+iVPM7?+)-|wE8j7!L$uKKV3fB*mLU)94H zp6>X2PwO2ExMvSpE8^s~?imW|^{~@a}dLGID7BHV$?>u5Y z4%M-bq19pl=R0;#)?8a5&a+YNQ*x@#W9v>3vtPztlc6<0>oPUAw3IuY=Ph&9{>s8c zz{j)j^t)e&`8s>^`!cc-hpC?DV520I3rzYh)~YM=FqOoqeU>G81aN7Tl}T5(eJ!kf z>!Fs!AtC>Tp-y>)Yi}9bPl3NN)+`Q$IA74AL6fZJtSl3}b{CKaN$uI!!0)(D%X2)p z$-@%9HGNr2OV~?J{M_;t5N?q0}J|%vro-td%xWIiP`5mHleawSZADvfbR7^jUf;X(N~6th-`xc>lmQ&Osu-s-vb^cKtwz&rwqT5{ zEw(o{j*gBTx|k{zxuf*K232ulo2B5W!X_#=+dL3`5%@00+m4fDib|~dfaN6 zrilS#^LR51EI@FWVGgAT@2On+P49;1@kkikE6gA%5HwG4SoBmB8A(Al)(9|ui>FEr zM2tmmVe`02lK@*;7+jv~vP6(%!qPU9+)AAakYMkpLoLR=KxKs}H-z5c4=Kr!0z@r{ z6KRo2+V_!64q{sX4dfVZzj<>3d-&3Q&*dhNxeSJstBj|fXlY`sb>Ig&;u*dZNv*`f zZ?+tlA5wYWbvl3r;h7@x?Clf&;(H%eXlIG`FW#rG5Z=iW-Cv^V68*YBpR*<6K>q=D Cv2#`c literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-18.mp4 b/android/app/src/androidTest/assets/video-18.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2fa8b2c36249d8d0d93520bce26aa7d35bced1dd GIT binary patch literal 1640 zcmaJ>O^6&t6t0<#A%utiD(pdnh%l+?uHJ5@yQ{aV zW_BlDM(`lwEg%v-MSJlH9 zuh7nt--tW`%8Q6`B~`y@|`7~tG6uad5A`%*OX zZA4lUhlKoR20G(2TzkvdUIzS)v1V}~#QA~_4Vq*%=gcz3Yj*%?kkp=j4g9w2G(E?2 zeV(PgBfs66Iji}?bPDZ&wOe~>a(en3JHHS8^QrdaWt=-ZLDSyV&fRg1ok)Is>Ibmb zOJPcAs!!uw&+2(|&nJx0U>A(78V^`;PBTgcF5ZU@gk%xF0b4jTEp6ae_$qw2=RQ)f z*YC_U;l~ct3Ka!GP;2d7=-wc*0{ZP-R+@aztR5^bgBE2;yZ#&+GqvL?E`TE|CR~jvT{pK#1kqqJ3Rpls?vE{z&HXFX{H(^Yy zEq69I4-XF=x|k{xg`@QTCRK3~n`hvt(x$2~J3JIU5e6>D$zG6VCE{f=CQS8gWYx6MCDi>XZdfaN6 zWvKyU>u4(qEkJmXV~(VVZmU8DzIVm*cr1+VmZqPU2%0B2D7z|-jiex(Xaty`$up(; zBEh1&uzB2MX^5>X3@$HpRU%AtVQCv_VWrLlNU-;^ffl1~sPa-&8$xgJhm;mb0iqVf ziM7Zj?fX!s`-v@q`f>z!;6J~BU3}?*=W-LuLWTp%RmD?Jv@{9UIt)S`^Bmuaq*h`P z_)W*<2UI?Aoi<=ec`7ZAg7!8H#>z5i@w7x&adv2W3nz3CxNMAj_JaK2{hhNPvig%} zC+xG5t$%)JCSQH~=VP7;LQh`5%U^u&qZzukM7P%O(Ps$nc!};U(PW8!U7%0d5^&x;&I6t0<#aS4JEBFSNe5+mf0>Fw?rhuI}98`t3A57>hS5oJ=-UA^5*cUNy! z&FoIRjNn1Uiy$KFB}e@W^djuRgI>I9#6!GDJg7IXJhbivF}JF?%QCVCXhUYkR+eg~^So8A+FyB; z3ix;)oqqQ#FkfMBeP2a3k|@*j9Bh5Z&V3&-o8V^`;&M`^_F5ZU@gk%xF0b4jTEp6ae_!@k-=RQ)f z*FT$S!jBxN6)FmXpw`;^(EVX#1@t?)tTg$aT|HP_1}(~xdcTxLI=em3e{B^dd?pfA zFuednhhucuYoo(1*6XpBxEl3;bFiS_Jp0&my7$|SKfn9qZugtdul~aCKKJ)ObPT+~ zL!~A60Cm%IJG|*hZ!6w{qw9dM`NrOjR~jvT{pK#1kqqJ3RpmI7vE{z&HXFX{H(^Yy zEw?u}4-XF=x|k{xg`@QTCRK3~n`hvt(x$2~+dLF~5e6>D$$qB7LEvuV$3-Es zv5~amxeV}Oa5L-!xx6%E&@uOP6nKsc3MQVXM>6VBE{f=BQS8gWYx6MCDi=M3dfaN6 zWvKz9bJU4K3lJXUm?J5o+p3U(?_Kpg9t&f8r5U6pg60Vh%ASg2BPqxx8UZF~@=U3L zNU-QFY#ujR8e%I8gUbtDl?c;ZSlUKfSgA7s672nKsKvM!s=O4{hR_@QA*BUUfT#s= zVl8q>`#zNEequ|YfgHmf_%AGA4_|uVx!i=Zkl~PWRq@mlElq;84uep~JjZt;sg+m+ ze$#RJ0hJG2rwv$Ao=S_OpuGiyv9b(WJT1{xoE@6p!U-J&E}P(v4MZa?)gt3Uqf z3EMgDu=Q7eoXOAc4IlB`L+sh>$Na?)KAEANCAxL~4t literal 0 HcmV?d00001 diff --git a/android/app/src/androidTest/assets/video-20.mp4 b/android/app/src/androidTest/assets/video-20.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..eea0711eaf41aca1722da4666b5ddf0b949d3ebf GIT binary patch literal 1641 zcmaJ>&x;&I6t0TtoFLf^ATe#RiikBUXj`(_#d$fqvx^wZvpeM^|i;$ zC!s3#F|=9?;C$Z>%9^zm;yfGGJ|@TNJhAQsF}KRNi!!tZXkDhpmX>m-^SouQ%AYDs z1bkeD$KU-D%$L|Z-<6T|I860C2OA}!Twu~SvCg|93sXs)%I7M{B7id^RVH2C_Qi1K zTMxA)4hi|69qN?NaOGWN`zi1@#+t=}5a&xeG-#65oHNS=uiXWtK~j0)4e;Bp)AStA zZSXYdA2!;pnX{bFO{dTfSUZ*LN2jL0v9o*7zpS;7Ud6ey6Ey8!>E4@E*opYZ=e`H~ zbRkRuP4#J<>03Q-?pfGruye+ijR!0_rx>LI7wlg~`2yT9J}fBt={6>H+R4cWdO%cUW`*2S>ABnX1(DynlQ%J z7F!z|2L}fZUCcZcnUm|i4XWbAR;A$Ng-!CzZ1F(!Mc}&}m(TZjDEnz1419MBKQ1zn zj*X-hSJKCa!OgG_^Ghgyt#L9Pl>ZV0`>A5xMb1&CS@ zC(=Sm+V_D>_F`KA4dfVZzwycf_VA_qp36-jGZ_piR~b(|(bB|N>%b3m#1+01Nv*`f zZ!{g3?^Ai-b=rUh + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + private fun insertImage(folder: String, name: String): Pair { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(120, 90, Bitmap.Config.ARGB_8888) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + bitmap.recycle() + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + val bucket = resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + return uri to bucket + } + + @Test + fun gridAppearsAndRefreshesOnMediaStoreChanges() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + + val folder = "GaloGrid-${System.nanoTime()}" + val (_, bucket) = insertImage(folder, "grid-${System.nanoTime()}.jpg") + val viewModel = DeviceAlbumViewModel(container, bucket) + + // The initial item is shown. + withTimeout(15_000) { + viewModel.state.first { it.items.any { media -> media.albumId == bucket } } + } + + // A new file appears in the bucket (as a materialized server photo would); + // the grid must pick it up without any manual reload. + val (_, bucket2) = insertImage(folder, "grid2-${System.nanoTime()}.jpg") + assertTrue("test setup must use one bucket", bucket == bucket2) + + withTimeout(20_000) { + viewModel.state.first { state -> + state.items.count { it.albumId == bucket } >= 2 + } + } + Unit + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt index ce842aa..0785d1a 100644 --- a/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt +++ b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt @@ -136,7 +136,7 @@ class OffloadInstrumentedTest { db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) db.syncDao().upsertAssets(listOf(asset)) - val result = OffloadManager(context, db.syncDao()).offload(asset) + val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")) assertEquals(OffloadResult.Done, result) // The file is still there but now a capped preview. @@ -156,7 +156,7 @@ class OffloadInstrumentedTest { db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) db.syncDao().upsertAssets(listOf(asset)) - val result = OffloadManager(context, db.syncDao()).offload(asset) + val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")) assertEquals(OffloadResult.Done, result) assertTrue("video row should be gone", !exists(uri)) @@ -185,7 +185,7 @@ class OffloadInstrumentedTest { val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg") val key = asset.assetKey db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) - assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset)) + assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))) // Simulate a reinstall: the app's index is gone. db.syncDao().clearAlbum(bucket) @@ -222,7 +222,7 @@ class OffloadInstrumentedTest { db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) db.syncDao().upsertAssets(listOf(asset)) - assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset)) + assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))) assertEquals(1600 to 1200, dimensionsOf(uri)) val original = Bitmap.createBitmap(2400, 1800, Bitmap.Config.ARGB_8888) diff --git a/android/app/src/androidTest/java/com/galo/android/SyncModesE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/SyncModesE2ETest.kt new file mode 100644 index 0000000..64709aa --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/SyncModesE2ETest.kt @@ -0,0 +1,307 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.sync.ContentHash +import com.galo.android.di.AppContainer +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * Every synchronization mode (A/B/C) and the switches between them. Small, fast + * and functional; the volume coverage lives in [SyncVolumeE2ETest]. Against a + * running galo instance at 10.0.2.2:8088. + */ +@RunWith(AndroidJUnit4::class) +class SyncModesE2ETest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + private data class One(val uri: Uri, val bucket: String, val key: String, val bytes: ByteArray) + + private fun insertImage(folder: String): One { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "mode-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.rgb(30, 90, 200)) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + bitmap.recycle() + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + val bucket = resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + val bytes = resolver.openInputStream(uri)!!.use { it.readBytes() } + return One(uri, bucket, DeviceAssetEntity.key(bucket, ContentUris.parseId(uri)), bytes) + } + + private suspend fun awaitState( + container: AppContainer, + bucket: String, + key: String, + state: LocalAssetState, + timeoutMs: Long = 90_000, + ): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val now = container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state + if (now == state.name) return true + delay(500) + } + return false + } + + private suspend fun awaitPhotos(container: AppContainer, album: String, minimum: Int): Int { + val deadline = System.currentTimeMillis() + 90_000 + while (System.currentTimeMillis() < deadline) { + val count = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0) + if (count >= minimum) return count + delay(500) + } + return runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0) + } + + private fun localHash(uri: Uri): String = TestMedia.hash(resolver, uri) + + // --- Mode A --------------------------------------------------------------- + + @Test + fun disabledModeIsLocalOnly() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + E2E.registerAuthenticated(container) + + val item = insertImage("GaloModesA") + assertNull("a disabled album has no link", container.syncRepository.currentLink(item.bucket)) + assertFalse("mode A must not contact the server", container.syncCoordinator.reconcile(item.bucket)) + assertEquals("the local original is untouched", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream())) + } + + // --- Mode B --------------------------------------------------------------- + + @Test + fun copyModeUploadsAndKeepsTheOriginal() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "B ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesB") + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + assertTrue(container.syncCoordinator.reconcile(item.bucket)) + + assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1) + assertTrue("asset stays uploaded", awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED)) + assertEquals("the original stays on the device", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream())) + } + + // --- Mode C --------------------------------------------------------------- + + @Test + fun moveModeUploadsAndOffloads() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "C ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesC") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + assertTrue(container.syncCoordinator.reconcile(item.bucket)) + + assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1) + assertTrue("the original is replaced by a preview", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + } + + // --- Switch B -> C -------------------------------------------------------- + + @Test + fun copyToMoveOffloadsExistingUploads() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "B2C ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesB2C") + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED)) + + container.syncRepository.link(item.bucket, "B", album, SyncMode.MOVE) + container.syncCheck.checkGallery(item.bucket) + + assertTrue("switching to move offloads the upload", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + } + + // --- Switch C -> B -------------------------------------------------------- + + @Test + fun moveToCopyRestoresTheOriginal() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "C2B ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesC2B") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + + container.syncRepository.link(item.bucket, "C", album, SyncMode.COPY) + container.syncCheck.checkGallery(item.bucket) + + assertTrue("the original is restored locally", awaitState(container, item.bucket, item.key, LocalAssetState.LOCAL)) + assertEquals("the restored file is the original bytes", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream())) + } + + // --- Switch to A (off), regression --------------------------------------- + + @Test + fun moveToOffStaysOffAfterRecovery() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "C2A ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesC2A") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + + container.syncRepository.unlink(item.bucket) + assertNull(container.syncRepository.currentLink(item.bucket)) + + container.syncCheck.checkGallery(item.bucket, recoverIndex = true) + assertNull("turning off must be sticky", container.syncRepository.currentLink(item.bucket)) + assertTrue(container.syncRepository.isSyncDisabled(item.bucket)) + } + + @Test + fun copyToOffStaysOffAfterRecovery() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "B2A ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesB2A") + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + + container.syncRepository.unlink(item.bucket) + container.syncCheck.checkGallery(item.bucket, recoverIndex = true) + + assertNull(container.syncRepository.currentLink(item.bucket)) + assertTrue(container.syncRepository.isSyncDisabled(item.bucket)) + } + + // --- Switch off -> B (re-enable) ----------------------------------------- + + @Test + fun offToCopyUploadsAgain() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "A2B ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesA2B") + container.syncRepository.unlink(item.bucket) + assertTrue(container.syncRepository.isSyncDisabled(item.bucket)) + + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + assertFalse(container.syncRepository.isSyncDisabled(item.bucket)) + container.syncCoordinator.reconcile(item.bucket) + + assertTrue("re-enabled sync uploads again", awaitPhotos(container, album, 1) >= 1) + } + + // --- Reinstall-style recovery for a genuine setup ------------------------- + + @Test + fun recoveryRebuildsLinkWhenSyncWasNotTurnedOff() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "Recover ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesRecover") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + + container.syncRepository.forgetAlbum(item.bucket) + assertNull(container.syncRepository.currentLink(item.bucket)) + + val recovered = container.syncRecovery.recover(item.bucket) + assertTrue("markers must rebuild the link", recovered >= 1) + assertNotNull(container.syncRepository.currentLink(item.bucket)) + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/SyncVolumeE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/SyncVolumeE2ETest.kt new file mode 100644 index 0000000..4762417 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/SyncVolumeE2ETest.kt @@ -0,0 +1,236 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.sync.ContentHash +import com.galo.android.data.sync.ProvenanceStamp +import com.galo.android.di.AppContainer +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.domain.model.AssetOrigin +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * Dedicated volume coverage: 100 photos and 20 videos per test, both directions. + * + * - upload: the whole device album reaches the server through the drain queue; + * - materialize: a server album of the same size lands on the device (previews in + * move mode; server videos become posters). + * + * This suite is slow (every file is one HTTP request) — it intentionally lives + * apart from the fast functional tests. Against a running galo at 10.0.2.2:8088. + */ +@RunWith(AndroidJUnit4::class) +class SyncVolumeE2ETest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + private val total = TestMedia.PHOTO_COUNT + TestMedia.VIDEO_COUNT + + private fun anchor(folder: String): String { + val values = android.content.ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + android.graphics.Bitmap.createBitmap(96, 96, android.graphics.Bitmap.Config.ARGB_8888).let { + // A colour nothing else uses, so the anchor never dedups with a server photo. + it.eraseColor(android.graphics.Color.rgb(7, 200, 13)) + resolver.openOutputStream(uri)!!.use { out -> it.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out) } + it.recycle() + } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + } + + private fun countLocal(bucket: String, collection: Uri, prefix: String): Int = + resolver.query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?", + arrayOf(bucket, "$prefix%"), + null, + )?.use { it.count } ?: 0 + + private fun localUri(bucket: String, collection: Uri, name: String): Uri? = + resolver.query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(bucket, name), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + android.content.ContentUris.withAppendedId(collection, cursor.getLong(0)) + } else { + null + } + } + + private suspend fun awaitServerCount(container: AppContainer, album: String, minimum: Int): Int { + val deadline = System.currentTimeMillis() + 900_000 + var last = -1 + while (System.currentTimeMillis() < deadline) { + last = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1) + if (last >= minimum) return last + delay(1_000) + } + return last + } + + private suspend fun awaitState( + container: AppContainer, + bucket: String, + key: String, + state: LocalAssetState, + ): Boolean { + val deadline = System.currentTimeMillis() + 600_000 + while (System.currentTimeMillis() < deadline) { + if (container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state == state.name) return true + delay(1_000) + } + return false + } + + private suspend fun materializeUntil( + container: AppContainer, + bucket: String, + timeoutMs: Long = 480_000, + done: () -> Boolean, + ) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + container.syncCoordinator.reconcile(bucket) + if (done()) return + delay(2_000) + } + } + + @Test + fun copyUploadsTheWholeDeviceAlbum() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "VolUp ${System.currentTimeMillis()}") + + val seeded = TestMedia.seed(resolver, "GaloVolUp") + created += seeded.uris + val bucket = seeded.bucket(resolver) + val first = seeded.photos.first() + val key = DeviceAssetEntity.key(bucket, android.content.ContentUris.parseId(first)) + val bytes = resolver.openInputStream(first)!!.use { it.readBytes() } + + container.syncRepository.link(bucket, "VolUp", album, SyncMode.COPY) + assertTrue(container.syncCoordinator.reconcile(bucket)) + + assertEquals("all media must reach the server", total, awaitServerCount(container, album, total)) + assertTrue("assets stay uploaded", awaitState(container, bucket, key, LocalAssetState.UPLOADED)) + assertEquals( + "the original stays on the device", + ContentHash.sha256(bytes.inputStream()), + TestMedia.hash(resolver, first), + ) + } + + @Test + fun copyMaterializesServerVolume() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "VolDown ${System.currentTimeMillis()}") + val bucket = anchor("GaloVolDown") + TestMedia.uploadServerMedia(token, album) + + container.syncRepository.link(bucket, "VolDown", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + val images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val videos = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + val deadline = System.currentTimeMillis() + 480_000 + var pass = 0 + while (System.currentTimeMillis() < deadline) { + container.syncCoordinator.reconcile(bucket) + val p = countLocal(bucket, images, "srv-photo-") + val v = countLocal(bucket, videos, "srv-video-") + android.util.Log.i( + "VolTest", + "pass=${pass++} photos=$p videos=$v server=${runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1)}", + ) + if (p >= TestMedia.PHOTO_COUNT && v >= TestMedia.VIDEO_COUNT) break + delay(2_000) + } + + assertTrue( + "all server photos must be materialized", + countLocal(bucket, images, "srv-photo-") >= TestMedia.PHOTO_COUNT, + ) + assertTrue( + "all server videos must be materialized", + countLocal(bucket, videos, "srv-video-") >= TestMedia.VIDEO_COUNT, + ) + + val sample = localUri(bucket, images, "srv-photo-000.jpg") + assertTrue("a sample server photo must exist locally", sample != null) + assertTrue("materialized sample must be a mirror", ProvenanceStamp.read(context, sample!!)?.origin == AssetOrigin.MIRROR.name) + + val mirrored = container.syncRepository.assets(bucket) + .count { it.origin == AssetOrigin.MIRROR.name } + assertTrue("materialized items must be recorded as mirrors", mirrored >= TestMedia.PHOTO_COUNT) + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/TestMedia.kt b/android/app/src/androidTest/java/com/galo/android/TestMedia.kt new file mode 100644 index 0000000..dafeab6 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/TestMedia.kt @@ -0,0 +1,117 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.provider.MediaStore +import com.galo.android.data.sync.ContentHash +import java.io.ByteArrayOutputStream + +/** + * Shared media seeding for the dedicated volume test. Content is made unique per + * index so the checksum-based deduplication does not collapse it. + */ +object TestMedia { + + const val PHOTO_COUNT = 100 + const val VIDEO_COUNT = 20 + + /** A unique JPEG for [index] (distinct pixel colour, so distinct checksum). */ + fun jpeg(index: Int, size: Int = 96): ByteArray { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val r = (index * 7) % 256 + val g = (index * 13) % 256 + val b = (index * 29) % 256 + bitmap.eraseColor(Color.rgb(r, g, b)) + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out) + bitmap.recycle() + return out.toByteArray() + } + + /** A unique video for [index] (bundled asset, distinct bytes each). */ + fun video(index: Int): ByteArray { + val name = "video-%02d.mp4".format(index.coerceIn(1, VIDEO_COUNT)) + return androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .context.assets.open(name).use { input -> input.readBytes() } + } + + data class Seeded(val photos: List, val videos: List) { + val uris: List get() = photos + videos + fun bucket(resolver: ContentResolver): String = + resolver.query(photos.first(), arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + } + + /** Inserts [PHOTO_COUNT] photos and [VIDEO_COUNT] videos into DCIM/[folder]. */ + fun seed(resolver: ContentResolver, folder: String): Seeded { + // A unique bucket per call: leftovers from interrupted runs never leak in. + val path = "DCIM/$folder-${System.nanoTime()}/" + val photos = (0 until PHOTO_COUNT).map { index -> + insertImage(resolver, path, "photo-%03d.jpg".format(index), jpeg(index)) + } + val videos = (1..VIDEO_COUNT).map { index -> + insertVideo(resolver, path, "video-%02d.mp4".format(index), video(index)) + } + return Seeded(photos, videos) + } + + private fun insertImage( + resolver: ContentResolver, + relativePath: String, + name: String, + bytes: ByteArray, + ): Uri { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + resolver.openOutputStream(uri)!!.use { it.write(bytes) } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + return uri + } + + private fun insertVideo( + resolver: ContentResolver, + relativePath: String, + name: String, + bytes: ByteArray, + ): Uri { + val values = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, name) + put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") + put(MediaStore.Video.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Video.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!! + resolver.openOutputStream(uri)!!.use { it.write(bytes) } + values.clear() + values.put(MediaStore.Video.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + return uri + } + + fun hash(resolver: ContentResolver, uri: Uri): String = + resolver.openInputStream(uri)!!.use { ContentHash.sha256(it) } + + /** Uploads the full volume (photos + videos) straight to [album] on the server. */ + fun uploadServerMedia(token: String, album: String) { + for (i in 0 until PHOTO_COUNT) { + E2E.upload(token, album, "srv-photo-%03d.jpg".format(i), jpeg(i), "image/jpeg") + } + for (i in 1..VIDEO_COUNT) { + E2E.upload(token, album, "srv-video-%02d.mp4".format(i), video(i), "video/mp4") + } + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/TwoWayMirrorE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/TwoWayMirrorE2ETest.kt new file mode 100644 index 0000000..af67e2e --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/TwoWayMirrorE2ETest.kt @@ -0,0 +1,306 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.exifinterface.media.ExifInterface +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.data.sync.ProvenanceStamp +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement +import java.io.File + +/** + * End-to-end two-way synchronization: a photo added to the target server album + * (as the web UI would) is materialized into the device album's MediaStore bucket + * with provenance, and deletions propagate both ways. Small and fast; volume + * coverage lives in [SyncVolumeE2ETest]. Against a running galo at 10.0.2.2:8088. + */ +@RunWith(AndroidJUnit4::class) +class TwoWayMirrorE2ETest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + /** Anchors the album bucket with one image and returns its bucket id. */ + private fun insertAnchor(): String { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloTwoWay-${System.nanoTime()}/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.rgb(7, 200, 13)) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + bitmap.recycle() + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + } + + /** JPEG with a capture date, so the server reports `taken_at`. */ + private fun datedJpeg(): ByteArray { + val file = File(context.cacheDir, "web-${System.nanoTime()}.jpg") + file.writeBytes(E2E.jpegBytes(128)) + ExifInterface(file).apply { + setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2021:05:05 10:00:00") + saveAttributes() + } + val bytes = file.readBytes() + file.delete() + return bytes + } + + private fun awaitLocalFile(bucket: String, name: String, timeoutMs: Long = 60_000): Uri? { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val uri = queryLocal(bucket, name) + if (uri != null) { + if (uri !in created) created += uri + return uri + } + Thread.sleep(300) + } + return null + } + + private fun queryLocal(bucket: String, name: String): Uri? = + resolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Images.Media._ID), + "${MediaStore.Images.Media.BUCKET_ID} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} = ?", + arrayOf(bucket, name), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + cursor.getLong(0), + ) + } else { + null + } + } + + private fun dateTakenOf(uri: Uri): Long = + resolver.query(uri, arrayOf(MediaStore.Images.Media.DATE_TAKEN), null, null, null)!! + .use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else 0L } + + @Test + fun webPhotoIsMaterializedWithProvenanceAndDate() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", datedJpeg(), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull("the server photo must be materialized into the album bucket", local) + + val marker = ProvenanceStamp.read(context, local!!) + assertNotNull("materialized file must carry a provenance marker", marker) + assertTrue("origin must be MIRROR", marker!!.origin == "MIRROR") + assertTrue("provenance must point at the server photo", marker.serverPhotoId == serverPhoto.id) + + val exifDate = runCatching { + resolver.openInputStream(local).use { + if (it == null) null else ExifInterface(it).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + } + }.getOrNull() + if (!serverPhoto.takenAt.isNullOrBlank()) { + assertTrue( + "capture date must be preserved on the device", + dateTakenOf(local) > 0L || !exifDate.isNullOrBlank(), + ) + } + } + + @Test + fun deletingTheLocalCopyKeepsTheServerPhotoAndRecreates() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay del ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull(local) + + // The user deletes the downloaded copy from the gallery. The server photo + // is not this client's upload, so it must survive; the local copy returns. + resolver.delete(local!!, null, null) + container.syncCoordinator.reconcile(bucket) + + assertTrue( + "a lost local mirror must not delete a server photo", + runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList()) + .any { it.id == serverPhoto.id }, + ) + assertNotNull("the materialized copy must be recreated", awaitLocalFile(bucket, "web.jpg")) + } + + @Test + fun deletingOnTheServerRemovesTheLocalCopy() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay srv ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull(local) + + E2E.deletePhoto(token, serverPhoto.id) + + var remaining: Uri? = queryLocal(bucket, "web.jpg") + val deadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < deadline && remaining != null) { + container.syncCoordinator.reconcile(bucket) + remaining = queryLocal(bucket, "web.jpg") + if (remaining != null) delay(300) + } + assertNull("a server deletion must remove the local materialized file", remaining) + } + + @Test + fun moveMaterializesVideoAsPoster() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay vid ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "vid.mp4", TestMedia.video(1), "video/mp4") + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.MOVE, mirrorFromCreatedAt = 0L) + + var local = queryLocal(bucket, "vid-preview.jpg") + val deadline = System.currentTimeMillis() + 60_000 + while (System.currentTimeMillis() < deadline && local == null) { + container.syncCoordinator.reconcile(bucket) + local = queryLocal(bucket, "vid-preview.jpg") + if (local != null) created += local + delay(500) + } + assertNotNull("a server video must become a poster in move mode", local) + + val mirrored = container.syncRepository.assets(bucket) + .any { it.origin == "MIRROR" && it.state == "PREVIEW" } + assertTrue("the poster must be recorded as a mirror preview", mirrored) + } + + @Test + fun explicitDeleteOfAMirrorRemovesTheServerPhoto() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay exp ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull(local) + + // An explicit in-app deletion: mark the intent and remove the local copy. + container.syncRepository.markExplicitDelete(bucket, serverPhoto.id) + resolver.delete(local!!, null, null) + container.syncCoordinator.reconcile(bucket) + + assertTrue( + "an explicit in-app deletion must remove the server photo", + awaitServerPhotoGone(container, album, serverPhoto.id), + ) + } + + private suspend fun awaitServerPhotoGone( + container: com.galo.android.di.AppContainer, + album: String, + photoId: String, + ): Boolean { + val deadline = System.currentTimeMillis() + 20_000 + while (System.currentTimeMillis() < deadline) { + val alive = runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList()) + .any { it.id == photoId } + if (!alive) return true + delay(300) + } + return runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList()) + .none { it.id == photoId } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt b/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt index 91f6e7c..59ad196 100644 --- a/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt +++ b/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.galo.android.data.db.dao.AlbumDao import com.galo.android.data.db.dao.PhotoDao import com.galo.android.data.db.dao.SyncDao @@ -11,6 +13,7 @@ import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.entity.AlbumEntity import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.PhotoEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.db.entity.UploadJobEntity @@ -23,8 +26,9 @@ import com.galo.android.data.db.entity.UploadJobEntity SyncedAlbumEntity::class, DeviceAssetEntity::class, DeviceAlbumMetaEntity::class, + DeletedAssetEntity::class, ], - version = 6, + version = 8, exportSchema = false, )abstract class GaloDatabase : RoomDatabase() { @@ -41,7 +45,43 @@ import com.galo.android.data.db.entity.UploadJobEntity fun create(context: Context): GaloDatabase = Room.databaseBuilder(context.applicationContext, GaloDatabase::class.java, NAME) + .addMigrations(MIGRATION_6_7, MIGRATION_7_8) .fallbackToDestructiveMigration() .build() + + /** + * Adds two-way mirroring state without losing the sync index: the + * provenance origin, the first-pass baseline and the deletion journal. + */ + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE synced_albums ADD COLUMN mirrorFromCreatedAt INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE device_assets ADD COLUMN origin TEXT NOT NULL DEFAULT 'OWN'", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS deleted_assets (" + + "photoId TEXT NOT NULL, deviceAlbumId TEXT NOT NULL, " + + "status TEXT NOT NULL, createdAt INTEGER NOT NULL, " + + "PRIMARY KEY(photoId))", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_deleted_assets_deviceAlbumId " + + "ON deleted_assets (deviceAlbumId)", + ) + } + } + + /** + * Records whether the user explicitly turned synchronization off for an + * album, so provenance recovery does not resurrect the link. + */ + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE device_album_meta ADD COLUMN syncDisabledAt INTEGER") + } + } } } diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt index 752e486..237e54c 100644 --- a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt +++ b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt @@ -5,6 +5,7 @@ import androidx.room.Query import androidx.room.Upsert import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import kotlinx.coroutines.flow.Flow @@ -97,4 +98,24 @@ interface SyncDao { @Query("DELETE FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") suspend fun clearAlbum(deviceAlbumId: String) + + // --- Deletion journal (tombstones) ------------------------------------- + + @Query("SELECT * FROM deleted_assets WHERE deviceAlbumId = :deviceAlbumId") + suspend fun getDeletedAssets(deviceAlbumId: String): List + + @Query("SELECT * FROM deleted_assets") + suspend fun getAllDeletedAssets(): List + + @Query("SELECT * FROM deleted_assets WHERE photoId = :photoId") + suspend fun getDeletedAsset(photoId: String): DeletedAssetEntity? + + @Upsert + suspend fun upsertDeletedAsset(asset: DeletedAssetEntity) + + @Query("DELETE FROM deleted_assets WHERE photoId = :photoId") + suspend fun deleteDeletedAsset(photoId: String) + + @Query("DELETE FROM deleted_assets WHERE deviceAlbumId = :deviceAlbumId") + suspend fun clearDeletedAssets(deviceAlbumId: String) } diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt index a467e06..514fc7d 100644 --- a/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt +++ b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt @@ -1,5 +1,6 @@ package com.galo.android.data.db.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -12,6 +13,11 @@ data class SyncedAlbumEntity( val serverAlbumId: String, val mode: String, val updatedAt: Long, + /** + * Two-way mirroring baseline: only server photos created at or after this + * epoch (millis) are materialized. 0 means "the whole current album". + */ + @ColumnInfo(defaultValue = "0") val mirrorFromCreatedAt: Long = 0L, ) /** @@ -24,6 +30,12 @@ data class DeviceAlbumMetaEntity( val displayName: String?, val coverUri: String?, val updatedAt: Long, + /** + * When the user explicitly turned synchronization off (or unlinked), the + * album is local-only until they enable sync again. Provenance recovery must + * not resurrect the link from the offloaded previews' markers. + */ + val syncDisabledAt: Long? = null, ) /** @@ -51,8 +63,29 @@ data class DeviceAssetEntity( val serverPhotoId: String?, val uploadedAt: Long?, val state: String, + /** Uploaded by this client (`OWN`) or materialized from the server (`MIRROR`). */ + @ColumnInfo(defaultValue = "OWN") val origin: String = "OWN", ) { companion object { fun key(deviceAlbumId: String, mediaStoreId: Long): String = "$deviceAlbumId/$mediaStoreId" } } + +/** + * A deletion made on the device that still has to be confirmed on the server. + * Keeps a locally removed element from being materialized again before its + * server photo is gone, and makes delete events idempotent. + */ +@Entity(tableName = "deleted_assets", indices = [Index("deviceAlbumId")]) +data class DeletedAssetEntity( + @PrimaryKey val photoId: String, + val deviceAlbumId: String, + /** `PENDING` while the server delete is not confirmed; `FAILED` after a refusal. */ + val status: String, + val createdAt: Long, +) { + companion object { + const val PENDING = "PENDING" + const val FAILED = "FAILED" + } +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt index 796daca..c1f2c86 100644 --- a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt @@ -3,6 +3,7 @@ package com.galo.android.data.repo import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.media.MediaStoreDeviceSource import com.galo.android.domain.model.DeviceAlbum @@ -57,6 +58,7 @@ class SyncRepository( deviceAlbumName: String, serverAlbumId: String, mode: SyncMode, + mirrorFromCreatedAt: Long? = null, ) = dao.upsertSyncedAlbum( SyncedAlbumEntity( deviceAlbumId = deviceAlbumId, @@ -64,10 +66,71 @@ class SyncRepository( serverAlbumId = serverAlbumId, mode = mode.name, updatedAt = System.currentTimeMillis(), + // Keep an existing two-way baseline; a brand-new link starts at "now" + // (only what appears from this point on), which is the safe default. + mirrorFromCreatedAt = mirrorFromCreatedAt + ?: dao.getSyncedAlbum(deviceAlbumId)?.mirrorFromCreatedAt + ?: System.currentTimeMillis(), ), - ) + ).also { + // Enabling sync clears any earlier explicit "off", so recovery may work. + markSyncDisabled(deviceAlbumId, false) + } - suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId) + /** Sets the two-way mirroring baseline: 0 downloads the whole album. */ + suspend fun setMirrorFrom(deviceAlbumId: String, from: Long) { + val current = dao.getSyncedAlbum(deviceAlbumId) ?: return + dao.upsertSyncedAlbum(current.copy(mirrorFromCreatedAt = from)) + } + + /** True when the user explicitly turned synchronization off for this album. */ + suspend fun isSyncDisabled(deviceAlbumId: String): Boolean = + dao.getAlbumMeta(deviceAlbumId)?.syncDisabledAt != null + + /** + * Records an explicit in-app deletion of a tracked photo, so the coordinator + * deletes it on the server. A local copy merely going missing (gallery cleanup, + * moved file) is not recorded and is re-materialized instead. + */ + suspend fun markExplicitDelete(deviceAlbumId: String, photoId: String) { + dao.upsertDeletedAsset( + DeletedAssetEntity( + photoId = photoId, + deviceAlbumId = deviceAlbumId, + status = DeletedAssetEntity.PENDING, + createdAt = System.currentTimeMillis(), + ), + ) + } + + /** + * Records that the user turned synchronization off (or unlinked) the album, + * so provenance recovery does not recreate the link from preview markers. + */ + suspend fun markSyncDisabled(deviceAlbumId: String, disabled: Boolean) { + val current = dao.getAlbumMeta(deviceAlbumId) + if (disabled) { + dao.upsertAlbumMeta( + DeviceAlbumMetaEntity( + deviceAlbumId = deviceAlbumId, + displayName = current?.displayName, + coverUri = current?.coverUri, + updatedAt = System.currentTimeMillis(), + syncDisabledAt = System.currentTimeMillis(), + ), + ) + } else if (current?.syncDisabledAt != null) { + dao.upsertAlbumMeta(current.copy(syncDisabledAt = null, updatedAt = System.currentTimeMillis())) + } + } + + suspend fun unlink(deviceAlbumId: String) { + dao.deleteSyncedAlbum(deviceAlbumId) + dao.clearDeletedAssets(deviceAlbumId) + // Remember the explicit "off" so the next provenance recovery does not + // recreate the link from the offloaded previews' markers. + markSyncDisabled(deviceAlbumId, true) + } // --- Local album metadata (display name, cover) ------------------------ @@ -91,6 +154,7 @@ class SyncRepository( displayName = name?.trim()?.takeIf { it.isNotEmpty() }, coverUri = current?.coverUri, updatedAt = System.currentTimeMillis(), + syncDisabledAt = current?.syncDisabledAt, ), ) } @@ -104,6 +168,7 @@ class SyncRepository( displayName = current?.displayName, coverUri = coverUri, updatedAt = System.currentTimeMillis(), + syncDisabledAt = current?.syncDisabledAt, ), ) } @@ -114,6 +179,7 @@ class SyncRepository( */ suspend fun forgetAlbum(deviceAlbumId: String) { dao.clearAlbum(deviceAlbumId) + dao.clearDeletedAssets(deviceAlbumId) dao.deleteAlbumMeta(deviceAlbumId) dao.deleteSyncedAlbum(deviceAlbumId) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt b/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt index f9191d1..fe22a54 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt @@ -1,12 +1,75 @@ package com.galo.android.data.sync +import com.galo.android.data.network.ApiClient import com.galo.android.data.network.GaloApi +import com.galo.android.data.network.dto.PhotoDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * The object URL to download for a server photo: the original in copy mode, + * otherwise the lightest available preview (for video, a poster frame). + */ +internal fun pickVariant(photo: PhotoDto, full: Boolean): String? = + if (full) { + photo.downloadUrl ?: photo.originalUrl + } else { + photo.previewUrl ?: photo.mediumUrl ?: photo.thumbUrl + } /** [SyncRemote] backed by the galo REST API. */ -class ApiSyncRemote(private val api: GaloApi) : SyncRemote { +class ApiSyncRemote( + private val api: GaloApi, + private val apiClient: ApiClient, +) : SyncRemote { override suspend fun serverChecksums(serverAlbumId: String): Map = - api.albumChecksums(serverAlbumId) + serverPhotos(serverAlbumId).mapNotNull { photo -> + photo.checksum?.let { it to photo.id } + }.toMap() + + override suspend fun serverPhotos(serverAlbumId: String): List { + val out = ArrayList() + var cursor: String? = null + while (true) { + val page = api.photos(serverAlbumId, cursor = cursor, limit = 100) + page.photos.forEach { p -> + out += ServerPhoto( + id = p.id, + checksum = p.originalChecksum?.ifBlank { null }, + displayName = p.originalFilename, + mimeType = p.mimeType, + isVideo = p.kind == "video", + takenAtMillis = parseTakenAt(p.takenAt), + createdAtMillis = parseInstant(p.createdAt) ?: 0L, + width = p.width, + height = p.height, + size = p.size, + ) + } + val next = page.nextCursor + if (next.isNullOrBlank()) break + cursor = next + } + return out + } + + override suspend fun fetchPhoto(photo: ServerPhoto, full: Boolean): ByteArray? = + withContext(Dispatchers.IO) { + val dto = runCatching { api.photo(photo.id) }.getOrNull() ?: return@withContext null + val raw = pickVariant(dto, full) + val url = apiClient.objectUrls.resolve(raw) ?: return@withContext null + runCatching { + apiClient.client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) null else response.body?.bytes() + } + }.getOrNull() + } override suspend fun renamePhoto(photoId: String, newName: String) { api.renamePhoto(photoId, newName) @@ -19,4 +82,24 @@ class ApiSyncRemote(private val api: GaloApi) : SyncRemote { override suspend fun movePhotos(photoIds: List, serverAlbumId: String) { api.movePhotos(photoIds, serverAlbumId) } + + private companion object { + val EXIF_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss") + val PLAIN_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + /** ISO instant, EXIF or plain datetime; null when unparseable. */ + fun parseInstant(raw: String?): Long? { + val value = raw?.trim()?.ifBlank { null } ?: return null + runCatching { return Instant.parse(value).toEpochMilli() } + runCatching { + return LocalDateTime.parse(value, EXIF_FORMAT).toInstant(ZoneOffset.UTC).toEpochMilli() + } + runCatching { + return LocalDateTime.parse(value, PLAIN_FORMAT).toInstant(ZoneOffset.UTC).toEpochMilli() + } + return null + } + + fun parseTakenAt(raw: String?): Long? = parseInstant(raw) + } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt b/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt index 34ff56c..d73cdaf 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt @@ -10,23 +10,34 @@ import android.provider.MediaStore /** * Watches MediaStore while the app process is alive and notifies on changes so * synchronization can run promptly in the foreground. Background changes are - * covered by the periodic worker. + * covered by the periodic worker. Bursts of changes (for example materializing or + * offloading a whole album) are coalesced into a single notification after + * [debounceMs], so one file write does not trigger a full synchronization pass. */ class DeviceMediaObserver( private val context: Context, private val onChanged: () -> Unit, + private val debounceMs: Long = 1_500, ) { - private val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + private val handler = Handler(Looper.getMainLooper()) + private val notify = Runnable { onChanged() } + + private val observer = object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { - onChanged() + schedule() } override fun onChange(selfChange: Boolean, uri: Uri?) { - onChanged() + schedule() } } + private fun schedule() { + handler.removeCallbacks(notify) + handler.postDelayed(notify, debounceMs) + } + fun start() { runCatching { context.contentResolver.registerContentObserver( @@ -44,5 +55,6 @@ class DeviceMediaObserver( fun stop() { runCatching { context.contentResolver.unregisterContentObserver(observer) } + handler.removeCallbacks(notify) } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt b/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt index 9634102..229c13f 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt @@ -8,6 +8,7 @@ import com.galo.android.data.media.MediaStoreDeviceSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.InputStream +import java.util.concurrent.ConcurrentHashMap /** Supplies the device's current content to the synchronization engine. */ interface LocalAssetProvider { @@ -34,7 +35,7 @@ class DeviceLocalAssetProvider( displayName = media.displayName, mimeType = media.mimeType, kind = media.kind, - contentHash = hash(media.uri), + contentHash = hashCached(media.uri, media.size, media.dateModified), uri = media.uri, ) } @@ -46,6 +47,23 @@ class DeviceLocalAssetProvider( override fun openStream(uri: String): InputStream? = runCatching { context.contentResolver.openInputStream(Uri.parse(uri)) }.getOrNull() + private data class CachedHash(val size: Long, val dateModified: Long, val hash: String?) + + // Hashing a large album every pass is expensive; MediaStore updates + // DATE_MODIFIED on any write, so an unchanged (size, dateModified) pair lets + // us reuse the previous sha256 instead of re-reading the whole file. + private val hashCache = ConcurrentHashMap() + + private fun hashCached(uri: String, size: Long, dateModified: Long): String? { + val cached = hashCache[uri] + if (cached != null && cached.size == size && cached.dateModified == dateModified) { + return cached.hash + } + val hash = hash(uri) + hashCache[uri] = CachedHash(size, dateModified, hash) + return hash + } + private fun hash(uri: String): String? = runCatching { openStream(uri)?.use { ContentHash.sha256(it) } }.getOrNull() } diff --git a/android/app/src/main/java/com/galo/android/data/sync/MirrorManager.kt b/android/app/src/main/java/com/galo/android/data/sync/MirrorManager.kt new file mode 100644 index 0000000..2fe4e73 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/MirrorManager.kt @@ -0,0 +1,174 @@ +package com.galo.android.data.sync + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** Outcome of materializing one server photo on the device. */ +sealed interface MirrorResult { + data class Done(val mediaStoreId: Long, val uri: String) : MirrorResult + + /** Nothing could be downloaded or written. */ + data object Failed : MirrorResult +} + +/** Writes server photos into a device album's MediaStore bucket, and removes them. */ +interface AssetMirror { + suspend fun materialize( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + ): MirrorResult + + /** + * Removes a materialized file from the gallery when its server photo is gone. + * @return true when the media is gone; false when the app does not own it. + */ + suspend fun remove(uri: String): Boolean +} + +/** + * Downloads a server photo and inserts it into the album's MediaStore bucket: + * the original in copy mode, a light preview (poster for video) in move mode. + * The inserted file carries an `origin=MIRROR` provenance marker and the capture + * date, and belongs to the app, so a later server deletion can remove it without + * a system consent dialog. + */ +class MirrorManager( + private val context: Context, + private val remote: SyncRemote, + private val deviceSource: MediaStoreDeviceSource, +) : AssetMirror { + + private val mutex = Mutex() + + override suspend fun remove(uri: String): Boolean = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.delete(Uri.parse(uri), null, null) > 0 + }.getOrDefault(false) + } + + override suspend fun materialize( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + ): MirrorResult = withContext(Dispatchers.IO) { + mutex.withLock { + val full = mode == SyncMode.COPY + val bytes = runCatching { remote.fetchPhoto(photo, full) }.getOrNull() + if (bytes == null || bytes.isEmpty()) { + android.util.Log.w(TAG, "fetch failed ${photo.id} video=${photo.isVideo} full=$full") + return@withLock MirrorResult.Failed + } + runCatching { insert(deviceAlbumId, serverAlbumId, mode, photo, bytes) } + .onFailure { android.util.Log.w(TAG, "insert failed ${photo.id}: ${it.message}") } + .getOrDefault(MirrorResult.Failed) + } + } + + private fun insert( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + bytes: ByteArray, + ): MirrorResult { + val asVideo = photo.isVideo && mode == SyncMode.COPY + val relativePath = relativePathOf(deviceAlbumId) + val mime = when { + asVideo -> photo.mimeType?.takeIf { it.startsWith("video/") } ?: "video/mp4" + photo.isVideo -> "image/jpeg" + else -> photo.mimeType?.takeIf { it.startsWith("image/") } ?: "image/jpeg" + } + val displayName = displayNameOf(photo, asVideo) + + val collection = if (asVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, mime) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + photo.takenAtMillis?.let { put(MediaStore.MediaColumns.DATE_TAKEN, it) } + } + val inserted = context.contentResolver.insert(collection, values) ?: return MirrorResult.Failed + + val temp = tempFile(displayName) + try { + temp.writeBytes(bytes) + runCatching { + ProvenanceStamp.write( + temp, + Provenance( + serverAlbumId = serverAlbumId, + serverPhotoId = photo.id, + originalHash = photo.checksum, + deviceAlbumId = deviceAlbumId, + mode = mode.name, + assetKey = null, + origin = "MIRROR", + ), + ) + } + context.contentResolver.openOutputStream(inserted)?.use { out -> + temp.inputStream().use { it.copyTo(out) } + } ?: return MirrorResult.Failed + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + context.contentResolver.update(inserted, values, null, null) + // MediaProvider may recompute DATE_TAKEN when the pending flag clears, + // so set the capture date again in a separate update. + photo.takenAtMillis?.let { taken -> + val date = android.content.ContentValues().apply { + put(MediaStore.MediaColumns.DATE_TAKEN, taken) + } + context.contentResolver.update(inserted, date, null, null) + } + } finally { + temp.delete() + } + return MirrorResult.Done( + mediaStoreId = android.content.ContentUris.parseId(inserted), + uri = inserted.toString(), + ) + } + + /** Writes the poster/original next to the album's existing files. */ + private fun relativePathOf(deviceAlbumId: String): String = + deviceSource.items(deviceAlbumId) + .firstOrNull { it.relativePath.isNotBlank() } + ?.relativePath + ?: "DCIM/" + + private fun displayNameOf(photo: ServerPhoto, asVideo: Boolean): String { + val base = photo.displayName.ifBlank { photo.id } + return when { + asVideo -> base + photo.isVideo -> base.substringBeforeLast('.') + "-preview.jpg" + else -> if (base.contains('.')) base else "$base.jpg" + } + } + + private fun tempFile(name: String): File { + val dir = File(context.cacheDir, "mirror").apply { mkdirs() } + val suffix = name.substringAfterLast('.', "bin") + return File(dir, "mirror-${System.nanoTime()}.$suffix") + } + + private companion object { + const val TAG = "MirrorManager" + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt index 1b420de..77a6b66 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt @@ -200,6 +200,7 @@ class OffloadManager( // this back to rebuild the link after a reinstall. mode = syncMode ?: SyncMode.MOVE.name, assetKey = asset.assetKey, + origin = asset.origin, ) private suspend fun markPreview(asset: DeviceAssetEntity) { diff --git a/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt b/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt index 8a5e271..5d532c7 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt @@ -16,6 +16,8 @@ data class Provenance( val deviceAlbumId: String?, val mode: String?, val assetKey: String?, + /** `OWN` when this client uploaded the element, `MIRROR` when downloaded. */ + val origin: String? = null, ) /** @@ -49,6 +51,7 @@ object ProvenanceStamp { append('|').append("device=").append(p.deviceAlbumId ?: "") append('|').append("mode=").append(p.mode ?: "") append('|').append("asset=").append(p.assetKey ?: "") + append('|').append("origin=").append(p.origin ?: "") } fun decode(raw: String?): Provenance? { @@ -66,6 +69,7 @@ object ProvenanceStamp { deviceAlbumId = map["device"]?.ifBlank { null }, mode = map["mode"]?.ifBlank { null }, assetKey = map["asset"]?.ifBlank { null }, + origin = map["origin"]?.ifBlank { null }, ) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt index 64f5a59..0db5c2c 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt @@ -1,8 +1,10 @@ package com.galo.android.data.sync import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity +import com.galo.android.domain.model.AssetOrigin import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.SyncMode import com.galo.android.domain.sync.SyncPolicy @@ -19,9 +21,11 @@ sealed interface RetargetResult { } /** - * Reconciles a device album with its server album. Mode A (DISABLED) is local - * only and performs no server requests; the other modes upload missing content, - * dedup by checksum, propagate renames and update the local index. + * Reconciles a device album with its server album as a two-way mirror. Mode A + * (DISABLED) is local only and performs no server requests; the other modes + * upload missing content and materialize server photos (originals in copy mode, + * previews in move mode), dedup by checksum, propagate renames and deletions in + * both directions, and update the local index. */ class SyncCoordinator( private val dao: SyncDao, @@ -29,6 +33,7 @@ class SyncCoordinator( private val remote: SyncRemote, private val uploadQueue: SyncUploadQueue, private val offloader: AssetOffloader, + private val mirror: AssetMirror, ) { /** Serializes reconciles so rapid triggers never double-upload or double-offload. */ @@ -45,19 +50,42 @@ class SyncCoordinator( // A mode can be chosen before a server album; nothing to sync until then. if (link.serverAlbumId.isBlank()) return false - val serverChecksums = remote.serverChecksums(link.serverAlbumId) - val livePhotoIds = serverChecksums.values.toSet() + // If the album could not be read, do NOT treat it as empty: an empty list + // would make the engine think every server photo is gone and delete the + // local mirrors. Skip the pass instead. + val serverPhotos = runCatching { remote.serverPhotos(link.serverAlbumId) }.getOrNull() + ?: return false + val serverChecksums = serverPhotos.mapNotNull { photo -> + photo.checksum?.let { it to photo.id } + }.toMap() + val livePhotoIds = serverPhotos.mapTo(HashSet()) { it.id } + + // A tombstone whose photo is gone from the server is confirmed: stop + // hiding it. Anything still pending keeps the element from coming back. + clearConfirmedTombstones(deviceAlbumId, livePhotoIds) + val assets = local.assets(deviceAlbumId) + val localKeys = assets.mapTo(HashSet()) { it.assetKey } + val tombstones = dao.getDeletedAssets(deviceAlbumId).mapTo(HashSet()) { it.photoId } // Reflect lost/returned server copies before planning, so an offloaded // preview whose photo is gone is seen as ORPHANED and handled as such. - val index = reflectLostCopies(dao.getAssets(deviceAlbumId), livePhotoIds).map { - IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state) + val reflected = reflectLostCopies(dao.getAssets(deviceAlbumId), livePhotoIds) + // Materialized elements are reconciled against the server set. Rows with an + // explicit deletion are left for the planner to delete on the server. + reconcileMirrors(reflected, livePhotoIds, localKeys, tombstones) + val indexEntities = dao.getAssets(deviceAlbumId) + + val index = indexEntities.map { + IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state, it.origin) } val hashesOutside = local.hashesOutside(deviceAlbumId) - val actions = SyncPlanner.plan(assets, index, serverChecksums, hashesOutside) + val actions = SyncPlanner.plan(assets, index, serverChecksums, hashesOutside, tombstones) for (action in actions) apply(deviceAlbumId, link.serverAlbumId, mode, action, livePhotoIds) + // Two-way: bring the target album's photos onto the device. + materializeMissing(deviceAlbumId, link, mode, serverPhotos, assets, indexEntities) + // Mode C: assets already on the server (e.g. after switching from copies) // are replaced with their offloaded versions, right away, but only while // their copy is confirmed in the target album. @@ -79,7 +107,8 @@ class SyncCoordinator( newServerAlbumId: String, mode: SyncMode, ): RetargetResult = mutex.withLock { - val previous = dao.getSyncedAlbum(deviceAlbumId) + val prior = dao.getSyncedAlbum(deviceAlbumId) + val previous = prior ?.serverAlbumId ?.takeIf { it.isNotBlank() } val recordedPhotoIds = dao.getAssets(deviceAlbumId) @@ -121,6 +150,7 @@ class SyncCoordinator( serverAlbumId = newServerAlbumId, mode = mode.name, updatedAt = System.currentTimeMillis(), + mirrorFromCreatedAt = prior?.mirrorFromCreatedAt ?: System.currentTimeMillis(), ), ) reconcileLocked(deviceAlbumId) @@ -143,12 +173,14 @@ class SyncCoordinator( /** * Reflects the loss or return of the server copy behind an offloaded preview: * a preview whose server photo is gone becomes ORPHANED, and one whose photo is - * back becomes PREVIEW again. Returns the updated index rows for planning. + * back becomes PREVIEW again. Materialized elements are handled separately. + * Returns the updated index rows for planning. */ private suspend fun reflectLostCopies( entities: List, livePhotoIds: Set, ): List = entities.map { entity -> + if (entity.origin == AssetOrigin.MIRROR.name) return@map entity val photoId = entity.serverPhotoId ?: return@map entity val live = photoId in livePhotoIds when { @@ -166,6 +198,128 @@ class SyncCoordinator( } } + /** + * Reconciles materialized (server-origin) elements: + * - if the server photo is gone, the local mirror is removed (two-way deletion); + * - if the server photo is still there but the local copy is missing, the stale + * index row is dropped so the photo is materialized again — the client must + * never delete a server photo just because it lost its own copy of it. + */ + private suspend fun reconcileMirrors( + entities: List, + livePhotoIds: Set, + localKeys: Set, + tombstones: Set, + ) { + for (entity in entities) { + if (entity.origin != AssetOrigin.MIRROR.name) continue + val photoId = entity.serverPhotoId ?: continue + // An explicit in-app deletion is handled by the planner (delete on the + // server); do not drop the row here or the deletion would be lost. + if (photoId in tombstones) continue + if (photoId in livePhotoIds) { + if (entity.assetKey !in localKeys) dao.deleteAsset(entity.assetKey) + continue + } + val removed = runCatching { mirror.remove(entity.uri) }.getOrDefault(false) + if (removed) { + dao.deleteAsset(entity.assetKey) + } else { + dao.markState(entity.assetKey, LocalAssetState.ORPHANED.name) + } + } + } + + /** Clears tombstones whose server photo is gone (delete confirmed). */ + private suspend fun clearConfirmedTombstones(deviceAlbumId: String, livePhotoIds: Set) { + for (tombstone in dao.getDeletedAssets(deviceAlbumId)) { + if (tombstone.photoId !in livePhotoIds) dao.deleteDeletedAsset(tombstone.photoId) + } + } + + /** + * Materializes the server photos that are not represented locally yet, are + * not pending deletion, and whose content is not already on the device. Only + * photos at or after the link's baseline are considered. + */ + private suspend fun materializeMissing( + deviceAlbumId: String, + link: SyncedAlbumEntity, + mode: SyncMode, + serverPhotos: List, + assets: List, + indexEntities: List, + ) { + // Several device albums may point at one server album (n:1). Materialize + // into exactly one of them (a stable choice) so the gallery never gets a + // duplicate from the same server photo. + if (!isMirrorTarget(deviceAlbumId, link.serverAlbumId)) return + + val represented = indexEntities.mapNotNull { it.serverPhotoId }.toSet() + val deviceHashes = HashSet() + assets.forEach { it.contentHash?.let(deviceHashes::add) } + indexEntities.forEach { it.contentHash?.let(deviceHashes::add) } + val tombstones = dao.getDeletedAssets(deviceAlbumId).mapTo(HashSet()) { it.photoId } + + val from = link.mirrorFromCreatedAt + val eligible = serverPhotos.filter { + from == 0L || it.createdAtMillis == 0L || it.createdAtMillis >= from + } + val toDownload = SyncPlanner.mirrorPlan(eligible, represented, deviceHashes, tombstones) + if (toDownload.isNotEmpty()) { + android.util.Log.i( + TAG, + "materialize plan=${toDownload.size} server=${serverPhotos.size} represented=${represented.size}", + ) + } + + for (photo in toDownload) { + val result = runCatching { + mirror.materialize(deviceAlbumId, link.serverAlbumId, mode, photo) + }.getOrNull() ?: continue + if (result !is MirrorResult.Done) continue + recordMirror(deviceAlbumId, mode, photo, result) + } + } + + /** + * Among all device albums linked to [serverAlbumId], the first by id owns + * materialization. Stable, so a photo is never written into two buckets. + */ + private suspend fun isMirrorTarget(deviceAlbumId: String, serverAlbumId: String): Boolean { + val links = dao.getSyncedAlbumsForServer(serverAlbumId) + if (links.size <= 1) return true + return links.minByOrNull { it.deviceAlbumId }?.deviceAlbumId == deviceAlbumId + } + + /** Records a materialized server photo in the index with `origin=MIRROR`. */ + private suspend fun recordMirror( deviceAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + result: MirrorResult.Done, + ) { + val state = if (mode == SyncMode.MOVE) LocalAssetState.PREVIEW else LocalAssetState.UPLOADED + dao.upsertAssets( + listOf( + DeviceAssetEntity( + assetKey = DeviceAssetEntity.key(deviceAlbumId, result.mediaStoreId), + deviceAlbumId = deviceAlbumId, + mediaStoreId = result.mediaStoreId, + uri = result.uri, + displayName = photo.displayName, + mimeType = photo.mimeType, + size = photo.size, + dateModified = 0L, + contentHash = photo.checksum, + serverPhotoId = photo.id, + uploadedAt = null, + state = state.name, + origin = AssetOrigin.MIRROR.name, + ), + ), + ) + } + /** * Offloads assets that are already on the server but still have their original * locally (mode C). Retries assets left as "awaiting free" from a previous pass, @@ -224,14 +378,27 @@ class SyncCoordinator( } is SyncAction.RenameOnServer -> { - remote.renamePhoto(action.photoId, action.newName) + runCatching { remote.renamePhoto(action.photoId, action.newName) } dao.updateDisplayNameByServerPhoto(action.photoId, action.newName) } is SyncAction.DeleteOnServer -> { // A photo already gone from the server is only reflected locally: // the device original is never touched and nothing is re-deleted. - if (action.photoId in livePhotoIds) remote.deletePhoto(action.photoId) + if (action.photoId in livePhotoIds) { + val ok = runCatching { remote.deletePhoto(action.photoId) }.isSuccess + // Record the deletion so the element is not materialized again + // before the server confirms it. A refusal is kept as FAILED so + // it is not retried forever and stays visible as a problem. + dao.upsertDeletedAsset( + DeletedAssetEntity( + photoId = action.photoId, + deviceAlbumId = deviceAlbumId, + status = if (ok) DeletedAssetEntity.PENDING else DeletedAssetEntity.FAILED, + createdAt = System.currentTimeMillis(), + ), + ) + } dao.deleteAsset(action.assetKey) } } @@ -263,4 +430,8 @@ class SyncCoordinator( ), ) } + + private companion object { + const val TAG = "SyncCoordinator" + } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt index 2f71937..555cd88 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt @@ -1,5 +1,6 @@ package com.galo.android.data.sync +import com.galo.android.domain.model.AssetOrigin import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.MediaKind @@ -21,6 +22,8 @@ data class IndexedAsset( val contentHash: String?, val displayName: String, val state: String = "", + /** `OWN` (uploaded by this client) or `MIRROR` (materialized from the server). */ + val origin: String = "OWN", ) /** A change to apply to the server album. */ @@ -54,6 +57,7 @@ object SyncPlanner { index: List, serverChecksums: Map, hashesPresentElsewhere: Set, + explicitlyDeleted: Set = emptySet(), ): List { val actions = mutableListOf() val livePhotoIds = serverChecksums.values.toSet() @@ -103,6 +107,10 @@ object SyncPlanner { for (indexed in index) { val photoId = indexed.serverPhotoId ?: continue if (indexed.assetKey in localKeys) continue + // A materialized (server-origin) photo is deleted on the server only on + // an explicit in-app deletion (a tombstone). A merely missing local copy + // is not an intent to delete: the coordinator recreates it instead. + if (indexed.origin == AssetOrigin.MIRROR.name && photoId !in explicitlyDeleted) continue val hash = indexed.contentHash if (hash != null && hash in hashesPresentElsewhere) continue // The remote delete only happens while this photo is in the target @@ -112,4 +120,20 @@ object SyncPlanner { return actions } + + /** + * Server photos that must be materialized on the device: every photo of the + * target album that is not already represented locally, not pending deletion, + * and whose content is not already present on the device (dedup). + */ + fun mirrorPlan( + serverPhotos: List, + representedPhotoIds: Set, + deviceHashes: Set, + tombstonedPhotoIds: Set, + ): List = serverPhotos.filter { photo -> + photo.id !in representedPhotoIds && + photo.id !in tombstonedPhotoIds && + (photo.checksum == null || photo.checksum !in deviceHashes) + } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt index 99fb41a..5c4e239 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt @@ -6,6 +6,7 @@ import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.domain.model.AssetOrigin import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.Dispatchers @@ -25,6 +26,10 @@ class SyncRecovery( /** @return how many assets were recovered from their markers. */ suspend fun recover(deviceAlbumId: String): Int = withContext(Dispatchers.IO) { + // The user explicitly turned synchronization off for this album: the + // preview markers must not resurrect the link they unlinked. + if (dao.getAlbumMeta(deviceAlbumId)?.syncDisabledAt != null) return@withContext 0 + var recovered = 0 for (media in deviceSource.items(deviceAlbumId)) { val marker = ProvenanceStamp.read(context, Uri.parse(media.uri)) ?: continue @@ -45,6 +50,7 @@ class SyncRecovery( serverPhotoId = photoId, uploadedAt = null, state = LocalAssetState.PREVIEW.name, + origin = AssetOrigin.from(marker.origin).name, ), ), ) diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt index e010fca..5e519dc 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt @@ -1,10 +1,39 @@ package com.galo.android.data.sync +/** + * A server photo of the target album as the synchronization engine sees it. + * Enough to decide whether it must be materialized on the device and to fetch + * the right variant. + */ +data class ServerPhoto( + val id: String, + val checksum: String?, + val displayName: String, + val mimeType: String?, + val isVideo: Boolean, + /** Capture time in epoch millis, when known. */ + val takenAtMillis: Long?, + /** Server creation time in epoch millis; 0 when unknown. */ + val createdAtMillis: Long, + val width: Int, + val height: Int, + val size: Long, +) + /** Server-side operations the synchronization engine needs. */ interface SyncRemote { /** Control sums of the server photos in an album, keyed by checksum. */ suspend fun serverChecksums(serverAlbumId: String): Map + /** The album's photos with the metadata needed to materialize them. */ + suspend fun serverPhotos(serverAlbumId: String): List + + /** + * Downloads a server photo: the original when [full] is true, otherwise a + * light preview (for video, a poster frame). + */ + suspend fun fetchPhoto(photo: ServerPhoto, full: Boolean): ByteArray? + suspend fun renamePhoto(photoId: String, newName: String) suspend fun deletePhoto(photoId: String) diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt index b7368ec..ff47124 100644 --- a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt @@ -9,7 +9,6 @@ import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.entity.UploadJobEntity import com.galo.android.domain.model.SyncUpload @@ -71,9 +70,9 @@ class UploadRepository( convertedPath = null, ) dao.upsert(job) - UploadScheduler.enqueue(context, job.id, wifiOnly) queued++ } + if (queued > 0) UploadScheduler.enqueueDrain(context) return queued } @@ -112,17 +111,22 @@ class UploadRepository( syncMode = mode, ) dao.upsert(job) - UploadScheduler.enqueue(context, job.id, wifiOnly) + UploadScheduler.enqueueDrain(context) } /** Re-schedules unfinished work after a restart and clears stale RUNNING status. */ suspend fun requeuePending() { dao.resetRunning() - dao.getPending().forEach { job -> - UploadScheduler.enqueue(context, job.id, job.wifiOnly) - } + if (dao.getPending().isNotEmpty()) UploadScheduler.enqueueDrain(context) } + /** Pending entries in queue order; the draining worker processes them. */ + suspend fun pendingJobs(): List = dao.getPending() + + suspend fun hasPending(): Boolean = dao.getPending().isNotEmpty() + + suspend fun resetRunning() = dao.resetRunning() + suspend fun getById(id: String): UploadJobEntity? = dao.getById(id) /** True when a sync upload for this asset is already queued or running. */ @@ -161,22 +165,20 @@ class UploadRepository( object UploadScheduler { - const val KEY_JOB_ID = "job_id" + /** One draining job processes the whole queue, instead of one job per file. */ + const val UNIQUE_DRAIN = "galo-upload-drain" - fun enqueue(context: Context, jobId: String, wifiOnly: Boolean) { + fun enqueueDrain(context: Context) { val constraints = Constraints.Builder() - .setRequiredNetworkType( - if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED, - ) + .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(KEY_JOB_ID to jobId)) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS) .addTag(TAG_UPLOAD) .build() WorkManager.getInstance(context) - .enqueueUniqueWork(jobId, ExistingWorkPolicy.KEEP, request) + .enqueueUniqueWork(UNIQUE_DRAIN, ExistingWorkPolicy.KEEP, request) } const val TAG_UPLOAD = "galo-upload" diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt index 76bfe92..80d2923 100644 --- a/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt @@ -2,6 +2,8 @@ package com.galo.android.data.upload import android.content.Context import android.content.pm.ServiceInfo +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker @@ -23,58 +25,89 @@ import java.io.File import java.io.IOException /** - * Uploads a single queue entry as one multipart POST. Using a foreground - * notification while running lets large/video uploads continue when the app is - * backgrounded; transient failures retry with WorkManager's exponential backoff - * up to [MAX_ATTEMPTS]. + * Drains the persistent upload queue: one WorkManager job processes every pending + * entry sequentially (one file per HTTP request, per the server's fail-fast, + * size-capped uploads). A single draining job avoids the JobScheduler limits and + * foreground-service churn of one job per file, so a large album uploads reliably. */ class UploadWorker( appContext: Context, params: WorkerParameters, ) : CoroutineWorker(appContext, params) { + private enum class Outcome { DONE, RETRY } + override suspend fun doWork(): Result { val app = applicationContext as? GaloApplication ?: return Result.failure() val container = app.container val repository = container.uploadRepository - val jobId = inputData.getString(UploadScheduler.KEY_JOB_ID) ?: return Result.failure() - val job = repository.getById(jobId) ?: return Result.success() - if (job.status == UploadStatus.SUCCESS.name) return Result.success() + // A previous run may have been killed mid-file; those entries are re-queued. + repository.resetRunning() + val pending = repository.pendingJobs() + if (pending.isEmpty()) return Result.success() - val attempt = runAttemptCount + var retry = false + for (job in pending) { + // A job limited to Wi-Fi is skipped while on a metered network. + if (job.wifiOnly && !isUnmetered()) { + retry = true + continue + } + if (process(container, repository, job) == Outcome.RETRY) retry = true + } + // Entries added while this pass ran (or transient failures) need another pass. + if (repository.hasPending()) retry = true + return if (retry) Result.retry() else Result.success() + } + + private suspend fun process( + container: AppContainer, + repository: UploadRepository, + job: UploadJobEntity, + ): Outcome { + val jobId = job.id + val attempt = job.attempts repository.updateState(jobId, UploadStatus.RUNNING, job.progress, null, attempt, job.convertedPath) var tempPath = job.convertedPath + var lastPercent = -1 return try { - setForeground(foregroundInfo(jobId, job.filename, 0)) + setForegroundBestEffort(job.filename, 0) val uri = Uri.parse(job.uri) - val (filename, body) = prepareBody(uri, job.filename, job.mimeType, job.size) { percent -> - onProgress(repository, jobId, job.filename, percent, attempt, tempPath) - }.also { if (it.tempPath != null) tempPath = it.tempPath } + val prepared = prepareBody(uri, job.filename, job.mimeType, job.size) { percent -> + if (percent != lastPercent) { + lastPercent = percent + runBlocking(Dispatchers.IO) { + repository.updateState(jobId, UploadStatus.RUNNING, percent, null, attempt, tempPath) + setForegroundBestEffort(job.filename, percent) + } + } + } + if (prepared.tempPath != null) tempPath = prepared.tempPath - val photo = container.api.upload(job.albumId, filename, body) + val photo = container.api.upload(job.albumId, prepared.filename, prepared.body) container.photoRepository.upsert(photo) recordProvenance(container, job, photo.id) repository.updateState(jobId, UploadStatus.SUCCESS, 100, null, attempt, tempPath) tempPath?.let { runCatching { File(it).delete() } } - Result.success() + Outcome.DONE } catch (e: Exception) { val message = describe(e) if (attempt + 1 >= MAX_ATTEMPTS) { repository.updateState(jobId, UploadStatus.ERROR, job.progress, message, attempt, tempPath) - Result.failure() + Outcome.DONE } else { - repository.updateState(jobId, UploadStatus.QUEUED, job.progress, message, attempt, tempPath) - Result.retry() + repository.updateState(jobId, UploadStatus.QUEUED, job.progress, message, attempt + 1, tempPath) + Outcome.RETRY } } } /** - * For a job created by the device-album sync, records the created server photo - * as the provenance of the source asset so later passes can dedup and delete. + * Records the created server photo as provenance of the source asset (sync + * jobs) and, in mode C, offloads the original right after the upload. */ private suspend fun recordProvenance(container: AppContainer, job: UploadJobEntity, photoId: String) { val deviceAlbumId = job.deviceAlbumId ?: return @@ -92,12 +125,8 @@ class UploadWorker( serverPhotoId = photoId, state = state, ) - // Mode C: as soon as the object is uploaded, replace the local original - // with the offloaded (preview) version. Media the app does not own needs - // the OS write consent, which is requested from the album screen. if (mode == SyncMode.MOVE) { container.syncRepository.getAsset(assetKey)?.let { asset -> - // The upload just succeeded, so the copy is live in the target. runCatching { container.offloadManager.offload(asset, setOf(photoId)) } } } @@ -162,23 +191,16 @@ class UploadWorker( return -1L } - private fun onProgress( - repository: UploadRepository, - jobId: String, - filename: String, - percent: Int, - attempt: Int, - tempPath: String?, - ) { - if (percent == lastPercent) return - lastPercent = percent - runBlocking(Dispatchers.IO) { - repository.updateState(jobId, UploadStatus.RUNNING, percent, null, attempt, tempPath) - setForeground(foregroundInfo(jobId, filename, percent)) - } + /** + * Foreground is best-effort: the OS may refuse to start a foreground service + * from the background. The upload still runs; it just may not show a persistent + * notification in that case. + */ + private suspend fun setForegroundBestEffort(filename: String, percent: Int) { + runCatching { setForeground(foregroundInfo(filename, percent)) } } - private fun foregroundInfo(jobId: String, filename: String, percent: Int): ForegroundInfo { + private fun foregroundInfo(filename: String, percent: Int): ForegroundInfo { val notification = NotificationCompat.Builder(applicationContext, GaloApplication.UPLOAD_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_sys_upload) .setContentTitle("Загрузка в galo") @@ -188,12 +210,19 @@ class UploadWorker( .setProgress(100, percent, percent == 0) .build() return ForegroundInfo( - jobId.hashCode(), + NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) } + /** True when the active network is unmetered (Wi-Fi/ethernet). */ + private fun isUnmetered(): Boolean { + val manager = applicationContext.getSystemService(ConnectivityManager::class.java) ?: return true + val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + private fun describe(e: Exception): String = when (e) { is ApiException -> ErrorMapper.userMessage(e.error) else -> e.message ?: "Ошибка загрузки" @@ -202,10 +231,8 @@ class UploadWorker( private fun percent(sent: Long, total: Long): Int = if (total > 0) ((sent * 100) / total).toInt().coerceIn(0, 100) else 0 - @Volatile - private var lastPercent = -1 - private companion object { const val MAX_ATTEMPTS = 3 + const val NOTIFICATION_ID = 42 } } diff --git a/android/app/src/main/java/com/galo/android/di/AppContainer.kt b/android/app/src/main/java/com/galo/android/di/AppContainer.kt index a88beaf..733ad14 100644 --- a/android/app/src/main/java/com/galo/android/di/AppContainer.kt +++ b/android/app/src/main/java/com/galo/android/di/AppContainer.kt @@ -26,6 +26,7 @@ import com.galo.android.data.sync.ApiOriginalFetcher import com.galo.android.data.sync.ApiSyncRemote import com.galo.android.data.sync.DeviceLocalAssetProvider import com.galo.android.data.sync.DeviceMediaObserver +import com.galo.android.data.sync.MirrorManager import com.galo.android.data.sync.OffloadManager import com.galo.android.data.sync.RepositorySyncUploadQueue import com.galo.android.data.sync.RestoreCoordinator @@ -95,10 +96,12 @@ class AppContainer(context: Context) { val syncRepository = SyncRepository(database.syncDao(), deviceSource) - val syncRemote: SyncRemote = ApiSyncRemote(api) + val syncRemote: SyncRemote = ApiSyncRemote(api, apiClient) val offloadManager = OffloadManager(appContext, database.syncDao()) + val mirrorManager = MirrorManager(appContext, syncRemote, deviceSource) + val syncCoordinator = SyncCoordinator( dao = database.syncDao(), local = DeviceLocalAssetProvider(appContext, database.syncDao(), deviceSource), @@ -108,6 +111,7 @@ class AppContainer(context: Context) { wifiOnly = { settingsState?.wifiOnlyUploads == true }, ), offloader = offloadManager, + mirror = mirrorManager, ) val restoreManager = RestoreManager( @@ -124,6 +128,16 @@ class AppContainer(context: Context) { /** Unified per-gallery check and fix (startup + album open). */ val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreCoordinator, syncCoordinator) + /** + * Watches MediaStore for this process so screens can refresh when the sync + * engine materializes or removes files. The caller stops it (return value). + */ + fun observeMediaChanges(onChanged: () -> Unit): () -> Unit { + val observer = DeviceMediaObserver(appContext, onChanged) + observer.start() + return { observer.stop() } + } + fun start() { scope.launch { settingsRepository.flow.collect { settings -> @@ -140,7 +154,10 @@ class AppContainer(context: Context) { } } runCatching { - DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start() + DeviceMediaObserver( + context = appContext, + onChanged = { runCatching { SyncScheduler.runNow(appContext) } }, + ).start() } } diff --git a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt index 8881549..ea24ca5 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt @@ -22,6 +22,25 @@ enum class SyncMode { } } +/** + * How an element of a device album came to be: uploaded by this client, or + * materialized from a server photo during two-way synchronization. Deletion, + * offload and recovery rely on it instead of the mere presence of a server link. + */ +enum class AssetOrigin { + /** Uploaded by this client. */ + OWN, + + /** Downloaded from the server to mirror the target album. */ + MIRROR, + ; + + companion object { + fun from(raw: String?): AssetOrigin = + entries.firstOrNull { it.name == raw } ?: OWN + } +} + /** Where a device asset currently lives relative to the server. */ enum class LocalAssetState { /** Original present locally, nothing uploaded yet. */ @@ -71,4 +90,6 @@ data class DeviceAsset( val serverPhotoId: String?, val uploadedAt: Long?, val state: LocalAssetState, + /** Uploaded by this client or materialized from the server. */ + val origin: AssetOrigin = AssetOrigin.OWN, ) diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt index f4ed8ad..e7d50aa 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt @@ -120,6 +120,9 @@ const val TAG_FREE_BANNER = "device_free_banner" const val TAG_FREE_BANNER_BUTTON = "device_free_banner_button" const val TAG_NEW_ALBUM_NAME = "device_new_album_name" const val TAG_CREATE_TARGET = "device_create_target" +const val TAG_MIRROR_SCOPE = "device_mirror_scope" +const val TAG_MIRROR_SCOPE_ONLY_NEW = "device_mirror_scope_only_new" +const val TAG_MIRROR_SCOPE_ALL = "device_mirror_scope_all" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -136,6 +139,7 @@ fun DeviceAlbumScreen( val state by viewModel.state.collectAsStateWithLifecycle() val role by viewModel.role.collectAsStateWithLifecycle() val notice by viewModel.notice.collectAsStateWithLifecycle() + val mirrorChoice by viewModel.mirrorChoice.collectAsStateWithLifecycle() val prefs by viewModel.preferences.collectAsStateWithLifecycle() val meta by viewModel.albumMeta.collectAsStateWithLifecycle() var viewerIndex by remember { mutableStateOf(null) } @@ -281,6 +285,14 @@ fun DeviceAlbumScreen( onDismiss = { deletePhotoId = null }, ) } + + if (mirrorChoice) { + MirrorScopeDialog( + onOnlyNew = { viewModel.chooseMirrorScope(downloadAll = false) }, + onDownloadAll = { viewModel.chooseMirrorScope(downloadAll = true) }, + onDismiss = viewModel::dismissMirrorChoice, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -835,10 +847,44 @@ private fun LostCopyBanner(count: Int) { } } +/** + * First-pass choice for two-way synchronization: only photos that appear from now + * on, or the whole current server album. + */ +@Composable +fun MirrorScopeDialog( + onOnlyNew: () -> Unit, + onDownloadAll: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_MIRROR_SCOPE), + title = { Text("Скачать содержимое альбома?") }, + text = { + Text( + "Новые фото с сервера будут появляться здесь автоматически. " + + "Скачать и уже существующие фото целевого альбома?", + ) + }, + confirmButton = { + TextButton( + onClick = onDownloadAll, + modifier = Modifier.testTag(TAG_MIRROR_SCOPE_ALL), + ) { Text("Скачать всё") } + }, + dismissButton = { + TextButton( + onClick = onOnlyNew, + modifier = Modifier.testTag(TAG_MIRROR_SCOPE_ONLY_NEW), + ) { Text("Только новые") } + }, + ) +} + /** Confirms a mode change and explains what the mode does. */ @Composable -fun ModeConfirmDialog( - mode: SyncMode, +fun ModeConfirmDialog( mode: SyncMode, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt index b3f379e..9658699 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt @@ -72,6 +72,32 @@ class DeviceAlbumViewModel( /** A one-off message for the user (for example a refused target change). */ val notice: StateFlow = _notice.asStateFlow() + private val _mirrorChoice = MutableStateFlow(false) + + /** True while the first-pass choice ("only new" vs "download all") is shown. */ + val mirrorChoice: StateFlow = _mirrorChoice.asStateFlow() + + fun dismissMirrorChoice() { + _mirrorChoice.value = false + } + + /** + * Sets the two-way mirroring baseline: only new server photos from now on, or + * the whole current album. + */ + fun chooseMirrorScope(downloadAll: Boolean) { + _mirrorChoice.value = false + viewModelScope.launch { + container.syncRepository.setMirrorFrom( + deviceAlbumId, + if (downloadAll) 0L else System.currentTimeMillis(), + ) + reconcileNow() + } + } + + private var stopMediaObserver: (() -> Unit)? = null + fun dismissNotice() { _notice.value = null } @@ -112,6 +138,17 @@ class DeviceAlbumViewModel( init { load() + // Refresh the grid when MediaStore changes, so materialized server photos + // and removals appear without reopening the album. + stopMediaObserver = container.observeMediaChanges { + viewModelScope.launch { load() } + } + } + + override fun onCleared() { + stopMediaObserver?.invoke() + stopMediaObserver = null + super.onCleared() } fun load() { @@ -218,8 +255,12 @@ class DeviceAlbumViewModel( fun deleteServerPhoto(photoId: String) { viewModelScope.launch { + // Explicit in-app deletion: record the intent so the local mirror is not + // recreated, then remove the photo on the server. + container.syncRepository.markExplicitDelete(deviceAlbumId, photoId) runCatching { container.api.deletePhoto(photoId) } .onSuccess { container.photoRepository.remove(photoId) } + reconcileNow() } } @@ -294,6 +335,8 @@ class DeviceAlbumViewModel( viewModelScope.launch { // Persist the chosen mode even before a server album is picked, so the // choice survives leaving and reopening the album. + val hadTarget = container.syncRepository.currentLink(deviceAlbumId) + ?.serverAlbumId?.isNotBlank() == true val target = state.value.serverAlbumId ?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId ?: "" @@ -305,7 +348,10 @@ class DeviceAlbumViewModel( ) refreshRole() // Nothing to sync until a server album is chosen. - if (target.isNotBlank()) reconcileNow() + if (target.isNotBlank()) { + if (!hadTarget) _mirrorChoice.value = true + reconcileNow() + } } } @@ -325,6 +371,7 @@ class DeviceAlbumViewModel( if (result.strandedCopies > 0) { _notice.value = "Фото без серверной копии: ${result.strandedCopies}." } + _mirrorChoice.value = mode != SyncMode.DISABLED } is RetargetResult.Refused -> _notice.value = result.reason @@ -348,7 +395,10 @@ class DeviceAlbumViewModel( mode = mode, ) ) { - is RetargetResult.Linked -> refreshRole() + is RetargetResult.Linked -> { + refreshRole() + _mirrorChoice.value = mode != SyncMode.DISABLED + } is RetargetResult.Refused -> _notice.value = result.reason } } diff --git a/android/app/src/test/java/com/galo/android/data/db/MigrationTest.kt b/android/app/src/test/java/com/galo/android/data/db/MigrationTest.kt new file mode 100644 index 0000000..6718313 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/db/MigrationTest.kt @@ -0,0 +1,115 @@ +package com.galo.android.data.db + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The two-way mirroring migration must preserve the existing sync index: the + * origin column defaults to OWN for rows written before mirroring existed, the + * baseline starts at 0 and the deletion journal table appears. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class MigrationTest { + + @Test + fun `migration 6 to 7 adds mirroring state without losing rows`() { + val context = ApplicationProvider.getApplicationContext() + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) + .callback( + object : SupportSQLiteOpenHelper.Callback(6) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS synced_albums (" + + "deviceAlbumId TEXT NOT NULL, deviceAlbumName TEXT NOT NULL, " + + "serverAlbumId TEXT NOT NULL, mode TEXT NOT NULL, " + + "updatedAt INTEGER NOT NULL, PRIMARY KEY(deviceAlbumId))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS device_assets (" + + "assetKey TEXT NOT NULL, deviceAlbumId TEXT NOT NULL, " + + "mediaStoreId INTEGER NOT NULL, uri TEXT NOT NULL, " + + "displayName TEXT NOT NULL, mimeType TEXT, size INTEGER NOT NULL, " + + "dateModified INTEGER NOT NULL, contentHash TEXT, " + + "serverPhotoId TEXT, uploadedAt INTEGER, state TEXT NOT NULL, " + + "PRIMARY KEY(assetKey))", + ) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, old: Int, new: Int) = Unit + }, + ) + .build() + val helper = FrameworkSQLiteOpenHelperFactory().create(config) + try { + val db = helper.writableDatabase + db.execSQL("INSERT INTO synced_albums VALUES ('b1','Camera','srv1','COPY',1)") + db.execSQL( + "INSERT INTO device_assets VALUES " + + "('b1/1','b1',1,'u','n','image/jpeg',1,1,'h','p1',null,'UPLOADED')", + ) + + GaloDatabase.MIGRATION_6_7.migrate(db) + + assertEquals("OWN", stringOf(db, "SELECT origin FROM device_assets WHERE assetKey='b1/1'")) + assertEquals( + 0L, + longOf(db, "SELECT mirrorFromCreatedAt FROM synced_albums WHERE deviceAlbumId='b1'"), + ) + assertEquals(0L, longOf(db, "SELECT COUNT(*) FROM deleted_assets")) + // The preserved asset is still there. + assertEquals(1L, longOf(db, "SELECT COUNT(*) FROM device_assets")) + } finally { + helper.close() + } + } + + private fun stringOf(db: SupportSQLiteDatabase, sql: String): String? = + db.query(sql).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + + private fun longOf(db: SupportSQLiteDatabase, sql: String): Long = + db.query(sql).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else -1L } + + @Test + fun `migration 7 to 8 adds the sync-disabled marker preserving metadata`() { + val context = ApplicationProvider.getApplicationContext() + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) + .callback( + object : SupportSQLiteOpenHelper.Callback(7) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS device_album_meta (" + + "deviceAlbumId TEXT NOT NULL, displayName TEXT, coverUri TEXT, " + + "updatedAt INTEGER NOT NULL, PRIMARY KEY(deviceAlbumId))", + ) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, old: Int, new: Int) = Unit + }, + ) + .build() + val helper = FrameworkSQLiteOpenHelperFactory().create(config) + try { + val db = helper.writableDatabase + db.execSQL("INSERT INTO device_album_meta VALUES ('b1','Camera','content://c',1)") + + GaloDatabase.MIGRATION_7_8.migrate(db) + + assertEquals(1L, longOf(db, "SELECT COUNT(*) FROM device_album_meta")) + assertEquals("Camera", stringOf(db, "SELECT displayName FROM device_album_meta WHERE deviceAlbumId='b1'")) + assertEquals(1L, longOf(db, "SELECT COUNT(*) FROM device_album_meta WHERE syncDisabledAt IS NULL")) + } finally { + helper.close() + } + } +} diff --git a/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt b/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt index bfff3ec..cbdf3a0 100644 --- a/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt +++ b/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt @@ -5,6 +5,7 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.SyncMode @@ -91,4 +92,44 @@ class SyncDaoTest { dao.deleteAsset(key) assertNull(dao.getAsset(key)) } + + @Test + fun `deletion tombstones are stored and cleared`() = runBlocking { + val tombstone = DeletedAssetEntity( + photoId = "p1", + deviceAlbumId = "bucket1", + status = DeletedAssetEntity.PENDING, + createdAt = 1L, + ) + dao.upsertDeletedAsset(tombstone) + + assertEquals("p1", dao.getDeletedAsset("p1")?.photoId) + assertEquals(1, dao.getDeletedAssets("bucket1").size) + + dao.deleteDeletedAsset("p1") + assertNull(dao.getDeletedAsset("p1")) + + dao.upsertDeletedAsset(tombstone) + dao.clearDeletedAssets("bucket1") + assertEquals(0, dao.getDeletedAssets("bucket1").size) + } + + @Test + fun `an asset defaults to origin OWN and keeps MIRROR when set`() = runBlocking { + dao.upsertAssets( + listOf( + DeviceAssetEntity( + DeviceAssetEntity.key("bucket1", 1L), "bucket1", 1L, "u", "n", + "image/jpeg", 1L, 1L, null, null, null, LocalAssetState.LOCAL.name, + ), + DeviceAssetEntity( + DeviceAssetEntity.key("bucket1", 2L), "bucket1", 2L, "u", "n", + "image/jpeg", 1L, 1L, "c", "p1", null, LocalAssetState.PREVIEW.name, + origin = "MIRROR", + ), + ), + ) + assertEquals("OWN", dao.getAsset(DeviceAssetEntity.key("bucket1", 1L))?.origin) + assertEquals("MIRROR", dao.getAsset(DeviceAssetEntity.key("bucket1", 2L))?.origin) + } } diff --git a/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt index 3b8d5ac..cd1ae99 100644 --- a/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt +++ b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt @@ -12,7 +12,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -69,6 +71,18 @@ class SyncRepositoryTest { assertEquals("p1", db.syncDao().getAsset("b1/1")?.serverPhotoId) } + @Test + fun `unlink marks sync disabled and relinking clears it`() = runBlocking { + repo.link("b1", "Camera", "srv1", SyncMode.MOVE) + assertFalse("a fresh link is not disabled", repo.isSyncDisabled("b1")) + + repo.unlink("b1") + assertTrue("unlinking must be remembered", repo.isSyncDisabled("b1")) + + repo.link("b1", "Camera", "srv1", SyncMode.COPY) + assertFalse("enabling sync clears the off marker", repo.isSyncDisabled("b1")) + } + @Test fun `uploaded counts are per album and skip assets without a server photo`() = runBlocking { db.syncDao().upsertAssets( diff --git a/android/app/src/test/java/com/galo/android/data/sync/ApiSyncRemoteTest.kt b/android/app/src/test/java/com/galo/android/data/sync/ApiSyncRemoteTest.kt new file mode 100644 index 0000000..9aa1b87 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/ApiSyncRemoteTest.kt @@ -0,0 +1,43 @@ +package com.galo.android.data.sync + +import com.galo.android.data.network.dto.PhotoDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ApiSyncRemoteTest { + + private fun dto( + download: String? = null, + original: String? = null, + preview: String? = null, + medium: String? = null, + thumb: String? = null, + ) = PhotoDto( + id = "p1", + downloadUrl = download, + originalUrl = original, + previewUrl = preview, + mediumUrl = medium, + thumbUrl = thumb, + ) + + @Test + fun `copy mode downloads the original`() { + assertEquals("/d", pickVariant(dto(download = "/d", original = "/o"), full = true)) + assertEquals("/o", pickVariant(dto(original = "/o"), full = true)) + } + + @Test + fun `move mode downloads the lightest preview`() { + assertEquals("/p", pickVariant(dto(preview = "/p", medium = "/m", thumb = "/t"), full = false)) + assertEquals("/m", pickVariant(dto(medium = "/m", thumb = "/t"), full = false)) + assertEquals("/t", pickVariant(dto(thumb = "/t"), full = false)) + } + + @Test + fun `nothing to download is null`() { + assertNull(pickVariant(dto(), full = true)) + assertNull(pickVariant(dto(), full = false)) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt b/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt index 6b8e177..30f6686 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt @@ -31,6 +31,30 @@ class ProvenanceStampTest { assertEquals("PREVIEW", ProvenanceStamp.decode(legacy)?.mode) } + @Test + fun `marker round-trips the origin`() { + val marker = Provenance( + serverAlbumId = "srv1", + serverPhotoId = "p1", + originalHash = "h", + deviceAlbumId = "b1", + mode = "MOVE", + assetKey = null, + origin = "MIRROR", + ) + + val decoded = ProvenanceStamp.decode(ProvenanceStamp.encode(marker)) + + assertEquals("MIRROR", decoded?.origin) + } + + @Test + fun `a marker without an origin decodes to null (legacy)`() { + val legacy = "galo|album=srv1|photo=p1|hash=h|device=b1|mode=COPY|asset=b1/1" + + assertNull(ProvenanceStamp.decode(legacy)?.origin) + } + @Test fun `bad markers decode to null`() { assertNull(ProvenanceStamp.decode(null)) diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt index ae99c3e..eb05b66 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.domain.model.LocalAssetState @@ -12,6 +13,7 @@ import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -34,6 +36,8 @@ class SyncCoordinatorTest { var serverMap: Map = emptyMap() val serverMapByAlbum = mutableMapOf>() var failChecksumsFor: Set = emptySet() + var failDeletesFor: Set = emptySet() + var photoCreatedAt: Long = 0L override suspend fun serverChecksums(serverAlbumId: String): Map { checksumCalls++ @@ -41,11 +45,33 @@ class SyncCoordinatorTest { return serverMapByAlbum[serverAlbumId] ?: serverMap } + override suspend fun serverPhotos(serverAlbumId: String): List { + if (serverAlbumId in failChecksumsFor) throw RuntimeException("album unavailable") + val map = serverMapByAlbum[serverAlbumId] ?: serverMap + return map.map { (checksum, id) -> + ServerPhoto( + id = id, + checksum = checksum, + displayName = "a.jpg", + mimeType = "image/jpeg", + isVideo = false, + takenAtMillis = null, + createdAtMillis = photoCreatedAt, + width = 0, + height = 0, + size = 0, + ) + } + } + + override suspend fun fetchPhoto(photo: ServerPhoto, full: Boolean): ByteArray? = ByteArray(4) + override suspend fun renamePhoto(photoId: String, newName: String) { renames += photoId to newName } override suspend fun deletePhoto(photoId: String) { + if (photoId in failDeletesFor) throw RuntimeException("forbidden") deletes += photoId } @@ -54,6 +80,28 @@ class SyncCoordinatorTest { } } + private class FakeMirror : AssetMirror { + val materialized = mutableListOf() + val removed = mutableListOf() + var result: MirrorResult? = null + var removeResult = true + + override suspend fun materialize( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + ): MirrorResult { + materialized += photo.id + return result ?: MirrorResult.Done(mediaStoreId = 1000L + materialized.size, uri = "content://m/${photo.id}") + } + + override suspend fun remove(uri: String): Boolean { + removed += uri + return removeResult + } + } + private class FakeQueue : SyncUploadQueue { val enqueued = mutableListOf>() val pending = mutableSetOf() @@ -97,6 +145,7 @@ class SyncCoordinatorTest { private lateinit var provider: FakeProvider private lateinit var queue: FakeQueue private lateinit var offloader: FakeOffloader + private lateinit var mirror: FakeMirror private lateinit var coordinator: SyncCoordinator @Before @@ -109,7 +158,8 @@ class SyncCoordinatorTest { provider = FakeProvider() queue = FakeQueue() offloader = FakeOffloader() - coordinator = SyncCoordinator(db.syncDao(), provider, remote, queue, offloader) + mirror = FakeMirror() + coordinator = SyncCoordinator(db.syncDao(), provider, remote, queue, offloader, mirror) } @After @@ -548,4 +598,244 @@ class SyncCoordinatorTest { assertEquals(listOf("b1"), db.syncDao().indexedAlbumIds()) } + + // --- Two-way materialization ------------------------------------------ + + private suspend fun linkFrom(mode: SyncMode, from: Long) = db.syncDao().upsertSyncedAlbum( + SyncedAlbumEntity("b1", "Camera", "srv1", mode.name, 1L, mirrorFromCreatedAt = from), + ) + + @Test + fun `a new server photo is materialized in copy mode`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertEquals(listOf("p1"), mirror.materialized) + val row = db.syncDao().getAssets("b1").single() + assertEquals("p1", row.serverPhotoId) + assertEquals("MIRROR", row.origin) + assertEquals(LocalAssetState.UPLOADED.name, row.state) + } + + @Test + fun `a new server photo is materialized as a preview in move mode`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertEquals(listOf("p1"), mirror.materialized) + assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAssets("b1").single().state) + } + + @Test + fun `a materialized photo already on the device is not downloaded`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("h" to "p1") + provider.items = listOf(asset("b1/9", hash = "h")) + + coordinator.reconcile("b1") + + assertTrue("content already on the device must not be duplicated", mirror.materialized.isEmpty()) + } + + @Test + fun `a server deletion removes the local mirror`() = runBlocking { + link(SyncMode.COPY) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + + coordinator.reconcile("b1") + + assertEquals(listOf("content://media/external/images/media/1"), mirror.removed) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `only-new baseline skips photos older than the link`() = runBlocking { + linkFrom(SyncMode.COPY, from = 1000L) + remote.photoCreatedAt = 500L + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertTrue("photos predating the baseline must not be downloaded", mirror.materialized.isEmpty()) + } + + @Test + fun `only-new baseline downloads photos after the link`() = runBlocking { + linkFrom(SyncMode.COPY, from = 1000L) + remote.photoCreatedAt = 2000L + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertEquals(listOf("p1"), mirror.materialized) + } + + @Test + fun `an explicitly deleted mirror is removed on the server`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + db.syncDao().upsertDeletedAsset( + DeletedAssetEntity("p1", "b1", DeletedAssetEntity.PENDING, 1L), + ) + + coordinator.reconcile("b1") + + assertEquals("an explicit in-app deletion reaches the server", listOf("p1"), remote.deletes) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `a mirror whose local copy is missing is recreated, not deleted on the server`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + mirror.materialized.clear() + + coordinator.reconcile("b1") + + assertTrue("a server-origin photo must not be deleted for a lost mirror", remote.deletes.isEmpty()) + assertNull("the stale row is dropped so the photo is re-materialized", db.syncDao().getAsset("b1/1")) + assertEquals(listOf("p1"), mirror.materialized) + } + + @Test + fun `a confirmed server deletion clears the tombstone`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("h1" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h1", "p1", null, LocalAssetState.UPLOADED.name, origin = "OWN", + ), + ), + ) + coordinator.reconcile("b1") + assertEquals(1, db.syncDao().getDeletedAssets("b1").size) + + // The server delete landed: the photo is no longer in the album. + remote.serverMap = emptyMap() + coordinator.reconcile("b1") + + assertEquals(0, db.syncDao().getDeletedAssets("b1").size) + } + + @Test + fun `a failed album read does not delete local mirrors`() = runBlocking { + link(SyncMode.COPY) + remote.failChecksumsFor = setOf("srv1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + + val contacted = coordinator.reconcile("b1") + + assertEquals(false, contacted) + assertNotNull("a transient read failure must not drop the mirror", db.syncDao().getAsset("b1/1")) + assertTrue("nothing must be removed on a failed read", mirror.removed.isEmpty()) + } + + @Test + fun `unlinking does not remove materialized files`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + coordinator.reconcile("b1") + assertEquals(listOf("p1"), mirror.materialized) + + db.syncDao().deleteSyncedAlbum("b1") + val contacted = coordinator.reconcile("b1") + + assertEquals(false, contacted) + assertTrue("unbinding must not delete local files", mirror.removed.isEmpty()) + } + + @Test + fun `materialization goes into a single bucket when several albums share a target`() = runBlocking { + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b1", "Camera", "srv1", SyncMode.COPY.name, 1L)) + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b2", "Trips", "srv1", SyncMode.COPY.name, 2L)) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + coordinator.reconcile("b2") + + assertEquals("exactly one bucket must receive the photo", listOf("p1"), mirror.materialized) + } + + @Test + fun `a refused server deletion does not abort the pass and is recorded`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("h1" to "p1") + remote.failDeletesFor = setOf("p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h1", "p1", null, LocalAssetState.UPLOADED.name, origin = "OWN", + ), + ), + ) + + coordinator.reconcile("b1") + + assertEquals( + DeletedAssetEntity.FAILED, + db.syncDao().getDeletedAsset("p1")?.status, + ) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `a restored server photo is materialized again`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + assertEquals(listOf("p1"), mirror.materialized) + + // Deleted on the server: the local mirror goes away (no tombstone). + remote.serverMap = emptyMap() + coordinator.reconcile("b1") + assertNull(db.syncDao().getAssets("b1").firstOrNull { it.serverPhotoId == "p1" }) + + // Restored from the server trash: it comes back. + remote.serverMap = mapOf("c1" to "p1") + coordinator.reconcile("b1") + + assertEquals(2, mirror.materialized.count { it == "p1" }) + } } diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt index 419e4d4..03d020a 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt @@ -184,4 +184,100 @@ class SyncPlannerTest { ) assertEquals(listOf(SyncAction.Link(asset, "p9")), actions) } + + @Test + fun `a missing local copy of a server photo is not deleted on the server`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf( + IndexedAsset("b1/1", "p1", "c1", "a.jpg", origin = "MIRROR"), + ), + serverChecksums = mapOf("c1" to "p1"), + hashesPresentElsewhere = emptySet(), + ) + assertTrue("a server-origin photo must never be deleted for a lost mirror", actions.isEmpty()) + } + + @Test + fun `an explicitly deleted mirror is deleted on the server`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf(IndexedAsset("b1/1", "p1", "c1", "a.jpg", origin = "MIRROR")), + serverChecksums = mapOf("c1" to "p1"), + hashesPresentElsewhere = emptySet(), + explicitlyDeleted = setOf("p1"), + ) + assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions) + } + + @Test + fun `a stale own upload whose content left the device is deleted`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg", origin = "OWN")), + serverChecksums = mapOf("h1" to "p1"), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions) + } + + // --- Two-way materialization ------------------------------------------ + + private fun serverPhoto(id: String, checksum: String? = "c$id") = ServerPhoto( + id = id, + checksum = checksum, + displayName = "$id.jpg", + mimeType = "image/jpeg", + isVideo = false, + takenAtMillis = null, + createdAtMillis = 0L, + width = 0, + height = 0, + size = 0, + ) + + @Test + fun `an unrepresented server photo is downloaded`() { + val photo = serverPhoto("p1") + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(photo), + representedPhotoIds = emptySet(), + deviceHashes = emptySet(), + tombstonedPhotoIds = emptySet(), + ) + assertEquals(listOf(photo), plan) + } + + @Test + fun `a represented server photo is not downloaded again`() { + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(serverPhoto("p1")), + representedPhotoIds = setOf("p1"), + deviceHashes = emptySet(), + tombstonedPhotoIds = emptySet(), + ) + assertTrue(plan.isEmpty()) + } + + @Test + fun `a tombstoned server photo is not materialized again`() { + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(serverPhoto("p1")), + representedPhotoIds = emptySet(), + deviceHashes = emptySet(), + tombstonedPhotoIds = setOf("p1"), + ) + assertTrue(plan.isEmpty()) + } + + @Test + fun `content already on the device is not downloaded twice`() { + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(serverPhoto("p1", checksum = "h1")), + representedPhotoIds = emptySet(), + deviceHashes = setOf("h1"), + tombstonedPhotoIds = emptySet(), + ) + assertTrue("the same content must not be materialized again", plan.isEmpty()) + } } diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncRecoveryTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncRecoveryTest.kt new file mode 100644 index 0000000..2d560d1 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncRecoveryTest.kt @@ -0,0 +1,58 @@ +package com.galo.android.data.sync + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeviceAlbumMetaEntity +import com.galo.android.data.media.MediaStoreDeviceSource +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SyncRecoveryTest { + + private lateinit var db: GaloDatabase + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `recovery is skipped for an album the user turned off`() = runBlocking { + // The album was explicitly unlinked; its previews may still carry markers. + db.syncDao().upsertAlbumMeta( + DeviceAlbumMetaEntity( + deviceAlbumId = "b1", + displayName = null, + coverUri = null, + updatedAt = 1L, + syncDisabledAt = 2L, + ), + ) + + val recovered = SyncRecovery(context, db.syncDao(), MediaStoreDeviceSource(context)) + .recover("b1") + + assertEquals(0, recovered) + assertNull("a disabled album must not be relinked", db.syncDao().getSyncedAlbum("b1")) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt index 93a61ca..6b59607 100644 --- a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt @@ -64,4 +64,20 @@ class DeviceAlbumOriginalsTest { ) assertEquals(setOf(1L, 3L), ids) } + + @Test + fun `a materialized preview uses the server original`() { + val map = serverOriginalsByMediaId( + listOf( + DeviceAssetEntity( + assetKey = "b/5", deviceAlbumId = "b", mediaStoreId = 5L, + uri = "content://media/external/images/media/5", displayName = "x.jpg", + mimeType = "image/jpeg", size = 0L, dateModified = 0L, contentHash = "h", + serverPhotoId = "p5", uploadedAt = null, + state = LocalAssetState.PREVIEW.name, origin = "MIRROR", + ), + ), + ) + assertEquals("p5", map[5L]) + } } diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/MirrorScopeDialogTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/MirrorScopeDialogTest.kt new file mode 100644 index 0000000..b743ab3 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/MirrorScopeDialogTest.kt @@ -0,0 +1,54 @@ +package com.galo.android.ui.devicealbum + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class MirrorScopeDialogTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun `choosing only new reports it`() { + var all: Boolean? = null + compose.setContent { + GaloTheme { + MirrorScopeDialog( + onOnlyNew = { all = false }, + onDownloadAll = { all = true }, + onDismiss = {}, + ) + } + } + compose.onNodeWithTag(TAG_MIRROR_SCOPE).assertIsDisplayed() + compose.onNodeWithTag(TAG_MIRROR_SCOPE_ONLY_NEW).performClick() + assertEquals(false, all) + } + + @Test + fun `choosing download all reports it`() { + var all: Boolean? = null + compose.setContent { + GaloTheme { + MirrorScopeDialog( + onOnlyNew = { all = false }, + onDownloadAll = { all = true }, + onDismiss = {}, + ) + } + } + compose.onNodeWithTag(TAG_MIRROR_SCOPE_ALL).performClick() + assertEquals(true, all) + } +}