From ffbacb764519756d7b7fca0ec08f58b06e4897b2 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Wed, 13 Jan 2021 21:43:46 +0100 Subject: [PATCH] New background task modes --- CapCollector.xcodeproj/project.pbxproj | 21 +- .../xcshareddata/swiftpm/Package.resolved | 9 - .../UserInterfaceState.xcuserstate | Bin 40241 -> 56289 bytes .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 + CapCollector/AppDelegate.swift | 13 +- CapCollector/Data/Database.swift | 848 +++++++++++------- CapCollector/Data/Download.swift | 43 +- CapCollector/Data/Storage.swift | 35 +- CapCollector/Data/Upload.swift | 56 +- .../Extensions/DispatchGroup+Extensions.swift | 28 + .../Presentation/GridViewController.swift | 6 +- CapCollector/Presentation/ImageSelector.swift | 4 +- CapCollector/TableView.swift | 389 ++------ 13 files changed, 797 insertions(+), 661 deletions(-) create mode 100644 CapCollector.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist create mode 100644 CapCollector/Extensions/DispatchGroup+Extensions.swift diff --git a/CapCollector.xcodeproj/project.pbxproj b/CapCollector.xcodeproj/project.pbxproj index 7361e2b..2baab75 100644 --- a/CapCollector.xcodeproj/project.pbxproj +++ b/CapCollector.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 591832CE21A2A97E00E5987D /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591832CD21A2A97E00E5987D /* Cap.swift */; }; 591FDD1E234E151600AA379E /* SearchAndDisplayAccessory.xib in Resources */ = {isa = PBXBuildFile; fileRef = 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */; }; 591FDD20234E162000AA379E /* SearchAndDisplayAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */; }; + 88A89ECE25AF420F00323B64 /* DispatchGroup+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A89ECD25AF420F00323B64 /* DispatchGroup+Extensions.swift */; }; CE0A501124752A9800A9E753 /* TileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A501024752A9800A9E753 /* TileImage.swift */; }; CE0A5013247D745200A9E753 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A5012247D745200A9E753 /* Colors.swift */; }; CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CECD209D81DE00932C01 /* AppDelegate.swift */; }; @@ -45,7 +46,6 @@ CEB269572445DB56004B74B3 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = CEB269562445DB56004B74B3 /* SQLite */; }; CEB269592445DB72004B74B3 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB269582445DB72004B74B3 /* Database.swift */; }; CEB2695B2445E54E004B74B3 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB2695A2445E54E004B74B3 /* UIColor+Extensions.swift */; }; - CEC7F815245A2B1200B896B1 /* JGProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = CEC7F814245A2B1200B896B1 /* JGProgressHUD */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -56,6 +56,7 @@ 591832CD21A2A97E00E5987D /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = ""; }; 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchAndDisplayAccessory.xib; sourceTree = ""; }; 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndDisplayAccessory.swift; sourceTree = ""; }; + 88A89ECD25AF420F00323B64 /* DispatchGroup+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchGroup+Extensions.swift"; sourceTree = ""; }; CE0A501024752A9800A9E753 /* TileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileImage.swift; sourceTree = ""; }; CE0A5012247D745200A9E753 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; CE56CECA209D81DD00932C01 /* CapCollector.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CapCollector.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -96,7 +97,6 @@ buildActionMask = 2147483647; files = ( CEB269572445DB56004B74B3 /* SQLite in Frameworks */, - CEC7F815245A2B1200B896B1 /* JGProgressHUD in Frameworks */, CE5B7D032458C921002E5C06 /* Reachability in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -168,6 +168,7 @@ CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */, CE56CEEC209D83B400932C01 /* UIViewExtensions.swift */, CE56CEED209D83B400932C01 /* ViewControllerExtensions.swift */, + 88A89ECD25AF420F00323B64 /* DispatchGroup+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -232,7 +233,6 @@ packageProductDependencies = ( CEB269562445DB56004B74B3 /* SQLite */, CE5B7D022458C921002E5C06 /* Reachability */, - CEC7F814245A2B1200B896B1 /* JGProgressHUD */, ); productName = CapCollector; productReference = CE56CECA209D81DD00932C01 /* CapCollector.app */; @@ -271,7 +271,6 @@ packageReferences = ( CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */, CE5B7D012458C921002E5C06 /* XCRemoteSwiftPackageReference "Reachability" */, - CEC7F813245A2B1200B896B1 /* XCRemoteSwiftPackageReference "JGProgressHUD" */, ); productRefGroup = CE56CECB209D81DD00932C01 /* Products */; projectDirPath = ""; @@ -330,6 +329,7 @@ CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */, CEB269592445DB72004B74B3 /* Database.swift in Sources */, CE56CF02209D83B800932C01 /* RoundedImageView.swift in Sources */, + 88A89ECE25AF420F00323B64 /* DispatchGroup+Extensions.swift in Sources */, CE0A501124752A9800A9E753 /* TileImage.swift in Sources */, CE56CEF8209D83B800932C01 /* CapCell.swift in Sources */, CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */, @@ -560,14 +560,6 @@ minimumVersion = 0.12.2; }; }; - CEC7F813245A2B1200B896B1 /* XCRemoteSwiftPackageReference "JGProgressHUD" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/JonasGessner/JGProgressHUD"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.1.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -581,11 +573,6 @@ package = CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; - CEC7F814245A2B1200B896B1 /* JGProgressHUD */ = { - isa = XCSwiftPackageProductDependency; - package = CEC7F813245A2B1200B896B1 /* XCRemoteSwiftPackageReference "JGProgressHUD" */; - productName = JGProgressHUD; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CE56CEC2209D81DD00932C01 /* Project object */; diff --git a/CapCollector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CapCollector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 906827f..366ecab 100644 --- a/CapCollector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CapCollector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "JGProgressHUD", - "repositoryURL": "https://github.com/JonasGessner/JGProgressHUD", - "state": { - "branch": null, - "revision": "08d130dd614a743f813286f096804c43a6ffa3f6", - "version": "2.1.0" - } - }, { "package": "Reachability", "repositoryURL": "https://github.com/ashleymills/Reachability.swift", diff --git a/CapCollector.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate b/CapCollector.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate index 0577e13cd7f7befdc935adab929d237592256af7..bed6008030f44224b89d465e8f8cafb598bb5e8d 100644 GIT binary patch literal 56289 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 vWAWiP;{r}wWt=-&w?|2opq8H@QZ83Hii delta 19594 zcma)k2Y3_57Vge$myIp=g1cdQgm_9kBc2m4hUR12#$hd;3PN&&Vlpb68IWi1>b`2!3}T+`~ZFi_rP!9 z0eB3afEVB;G=Zj23eBK7w1Ae-3R*)OXbT;n47x)P=nZ{gFjT@27z)E+IE;hwFaeH& zqv04h7G}Uqm<2W2FbC$sJXi?JU^%RSm9P;u!DiS3Tj3-)87_m%VF&DlE8t4F3a*B0 z;9A%PH^OajJKO;ez=QA*JPc34)9^OD1Al;b;g9eq_%pl*e}TWk-{Euk0=|TA;a?<8 zG9*iKBu|===A;+tP5O|&8q$xHlm27?8At|^A!HO8O~#N(WHR{?*^lf`4k8DWL&!1Y zSh9+&CTqxAvW~1L8^}hoiEJhXaxyuEoJ!6jXOj!bMdV^~3Hd3xl3YdZCHIm0$php; z@(_8LJVG8NkC7+I3*<%eEAlG&EqR-~L;gVCB{jd14=6~H6h+Y#L$MS`@sxxzqD(1k z%7${JoG4GK7v)6-QbANIl}4pgy{SG_U+N>OAJv~4KnMHdu zb&dLtx=!7qex~kGzfcdUN7NtGE9y1%C-sIVX^OU|d(sZHBke>x(=N0t?MBP!UbH_Q zK!?zwbPOF!$Ib#x2eN>8FE(^F{8 zY4)?q`Z4{4 zeo8;1pVKesm-HX>8wN5Y!!r`boUvdm87sz-ablEA2ouVLG2x7giC`j`C?=YTXVRE{ zOn+uLGlCh(jAC*$OfJ*FG%`(0GtaxBkUu$HV9>&&{a?yMK931?Mo1RKdlvC(Wio5UuwDQr61pB>B&V{_R8 zwva7mOV|pwlC5Ry*e14_6}TvUb~)R@cCstjmFymNFT0Q3&mLe8 zvWM8i>=E`TdyYNNUSKb>-?KN^o9r$2Hv1#{6Z;$cfPKt9(XcPsx9nf+-yFr!9LMpT zDJSJ@IXljt3+2MNa8AWVaFJXT7tO_Rv0NM%&n0kaTsqf}8^Mj_MscILG2B?LfGgyR zxN+Qgu8gbV>bQEYjcezob8|T@H;-G$eabE6I=D`51-FJ<%WdE`a$C4BxUHP#Fn5AG z&7J4I)3^XdEmejq=bAHiqw*?bW{jvvpL@%4NwKaroxPvd9uvv@7PfM3Xe%74Lc<+t(M z`5pXDeiy%6!|&nu@(1}7{8|1Se}(^szscX?Z}UI$zwl4^Klr~TP{K$g5)+B3#6sdN z@sapS{3LRTza&5sC<&4%B%zXMNunf4(pU14WT<4AWRzsIq(D+ADUytnjF;3&>Lm@5 zMoE(dN!lb+Br_xnBnu^-k`6$M8K>$O1=8%?IvJy?+xmJ%GEEDsNh z50fiZVPSHWQV}Rm3{?e&E0R_5NnuG}6FmqM!j!Nl+zJ0Kq9@@%I1)~TGvPwG5^jV{ zpan)?1y0}viC`oc3q86B55kk^MR*b3gb(3M_z`l!L@-6r1VJ+d-4IMfFo{Dj1;JFo zT?^9hMe~N&L=bU=|0*Jqh$5ni7$R1X3TA@2U?EtpBI1bzB9TZUk_9WlLx>Z`3iX0} zrnHy#R&Pg1aZy!ua$Zq&S*5nQPn5Q2pI{KCb?774uI+8C?bpXY(eQ?ki2j6s2hmTk z?jQyTc7prY#9%Dn5Mn4Xj2J$=-(dfo>?-wW{8_0k&aN)1RSSe*E7)X86SZ6NRC(#8 z`DOm);_EIEqlnSO7-H-w_uS&_s;biL67>lES9R6UeuIY&>f4DQXN>4KxPQ(#b#Aqx z30XuQ;lF~&CUS^e!CvSoIIJMlL_SduuX4x(Cc5#02@>xla0f~&qnBhi%6Z}3Mo)!8}4YF)tX(@})uA3Y>qNyr)*?Z>2G` zbUm?w@L#@MWSaSi+_F4%@R01v0(Cdp47oNFTlBdM3EPP6|4i6L?A9mfyJ{a%+Cl6W z!UU(w#3AA^aRhVlC~=H9PMjE7r7l*>bP$iME-F!1RcDu!kIpO0ttnBLR%2EP5rRqx z7h*+3Q)Tf-P86*S-&tmO^J(H7;onJ|AVBtFE?#BYTEO5z^z3-K#)Uq}*?g%lxmB_{jt#C_rsrhA%@D)h&_X*9q|J0WX` z8}X9(Qyd$A5U+^WLb}jf=(B=&L%b#a68Z`s3H=23x!FV1fCSV(&;=~u^a--K^4o;? zuNw|v3@ix$)u0D30j59-%z(KtKo}?t5(W!HgrTc}C9ne4zy{a?J7JhGTo@sY5=IMS zaGUmKPt<-qkk^cD3Rd`sh4{IIEB!-SblnHsarXfaVWi+Rs;FO}yf|2{E>AGL(i`}6 zHzxL?t}O&-coYDFFpMBj$mjqHA?qJ7f-oJ7nVHhT`9;O*h=72Ap;hWi?fyKuc6WbQ z9}odzF`OV0M1g1!BV-FXLava9;RG=t0VHBL^8~d}po6p49P|c##dq})@;fkm7&>iG zwk!e+1S3R127$p~2p9^6f#E`-P$Y~K#tX$li3rFjFdB>jV|9R(3S~mAP-g%nIHO4w z6r@8VNTCSsMx&4@T@8xBI4~X*gA!0GlnWI?rBEeQ3pIk1_T0d94X6Y4{{@NxlV&iJ z@c&$D23kNX&;S7-&<5JU1TYay0+YcMFcnM#AA{*&hR`512`z#~Ktj7PQJ5@D6+RYb z2(yGaf>xL>ED#on)yx93!5lCbXu&)%AAAB9fQ4Z2BCr@N0iS}U;4`pHSR!l}&I)&g zR|rTE=!HNu0s|2!K%fzUIS8ylU>^dP5V(iHn}2rFN?j*4W=gZf=|DR>-&{K}&)Vug zlL+Y2)wDg&7|f+Nw(yGNpaH3(?w#ADLKxFgbx;x-4y( z(mwCAND53otb4yM#{})BynY|neMFZ$DN{NU56=|sj|G+=7Wz{6bc$9e9Q|RTv%2JI znbHhhp&^BK9~Syb_jI~8vnWdYI><C+G># zr&FAp4Uhd`0Abw;<UwomM)!;oRoz!zT2NhR zM&FSge5}`3QiJ-0Di|eJ6#*lKt-`hssv4SKR-9L*tgk?PQVLqL7Q=nFqZYrDxKZeuc3^)_cg0tZqI9E6)oEI(#7lp5c zOTyQ}W#NkO%{n;my>yG(g zHQ_tm)&NAR(D3BL;ub(fPF zSRthhE&T(&CagQ)E8%em{8M-$te+e0J`(;-0z=Z0sZHkOef@QKe zTfOXNU|M&jq=f9D6Q4B3;{So6BOxyQ4ATNjKa#W{9kKMJC22)klQyI+X-C?VJxK@Q zweY9#MtCdyCHyVCLx4a4bdgSa`AIiYM!Ew#(h~uw2LJ&E0b>Mu2xAa1(SZ;Q`j85Y z0vU_|*$o5U6f9jyhLPdog#d*Bt-oV7UzHjbMr14*kC7nb5MVpV1Ozx87UReiG93#; zrb0Oaya+~bvJV0h1dRR{4zJ-l{X!1VEi7bl((b{&-hj+da)ch4;lfq~OgqUwWN%y( zu1^n67MVd7ir{3DS!6buL*|lsq?*hp@x*C{fH?vd2v{Oug@82zHVD`vVAn+!i3$;8 zQ$m)KW%#oK0eegg{D*)O0x|^L#jhUkF=`PpYDJ)@0V5=0)JC?87Xl6lIO^{hqZnzd zq50Fu=^{oSBjDUY&OpFLhtU{v4mnT6Xs(EntBBEjLXCjizcEI-LKf%H4U3VX-lgPn zJuJ&aSUg3>%olJA-1MkqlB>zhA}VXhwPY8$j$BV}AUBem$j=e*LckjV9|U|6@IwGM zz#oAC1OmIrE#1u74zH6tF)F(e2r@7yM0k!sga}IHdr*#xpqxP1Y}8K^C23{#(RT2!+DPs%<)dPXF zZZIe*27@vq!^I1MbloTt?-&fb?xs_=ls$%mvO}Ox2h|gSzB(udQqGhc7Kw5Z`SB6v z2PG5v(eGdR5n-szoAT4)K>3O|48WwIWFjg0b(4ZpP*E5LDwtAIAyg<8Muk%5g3BNPy~h{FdTsq2#iEvR2LPkCj}J`uTzN_1`N(<11T~^Qsjv^s12l`28uY~ z`odv zAy8?6;!_L-#CvMuAD(jce@=!rub@_m5UfO?x`SGcK#dLp2WlO)QIthaZ4k#rttiXq zqAYb-mLq6c%sCe2@Y2Ka1K=eJKiifI3JWq7G9>sH4;|>NtgmZW98{ z2(%!8b(;nO0Re>9Ita9PQ75}Q{w#HlI*&X4A_5Z(9X|zu83@c2dw$mYp8sAH_XYwJ z4dUJwd;SjfgLok@34zJ_JMQ@@Qi(zAU#Z_jvF{@=wS#(qz_f0$A5+itV&fSVfsaM0 zUWj5($6}{1aCe~IQt$L)|1F9=`yUxZv1yt%#bVP8&C(pr(-PWdZf#u9_rpCPaUft8}zs|*9578l;M2LhjV zi%olBv1xB=xOgG3R7ZI6j>R@?iO_+x0t-zCA+W524n_bQ&U2$ZMu*W6I+og(VmmVfU@)6yS?oSV(2hxM+ z!SoOs5Bs$UbRn<~f%OP%Kmfmf69Ra?*xW@A*VCRJt*1SW2dTJX(9M?H4Yc3W4M=Z# zya)&$IbRq6DH8!HhvVsTx(b1nB6SZZNtdOh7JBGQ1sjt&~ra;FZFo*G)T zglRz>|GV(`r`yHxzgryt+Tuxe4)jzSPqBD8Kz}TXyjK*tU4(46exzs7bLme-k+t+Z zdOrOLy?|awFQOOIOAy$PzySmfB5(+S!w4Kf0JrQI0>``PrQIy(AWP{Lm<6j4IALJH zX`O*^L1e+jZlU|qTSTG1K;TP*(A!0!chEb<3xSgeoYLO~Co^)i)YTyN0s4?AHg3e3 z4jMP&tWNB~$LW)LB77+l;hZSdX;G^4|4W3qwPvKj;Gi$kU+dAhB%<-vhvh^9WYO2? zyCNFj(bwtk=^OM-`WAhgzC+{j`85KU5y12DHwaus;9CT)A@Cgn*SqK+y9w|Mb&kG| z(ZHkhdjkP(=~TgwA{sxv9}}-cG+rZc!+^$Hj0Sm{{#(3sHo;B(9dp1ie=!upU?>wb)KVX>{sffkh|HZ;)uXJQLvWzujr-OpA6`}a~!(t*7 znT#{zjiF#%7+1!PkumO!2jj`~V!ROe1%Y1?z#8c{1hA~XBk&M`M+iLbVtn*aF#b${ zxR7EL2s|-B@eF}i;z|mE*WE}YGcgzmCKiFG-AFJAA`)fLNxVce6OTK56AZHelg{)J zQRt1p^A4sj0xxtZjA8~bgRwZwKye_v!~=mDA`XN<{%s<(_mSxp!}~o)W;Bzb2V<-V z#-Ab>LqrV!(2tT#CXXo2N#h)lZ(#smZz0~nJr7_bCShxoz<)fV0L9*UXF zY!QlC2yz|F90YkC6a$(0%tAdO7GOd^i73osEDZdY1qM}Wpu;kzQ;$FgMgaEsu#iXx zOJ*&zPJCMzf~FnJdIY5yn!sgllG5bY5!t7@bGe?-C%rU|PK?ejKMOA`75T*chM$iR8 zSDdtd>;7etz>E2o`5x~$GuN2!nCl425Ohb-V+C`Axyjr@&=bJ~gtt%JwJsgSgP5O) z`c=%&%su88=2zxEg1r#*M^J%a2!blXpl6uhnI~eM51B{IV+6er^hVHU1@n}7#ym&R z7ePOS7q8f*;HiDQ+e!PjW0HpXiv|BuhJ`FCwj%(+KwUdzaoubi=rUC2*XV|vm9QrN zQl6BtQWmexj1de*P${?%if|c|m|dP&R$Po7CS{e~#ba4(*8X40154I{brgmn7>Zz+ z;GRpkctFDC66;EsvTm$QbeQF3S7&cktthnCz;NA$6zjoyj(zW4O2Us<;G|we#xU$W z!oLhn_GSa|Ry6aF^=18-$E-h#rNm%GAQ*}9LNE%!=+(?qR>20dN_@vSW9(uOj72bB z_XWE(GNo?mNy+ipUs9`mwOgx^f=g@+Zd@!ICpJ#>pkN=#m9;J>UtrYZ^bJg46ZLIT zcd`jYM#hk`yzB;->}r>w(1<{l^uBJP%%&2eKh4nE-mJKvPM8a12m29%$%4B+*8rkk zmnwSROo-8@>=0cs{lnoz={mfbjo4A_Xm$)cmd#)@*(^32Z(kz>(-7>9U|$6LAvgfR zK?n{(aG0o`-tStn`Qr8@g6SXbn&Pctyc6`FJmd9w`iMKD)*qyo>(f8_U>Enp$2I!L z{XeW4Z^&AFP(!0W%Rq5Y`NN_befr@4+BN;K=mdS1q2kW+hv`%G>BGf+*unZ;R(3i& z5Bmez8SG4U7CW1r!_H;32#!Q>6oR7>#KU(if*A;Au3_i1p8z{{Av;4H>ipZGe%l_D9J_+EGR!2KOJkiv1jGYIZfdhF!~cvFq6N>;`rty9vQ;1alC~ zMKBLRHG=sF79d!NU=enJvs>6N*sbg~c00QR|Jo&B-}g8Kn-Oe5P(ToOeY==8Nu;k7 zH)C*NSzW*E+M`6Q_+2ia9_DclEBUp@J$qM!qPC0{MsV=Ee ztjR^Yw1*8pysUD3Re5%?7@AE61J)HXvBv-we*p(Rs@Q11|$eQnZh6koFI&h~Tx1vDDJm(be-3%kf4- z%CoBrN8-Lv*QXVgR(DALp`)b4r5+}xMp|HPsnyt+$*-*G;}h?J3z`{qnp^xM>py;6 zva+^$U&<7h+Wv4WE@kJi!rr2%c9%5{d2)97FvB=dABr=$d909GxNGm)xKX{lyx(Wg zTv<~zcF40}QJX~l(vaEd=ie!pzn}d(0|I4n6?*L~i<`Gu_pL*L>kD>RskG=B@{hJ< zN-fe$vJ2FMv9njy!+LiG!4YOS$ikn9#vy=1aQI&aQAms<>TuX!E1{V~EGN2%P1x6b zfH*^3B<|uMzBhmX6n1Mm0T?pJ9fgx>1r|XnjNmA$3 z6cnf{<15wKvN)k$JJZ?T6nlEOVy*^U{a;^dg!sz@(1Mv9l zOjm#a(DONV6VR9)d3xJJU1YvP)@7Os`kZ~}+4Aua<=5!{8~ z8U*(sxEjHo+Sx8)Jch6b`Hz2qPK@9L#1h?Z^k(&c93lYS|(FVFY)1BM` z%whaZW2kcx?q=Nm8^!KiKlUFZ(~#veZkd?na|Ac*-tuqbS4taluhi%MLZ5rgfJ${0 zZZ8ffDl0W)>4FlCSmQR`OaGxA3|Thmo3-PAb4cNL#1U>Ax1HO;?Zlw$A`WxAxjo}cm%?ByB~}op5TEvf}jM!a|nK^A3VYqosG#ecwNV{Jje4qetr_c zQwW|$@XUWk%s;7XAXhgfv;RxPuXV9YhrtovnHVZeLh!tR z!_9Gw8Slo+v=>}$DBc}+$^}uXXVX{nim5s`sMscO;@Bm1qCXCO%BQ}DW&Pet&0_qv$xE#lV)elA`JelJ|p-SB2fj-hFT_#xQd z;Rhplvx6Us;4Q3;g;I~6Bl$79wJ|?hTpMFos+znhF7&YvMO^0k@;SU(w+`m>#C5Q^ zoAN%xe>rNkeftFJ$;=n?CECqCwiI8Aaog-;qxl~i@#WkrzLKxv-taX5aepI-jWEoh z#|Zu`G6;J#v5EN)1~u@F1_nJs@ZLWeq!AGic*M8y?FjyY;C%!iAo#nUK^m-d@f(rI z3jCg(qJ|udO%`mubO%}J6*K=a946-ZO~3?PKSa0E>sHzS%BgvJPCfj;b8454QzdOW zQW-j9DZfVK$!GjBemUR4ck(OvmHa9m&m~U~e2U;R1o0yK1%fXT`~yL3oxJYi*LL${ zgWgZeV-^0VfhTy+#aQpBC5i6clT=;|(BrX&cw=D6A&k+#`)Lgg`;tE;vIGynzdCq4 z0RQf0$$9=OJxeZ%EO{rApts19Zm+6_zsi57XUR3p5)yov;lEgdbzhNaHHz4J<(E}P z)T%41@RSnatycwiu-4;$KqQ41qx?^}+kWQn;b_j>n#xK%`{)Az({Su+S!F|dwYmh6 zG$J)F|HCg)P4U0+4-9ImTSJj1h-CgR8tN(kteanC4@9#6^nN|5HW%e`qL)k&aE+slNr2@1K+Dzi6oLK9g8VoJM0dNvtH+5*vxF z#7<%_=_zrLI3m&%ky1pOA<`U?7KpS&q!l8q5oyyUan`d*BGYRfNiRg&cC(4JH)tKw z$v`GaFeZ~kiAcL{GD*TjGM)Z6->soxF_Jh;CP^$Jdv-|T5sp6){k9I0WJ#KiOp;Vg zCerakjUuWXrKF!^ppHzE0V0{4Kg{r7WYTGA$#BUC?Y;mzN`e;~_=~OfNr0U~GDeb( z=Ss;~Nrog-g4ftGM7kr=1CgF9B{`B@4oTFA#A5r2`hfJu6QDXi+Fw#EsSt~mNJ=GT zl5#|PA<`R>K8W;PiGxEVIP%j28_HYoKB{QbUyN_HkTgqLv9&B|L8QDx=csenW+(L2 zNZKV6-&@g=Nr(*S-fxji#a6Urn&e|d1|m}NetMV8l+67XTUdhQ7$x(u0OBS=kg#4B zH|yeuCbVRcWa+;YOORv`9+Kr)YHUN3!Mbwwx^kIPyQZd)@IXHoMM!vypG#AaG7$ez z1qQaX=(fcqt5{ioGqQH8TjD(6PvH1?Xm-d{;BbVzlrZZ*V!hlyATUS~tPF`t&;{lf zXG)!Mg<^f$CrMT?C`8L8JEjJOxZo(|AXPAq%nuKgC*i1kxiT>+Ngl6KrpUt+m0_xc z;PAx26y>nYeuE=|l>fe@)RrYHwbyxj9>2$&z`JC6c9*Ws(lbTFE-e2FWJLX31BQr$$yrPDai~u0}pa z3ZpQiIHN?PWTRB00Y(Fj1{;kr8gEo-)MPZpXqM4@qs>OUjV>5{V|3T(vC%W57e=o% zMt>T;HRg?tjC&ZH8V@nfHZC`=G_E$THLf>qG;TI-HJ)KS%Xp5l)_A`00^>!-ON^Hq zpEG{b!>7ln9;nB<9@lz2?(tWTzk9qh0VdWaG81rli4P7O|F_~ZkzmW^3>$H$xD+rCV!c{GXE zX^?4z=_u1|Q)IfpbfxJU(=O8urkhMRo9;I~XnNT6sMK2;Dvg!KOB1EZ(p2dH=^*J4 z=`iUC=_qNfbfI*E^tkkr^qTa#^oI17^k*Doa9{dB`p}Fsb2f`KOEXJ1>uuK8Y^2#J zjoE0kv1a*ZRc3-&o7n`jNoG^brkPDQTV%GxY^m8YvktQrW~_1;PX7wbfdy^;Vm#wpeYm+G(}NYQNPXtD{yY ztWH^-wK{Kg-Rg$bEvq}$N!A0c2U`!d9&WwXdb{;b>)qCSZES5kY3OW@iJm7NtQ_1N+#Ng}yd0VxraFA=FvDS%!*33+9D6vLI+{6JI9fT{INCY(baZrd zc64$J{kgVT1W15SsWjyN52I^lHE>9o^Xr)y5%IbC}>1Y%h|^{*g3>G!8z4A-MNqR0Ovu@L!5^>k8vLFT<<)?d6u(gjHO@7`HOV!_HO;lRYhTxXuA^PETytFWT#H=CyOy|?xwg6@*J-ZP zT^G52=DOUq({+t&m+N|s>qggouBTjYy54sE!SzSipIv`(z3=+K^`Yxy*Qai*8}DZ1 z*2B%z&CJch&C1Qj&Cac-o1xktM9b06S7$bE?WF!vGequj^1XSip%=eTR!KXX6g z{-X!y;qNiXqrzjh$0CnUJwEeT?$PD(g~v9J9Ui+p_IT{`IOuWM zcOHLuQl7l0k!KH2b5Bc8YmKL^XD?45&tT6`&qU8O&)%MWJqLOY_8jUt+%w;^%5#$E z6whg%(>-T;&i0(^InQ%}=VH%|o|`?t@Z9FP!*jRiKF@=mhdqyZp76Ze3-WdzpCIc-eb7dbxPXyga<{)~#2lm&z;3E7mK)OOxc4?N#bk z?^qS|jz-zfzht~?P4PKkP_Ie%iI^uQQ>$KN7uZv!nyzY2C^akFP zH|s6&?%^%r*yn}M zE1y4o-uXgb%9ruA^0oDK^_BSs`iA(1`9}E0`o{Yv`KI^|_093E@vZl5@@@4+z7u>W z`%d$n;XB)Rh3{(LwZ7|oH~4<;yTx~#?+)MHzI%PY_5H)Ir(d*Rw%=sG^?sVue!u!X z^?Tv>%I{A(DL0mz%FX3ga$C8*+(GUn_m%t0gXBs%J_0BoCeM`@%E!q|1#Qp%Km$^fN8sZ>TQQto&5DOu16I zTG^#suiT^DuRN{1p!`aCS$R!)U3pV^Tlq-&HpDcd*8-%?+L#a{z?T@l!{gHDsxp&m6OU<<*w?b z@>cn(!c~!~7*)I~NtL3?Rh6r1RCTIGl}3eB6I7E_^HrLKst(l()n?Up)lStO)j`!^ z)iKox)fLqbsu!wPsyC{?BR~Wd!A3|TdPGPgWD%YbUJ*VKeh~o?K@q_bAravb5fQ^9 zY9kg$?2EV^$wc}@rbiY=Rzy}u)6&aNlH99II zDmyAKsvv4yR7q5MR8>@M)YPcyQ8S}vN6n3zAGI)QNz~G)s)>A;vkzH6|vePt1Ur zK`}#PM#YSY$&AU4DUYd&sf}rmnG!QCW=721n0YY^Viv`$joBV^GUiOo`IxU_F2`Jr z`7Y*0%hI~dlvUH?oHfZ@kBg~H;Z?Q4~kdDhs8(4N5{v-C&s75r^ok=&y3HB z&x_BGFN_}_Um9N?Um0H$Ul%_werx>I_}2-J35f{>36m05C1^G!Y)RObup{AM!pVd) z3Fi_nCR|FmoNy=M$Ao(c_Y;0kc$8?IXqV`e=$a@?^h)$ilqUux#w5lk_D&q0I3_V8 zF*`9ou`qFbVo73i;?%@X6PG1+Cay|co47u4Q{tAyZHYS*PbZ#BypZ@+;@64aBwkCr zo_HhicH$3-?~cN1=aRoq z{x$hQ@}uOZ$uE-sNTE~AQ=C(jDPbuQDbXo$DTyg5Dd{PFQ~IY2N-0Ruj7up=DNCtH zsZOa&X-sKJ5mMSxCZxQ}a@nr>;)jn7S+Vc5ok@G0?vd`F9+IA*N$;ONEPZ79nDor_{Pd#q;`FlgiuA_x=JdAoS?Qmo zFG~M3eOdbI^se*`>7S=>N#B-!IQ@M3mGp1ZuczNk|0(@m`u+6Z(;ub3On=v#%__~R z$ZE`*k~J&qldKh4o3plN?a11lwJ+;n){(3WS(mb|WPO`;J?m!HovgdrT(&%Wc(w** zw`Wh!o|nBedwF(O_U7#E*~hX^WM9a>n0+bxR`w6sKW5*{zMuUd`$hJv>_4;L=FmAz z4wqw=FlaLF$R>x#|w}O7$A`I`u~NX7yI}4)t;MN%a}^IrT;L*Xk?ktLkg{_W9oVgYsMQ zm*wxx|0e%#{>%K=`ET>z6~F?zfGaR6FexxAa4+yI@GkHx2q;h#gcO7qL=;39#1?1< z6{rgu3Z@qbuEo3jV_HVO)O2p zKE}SK{YwXx4lNx~I=VEYw63(Vw7FDM+EzNTbV}*Rr87%smugEtDP35)xO7wL-7=<3 zV^*dt8(vmaHmmHjvK?h-%l;^*%Gq*Bd5>~wxp}!~c|f_cJghvTJgPjsd_ei&@?qs8 z%d^UJ%hlzD<>Sih%O{pEEblB|U%spSVEK{qM^PGw1DRb_2uLuGU2tjal+iz|0m9;m!gdB5`a%14z?tB9%| zRnjVpD(fo0D*vj;s+g+ys-&ucRT)*;Rk>C9RaI3&ReROMswq|Ts}@ups=8YBpqi+* zsMdH^M^^W*9#}oNdRX;{>QU9X)y375s%KQMsor0GqWVeo%No}jpBi~hV2z?CwkEBn zPffp?fi**F#@1xjpoyUUh+Ws=BDU*t&$evMg?_VEOudENNkEoBXkE>6tPpMC@A5))MpIx6@ zudXkw*Nm$#sV}Rqtgo)0Q@^?Xa{bE&`-ZrNyoUCM&W803n;Nz>Y;D-raJ=DU!|8@| z4Hp`|YWTY0cEjC&W> z-I&ps)mYZJtZ{AQ!Ny~a*Bjq9zH5R_bQ9NP)MU~m)ihZ&c{TYq`8NeMDVsu@Vw=*M z`ZV=x8rU?XX;@QkQ+`uXQ*l#c)8wXUP1BoZHO*`4Xj<8{x~Z#aN7L@6y-f$2&NO}7 z^j*`9rdv%9n%*_TX1bYcHflC$Hfy$QwrRF+mNy4BE1H$fq0Or1sOH$__~xYMl;+&# zw&vB%Cz~}7TFhHQTZXn&x3shfE$uB6TV}Q_Xj$B{v}JkAik4L^U$ks*+10YQpQeX{$r4Ypbl)v(>j%-Wu4dXpL_j-I~{0&^oTQxV5Hr zcB{7alh&QBds_Fm9%?<>dZP7I>zUT`t#@00ZvD0OLF*$;>yy?$HKc~o@ET){sm4s> zq;b`_YkFyvnlMd-CR)>5^O2^%W{@URlcUMg6lkh7NYk#Fq?w|buUV^Eui2#8qS>a| zsoA61uQ{YSs`*B9O>gXTxgJ`xo81egg!+bXa!o0)}o#07&?W{p!4W! zbOn8jzC*vFr)`op)3#o1sGpH&7u&D4f7kwf`>pmn?f2XNX#aZxm_SWn qCzwnyn_w})dV=i)*9p-RQYIhLZ@J?0%DR7&HTuu)J4sAF`hNg?32`0( diff --git a/CapCollector.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/CapCollector.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..d502ec3 --- /dev/null +++ b/CapCollector.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/CapCollector/AppDelegate.swift b/CapCollector/AppDelegate.swift index 64f38f4..87e88cc 100644 --- a/CapCollector/AppDelegate.swift +++ b/CapCollector/AppDelegate.swift @@ -17,7 +17,7 @@ import Reachability #warning("GridController: Reorder caps by dragging") #warning("TableView: Fix blur background of search bar after transition") #warning("TableView: Add banner to jump down to unmatched caps / bottom") -#warning("Database: Calculate thumbnails and colors in the background") + var shouldLaunchCamera = false var app: AppDelegate! @@ -34,16 +34,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - var mainStoryboard: UIStoryboard { - UIStoryboard(name: "Main", bundle: nil) - } + var mainStoryboard: UIStoryboard { .init(name: "Main", bundle: nil) } let documentsFolder = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) var database: Database! - var storage: Storage! - var reachability: Reachability! var dbUrl: URL { @@ -65,12 +61,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { app = self - storage = Storage(in: documentsFolder) reachability = try! Reachability() //resetToFactoryState() - database = Database(url: dbUrl, server: serverUrl) + database = Database(url: dbUrl, server: serverUrl, storageFolder: documentsFolder) return true } @@ -97,8 +92,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationDidBecomeActive(_ application: UIApplication) { - app.database?.uploadRemainingData() - guard shouldLaunchCamera else { return } shouldLaunchCamera = false if let c = (frontmostViewController as? UINavigationController)?.topViewController as? TableView { diff --git a/CapCollector/Data/Database.swift b/CapCollector/Data/Database.swift index a49b4c9..062d013 100644 --- a/CapCollector/Data/Database.swift +++ b/CapCollector/Data/Database.swift @@ -19,22 +19,91 @@ protocol DatabaseDelegate: class { func database(didLoadImageForCap cap: Int) + func database(completedBackgroundWorkItem title: String, subtitle: String) + + func database(needsUserConfirmation title: String, body: String, shouldProceed: @escaping (Bool) -> Void) + + func database(didFailBackgroundWork title: String, subtitle: String) + + func databaseHasNewClassifier() + + func databaseDidFinishBackgroundWork() + func databaseNeedsFullRefresh() } +private enum BackgroundWorkTaskType: Int, CustomStringConvertible, Comparable { + + case downloadCapNames = 9 + case downloadCounts = 8 + case downloadClassifier = 7 + case uploadingCaps = 6 + case uploadingImages = 5 + case downloadMainImages = 4 + case creatingThumbnails = 3 + case creatingColors = 2 + + var description: String { + switch self { + case .downloadCapNames: + return "Downloading names" + case .downloadCounts: + return "Downloading counts" + case .downloadClassifier: + return "Downloading classifier" + case .uploadingCaps: + return "Uploading caps" + case .uploadingImages: + return "Uploading images" + case .downloadMainImages: + return "Downloading images" + case .creatingThumbnails: + return "Creating thumbnails" + case .creatingColors: + return "Creating colors" + } + } + + + var maximumNumberOfSimultaneousItems: Int { + switch self { + case .downloadMainImages: + return 50 + case .creatingThumbnails: + return 10 + case .creatingColors: + return 10 + default: + return 1 + } + } + + var nextType: BackgroundWorkTaskType? { + BackgroundWorkTaskType(rawValue: rawValue - 1) + } + + static func < (lhs: BackgroundWorkTaskType, rhs: BackgroundWorkTaskType) -> Bool { + lhs.rawValue < rhs.rawValue + } + +} + + final class Database { // MARK: Variables let db: Connection - let upload: Upload + private let upload: Upload - let download: Download + private let download: Download + + let storage: Storage weak var delegate: DatabaseDelegate? - init?(url: URL, server: URL) { + init?(url: URL, server: URL, storageFolder: URL) { guard let db = try? Connection(url.path) else { return nil } @@ -54,6 +123,7 @@ final class Database { self.db = db self.upload = upload self.download = download + self.storage = Storage(in: storageFolder) log("Database loaded with \(capCount) caps") } @@ -64,6 +134,11 @@ final class Database { (try? db.prepare(Cap.table))?.map(Cap.init) ?? [] } + /// The ids of all caps + var capIds: Set { + Set(caps.map { $0.id }) + } + /// A dictionary of all caps, indexed by their ids var capDict: [Int : Cap] { caps.reduce(into: [:]) { $0[$1.id] = $1 } @@ -90,61 +165,68 @@ final class Database { (try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.columnCount] }) ?? 0 } - /// The caps without a downloaded image - var capsWithoutImages: [Cap] { - caps.filter({ !app.storage.hasImage(for: $0.id) }) - } - - /// The number of caps without a downloaded image - var capCountWithoutImages: Int { - capsWithoutImages.count - } - - /// The caps without a downloaded image - var capsWithoutThumbnails: [Cap] { - caps.filter({ !app.storage.hasThumbnail(for: $0.id) }) - } - - /// The number of caps without a downloaded image - var capCountWithoutThumbnails: Int { - capsWithoutThumbnails.count - } - - var pendingImageUploads: [(cap: Int, version: Int)] { + var nextPendingCapUpload: Cap? { do { - return try db.prepare(upload.table).map { row in - (cap: row[upload.rowCapId], version: row[upload.rowCapVersion]) + guard let row = try db.pluck(Cap.table.filter(Cap.columnUploaded == false).order(Cap.columnId.asc)) else { + return nil } + return Cap(row: row) } catch { - log("Failed to get pending image uploads") - return [] + log("Failed to get next pending cap upload") + return nil } } - /// Indicate if there are any unfinished uploads - var hasPendingImageUploads: Bool { - ((try? db.scalar(upload.table.count)) ?? 0) > 0 - } - - var pendingCapUploads: [Cap] { - do { - return try db.prepare(Cap.table.filter(Cap.columnUploaded == false).order(Cap.columnId.asc)).map(Cap.init) - } catch { - log("Failed to get pending cap uploads") - return [] - } - } - - var hasPendingCapUploads: Bool { + var pendingCapUploadCount: Int { do { let query = Cap.table.filter(Cap.columnUploaded == false).count - return try db.scalar(query) > 0 + return try db.scalar(query) } catch { log("Failed to get pending cap upload count") - return false + return 0 } } + var nextPendingImageUpload: (id: Int, version: Int)? { + do { + guard let row = try db.pluck(upload.table) else { + return nil + } + return (id: row[upload.rowCapId], version: row[upload.rowCapVersion]) + } catch { + log("Failed to get pending image uploads") + return nil + } + } + + var capsWithImages: Set { + capIds.filter { storage.hasImage(for: $0) } + } + + var capsWithThumbnails: Set { + capIds.filter { storage.hasThumbnail(for: $0) } + } + + var pendingImageUploadCount: Int { + ((try? db.scalar(upload.table.count)) ?? 0) + } + + /// The number of caps without a thumbnail on disk + var pendingCapForThumbnailCreation: Int { + caps.reduce(0) { $0 + (storage.hasThumbnail(for: $1.id) ? 0 : 1) } + } + + var pendingCapsForColorCreation: Int { + do { + return try capCount - db.scalar(Colors.table.count) + } catch { + log("Failed to get count of caps without color: \(error)") + return 0 + } + } + + + var classifierVersion: Int { set { UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey) @@ -184,28 +266,12 @@ final class Database { log("Cap not inserted") return false } - guard app.storage.save(image: image, for: cap.id) else { + guard storage.save(image: image, for: cap.id) else { log("Cap image not saved") return false } - guard !isInOfflineMode else { - log("Offline mode: Not uploading cap") - return true - } - upload.upload(name: name, for: cap.id) { success in - guard success else { - return - } - self.update(uploaded: true, for: cap.id) - self.upload.uploadImage(for: cap.id, version: 0) { actualVersion in - guard let actualVersion = actualVersion else { - self.log("Failed to upload first image for cap \(cap.id)") - return - } - self.log("Uploaded first image for cap \(cap.id)") - self.update(count: actualVersion + 1, for: cap.id) - } - } + addPendingUpload(for: cap.id, version: 0) + startBackgroundWork() return true } @@ -236,7 +302,7 @@ final class Database { log("Failed to get count for cap \(cap)") return false } - guard app.storage.save(image: image, for: cap, version: version) else { + guard storage.save(image: image, for: cap, version: version) else { log("Failed to save image \(version) for cap \(cap) to disk") return false } @@ -248,22 +314,7 @@ final class Database { log("Failed to add cap \(cap) version \(version) to upload queue") return false } - guard !isInOfflineMode else { - log("Offline mode: Not uploading cap image") - return true - } - upload.uploadImage(for: cap, version: version) { actualVersion in - guard let actualVersion = actualVersion else { - self.log("Failed to upload image \(version) for cap \(cap)") - return - } - guard self.removePendingUpload(of: cap, version: version) else { - self.log("Failed to remove version \(version) for cap \(cap) from upload queue") - return - } - self.log("Uploaded version \(actualVersion) for cap \(cap)") - self.update(count: actualVersion + 1, for: cap) - } + startBackgroundWork() return true } @@ -310,7 +361,7 @@ final class Database { guard update("name", for: cap, setter: Cap.columnName <- name, Cap.columnUploaded <- false) else { return false } - uploadRemainingData() + startBackgroundWork() return true } @@ -343,8 +394,12 @@ final class Database { // MARK: Uploads + @discardableResult private func addPendingUpload(for cap: Int, version: Int) -> Bool { do { + guard try db.scalar(upload.existsQuery(for: cap, version: version)) == 0 else { + return true + } try db.run(upload.insertQuery(for: cap, version: version)) return true } catch { @@ -353,6 +408,7 @@ final class Database { } } + @discardableResult private func removePendingUpload(for cap: Int, version: Int) -> Bool { do { try db.run(upload.deleteQuery(for: cap, version: version)) @@ -420,32 +476,18 @@ final class Database { @discardableResult func downloadImage(for cap: Int, version: Int = 0, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { - return download.image(for: cap, version: version) { image in - if version == 0 && image != nil { + let url = storage.localImageUrl(for: cap, version: version) + return download.image(for: cap, version: version, to: url) { success in + if version == 0 && success { DispatchQueue.main.async { self.delegate?.database(didLoadImageForCap: cap) } } + let image = self.storage.image(for: cap, version: version) completion(image) } } - func downloadCapNames(completion: @escaping (_ success: Bool) -> Void) { - log("Downloading cap names") - download.names { names in - guard let names = names else { - DispatchQueue.main.async { - completion(false) - } - return - } - self.update(names: names) - DispatchQueue.main.async { - completion(true) - } - } - } - private func update(names: [String]) { let notify = capCount > 0 log("Downloaded cap names (initialDownload: \(!notify))") @@ -475,46 +517,418 @@ final class Database { } } - func downloadMainCapImages(progress: @escaping (_ current: Int, _ total: Int) -> Void) { - let caps = capsWithoutImages.map { $0.id } - - var downloaded = 0 - let total = caps.count - - func update() { - DispatchQueue.main.async { - progress(downloaded, total) - } + var isDoingWorkInBackgound: Bool { + backgroundTaskStatus != nil + } + + private var didUpdateBackgroundItems = false + private var backgroundTaskStatus: BackgroundWorkTaskType? = nil + private var expectedBackgroundWorkStatus: BackgroundWorkTaskType? = nil + + private var nextBackgroundWorkStatus: BackgroundWorkTaskType? { + guard let oldType = backgroundTaskStatus else { + return expectedBackgroundWorkStatus } - update() - - guard total > 0 else { - log("No images to download") + guard let type = expectedBackgroundWorkStatus else { + return backgroundTaskStatus?.nextType + } + guard oldType > type else { + return type + } + return oldType.nextType + } + + private func setNextBackgroundWorkStatus() -> BackgroundWorkTaskType? { + backgroundTaskStatus = nextBackgroundWorkStatus + expectedBackgroundWorkStatus = nil + return backgroundTaskStatus + } + + private let context = CIContext(options: [.workingColorSpace: kCFNull!]) + + + func startInitialDownload() { + startBackgroundWork(startingWith: .downloadCapNames) + } + + func scheduleClassifierDownload() { + startBackgroundWork(startingWith: .downloadClassifier) + } + + func startBackgroundWork() { + startBackgroundWork(startingWith: .uploadingCaps) + } + + private func startBackgroundWork(startingWith type: BackgroundWorkTaskType) { + guard !isDoingWorkInBackgound else { + if expectedBackgroundWorkStatus?.rawValue ?? 0 < type.rawValue { + log("Background work scheduled: \(type)") + expectedBackgroundWorkStatus = type + } return } - log("Starting to download \(total) images") + DispatchQueue.global(qos: .utility).async { + self.performAllBackgroundWorkItems(allItemsStartingAt: type) + } + } + + private func performAllBackgroundWorkItems(allItemsStartingAt type: BackgroundWorkTaskType) { + didUpdateBackgroundItems = false + expectedBackgroundWorkStatus = type + log("Starting background task") + while let type = setNextBackgroundWorkStatus() { + log("Handling background task: \(type)") + guard performAllItems(for: type) else { + // If an error occurs, stop the background tasks + backgroundTaskStatus = nil + expectedBackgroundWorkStatus = nil + break + } + } + log("Background work completed") + delegate?.databaseDidFinishBackgroundWork() + } + + private func performAllItems(for type: BackgroundWorkTaskType) -> Bool { + switch type { + case .downloadCapNames: + return downloadCapNames() + case .downloadCounts: + return downloadImageCounts() + case .downloadClassifier: + return downloadClassifier() + case .uploadingCaps: + return uploadCaps() + case .uploadingImages: + return uploadImages() + case .downloadMainImages: + return downloadMainImages() + case .creatingThumbnails: + return createThumbnails() + case .creatingColors: + return createColors() + } + } + + private func downloadCapNames() -> Bool { + log("Downloading cap names") + let result = DispatchGroup.singleTask { callback in + download.names { names in + guard let names = names else { + callback(false) + return + } + self.update(names: names) + callback(true) + } + } + log("Completed download of cap names") + return result + } + + private func downloadImageCounts() -> Bool { + log("Downloading cap image counts") + let result = DispatchGroup.singleTask { callback in + download.imageCounts { counts in + guard let counts = counts else { + self.log("Failed to download server image counts") + callback(false) + return + } + let newCaps = self.didDownload(imageCounts: counts) + + guard newCaps.count > 0 else { + callback(true) + return + } + self.log("Found \(newCaps.count) new caps on the server.") + self.downloadInfo(for: newCaps) { success in + callback(success) + } + } + } + guard result else { + log("Failed download of cap image counts") + return false + } + log("Completed download of cap image counts") + return true + } + + private func downloadClassifier() -> Bool { + log("Downloading classifier (if needed)") + let result = DispatchGroup.singleTask { callback in + download.classifierVersion { version in + guard let version = version else { + self.log("Failed to download server model version") + callback(false) + return + } + let ownVersion = self.classifierVersion + guard ownVersion < version else { + self.log("Not updating classifier: Own version \(ownVersion), server version \(version)") + callback(true) + return + } + let title = "Download classifier" + let detail = ownVersion == 0 ? + "A classifier to match caps is available for download (version \(version)). Would you like to download it now?" : + "Version \(version) of the classifier is available for download (You have version \(ownVersion)). Would you like to download it now?" + self.delegate!.database(needsUserConfirmation: title, body: detail) { proceed in + guard proceed else { + self.log("User skipped classifier download") + callback(true) + return + } + self.download.classifier { progress, received, total in + let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) + let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) + let title = String(format: "%.0f", progress * 100) + " % (\(r) / \(t))" + self.delegate?.database(completedBackgroundWorkItem: "Downloading classifier", subtitle: title) + } completion: { url in + guard let url = url else { + self.log("Failed to download classifier") + callback(false) + return + } + let compiledUrl: URL + do { + compiledUrl = try MLModel.compileModel(at: url) + } catch { + self.log("Failed to compile downloaded classifier: \(error)") + callback(false) + return + } + + guard self.storage.save(recognitionModelAt: compiledUrl) else { + self.log("Failed to save compiled classifier") + callback(false) + return + } + callback(true) + self.classifierVersion = version + } + } + } + } + log("Downloaded classifier (if new version existed)") + return result + } + + private func uploadCaps() -> Bool { + var completed = 0 + while let cap = nextPendingCapUpload { + guard upload.upload(cap) else { + delegate?.database(didFailBackgroundWork: "Upload failed", + subtitle: "Cap \(cap.id) not uploaded") + return false + } + update(uploaded: true, for: cap.id) + completed += 1 + let total = completed + pendingCapUploadCount + delegate?.database(completedBackgroundWorkItem: "Uploading caps", subtitle: "\(completed + 1) of \(total)") + } + return true + } + + private func uploadImages() -> Bool { + var completed = 0 + while let (id, version) = nextPendingImageUpload { + guard let cap = self.cap(for: id) else { + log("No cap \(id) to upload image \(version)") + removePendingUpload(for: id, version: version) + continue + } + guard let url = storage.existingImageUrl(for: cap.id, version: version) else { + log("No image \(version) of cap \(id) to upload") + removePendingUpload(for: id, version: version) + continue + } + guard let count = upload.upload(imageAt: url, of: cap.id) else { + delegate?.database(didFailBackgroundWork: "Upload failed", subtitle: "Image \(version) of cap \(id)") + return false + } + if count > cap.count { + update(count: count, for: cap.id) + } + removePendingUpload(for: id, version: version) + + completed += 1 + let total = completed + pendingImageUploadCount + delegate?.database(completedBackgroundWorkItem: "Uploading images", subtitle: "\(completed + 1) of \(total)") + } + return true + } + + private func downloadMainImages() -> Bool { + let missing = caps.map { $0.id }.filter { !storage.hasImage(for: $0) } + let count = missing.count + guard count > 0 else { + log("No images to download") + return true + } + log("Starting image downloads") let group = DispatchGroup() - let split = 50 - DispatchQueue.global(qos: .userInitiated).async { - for part in caps.split(intoPartsOf: split) { - for id in part { - let downloading = self.downloadImage(for: id) { _ in + group.enter() + + var shouldDownload = true + let title = "Download images" + let detail = "\(count) caps have no image. Would you like to download them now? (~ \(ByteCountFormatter.string(fromByteCount: Int64(count * 10000), countStyle: .file))). Grid view is not available until all images are downloaded." + delegate?.database(needsUserConfirmation: title, body: detail) { proceed in + shouldDownload = proceed + group.leave() + } + group.wait() + guard shouldDownload else { + log("User skipped image download") + return false + } + + group.enter() + let queue = DispatchQueue(label: "images") + let semaphore = DispatchSemaphore(value: 5) + + var downloadsAreSuccessful = true + var completed = 0 + for cap in missing { + queue.async { + guard downloadsAreSuccessful else { + return + } + semaphore.wait() + let url = self.storage.localImageUrl(for: cap) + self.download.image(for: cap, to: url, queue: queue) { success in + defer { semaphore.signal() } + guard success else { + self.delegate?.database(didFailBackgroundWork: "Download failed", subtitle: "Image of cap \(cap)") + downloadsAreSuccessful = false + group.leave() + return + } + completed += 1 + self.delegate?.database(completedBackgroundWorkItem: "Downloading images", subtitle: "\(completed) of \(missing.count)") + if completed == missing.count { group.leave() } - if downloading { - group.enter() - } } - if group.wait(timeout: .now() + .seconds(30)) != .success { - self.log("Timed out waiting for images to be downloaded") - } - downloaded += part.count - self.log("Finished \(downloaded) of \(total) image downloads") - update() } - self.log("Finished all image downloads") } + guard group.wait(timeout: .now() + TimeInterval(missing.count * 2)) == .success else { + log("Timed out downloading images") + return false + } + log("Finished all image downloads") + return true + } + + private func createThumbnails() -> Bool { + let missing = caps.map { $0.id }.filter { !storage.hasThumbnail(for: $0) } + guard missing.count > 0 else { + log("No thumbnails to create") + return true + } + log("Creating thumbnails") + let queue = DispatchQueue(label: "thumbnails") + let semaphore = DispatchSemaphore(value: 5) + + let group = DispatchGroup() + group.enter() + var thumbnailsAreSuccessful = true + var completed = 0 + for cap in missing { + queue.async { + guard thumbnailsAreSuccessful else { + return + } + semaphore.wait() + defer { semaphore.signal() } + guard let image = self.storage.image(for: cap) else { + self.log("No image for cap \(cap) to create thumbnail") + self.delegate?.database(didFailBackgroundWork: "Creation failed", subtitle: "Thumbnail of cap \(cap)") + thumbnailsAreSuccessful = false + group.leave() + return + } + let thumb = Cap.thumbnail(for: image) + guard self.storage.save(thumbnail: thumb, for: cap) else { + self.log("Failed to save thumbnail for cap \(cap)") + self.delegate?.database(didFailBackgroundWork: "Image not saved", subtitle: "Thumbnail of cap \(cap)") + thumbnailsAreSuccessful = false + group.leave() + return + } + completed += 1 + self.delegate?.database(completedBackgroundWorkItem: "Creating thumbnails", subtitle: "\(completed) of \(missing.count)") + if completed == missing.count { + group.leave() + } + } + } + guard group.wait(timeout: .now() + TimeInterval(missing.count * 2)) == .success else { + log("Timed out creating thumbnails") + return false + } + log("Finished all thumbnails") + return true + } + + private func createColors() -> Bool { + let missing = capIds.subtracting(capsWithColors) + guard missing.count > 0 else { + log("No colors to create") + return true + } + log("Creating colors") + let queue = DispatchQueue(label: "colors") + let semaphore = DispatchSemaphore(value: 5) + + let group = DispatchGroup() + group.enter() + var colorsAreSuccessful = true + var completed = 0 + for cap in missing { + queue.async { + guard colorsAreSuccessful else { + return + } + semaphore.wait() + defer { semaphore.signal() } + guard let image = self.storage.ciImage(for: cap) else { + self.log("No image for cap \(cap) to create color") + self.delegate?.database(didFailBackgroundWork: "No thumbnail found", subtitle: "Color of cap \(cap)") + colorsAreSuccessful = false + group.leave() + return + } + defer { self.context.clearCaches() } + guard let color = image.averageColor(context: self.context) else { + self.log("Failed to create color for cap \(cap)") + self.delegate?.database(didFailBackgroundWork: "Calculation failed", subtitle: "Color of cap \(cap)") + colorsAreSuccessful = false + group.leave() + return + } + guard self.set(color: color, for: cap) else { + self.log("Failed to save color for cap \(cap)") + self.delegate?.database(didFailBackgroundWork: "Color not saved", subtitle: "Color of cap \(cap)") + colorsAreSuccessful = false + group.leave() + return + } + completed += 1 + self.delegate?.database(completedBackgroundWorkItem: "Creating colors", subtitle: "\(completed) of \(missing.count)") + if completed == missing.count { + group.leave() + } + } + } + guard group.wait(timeout: .now() + TimeInterval(missing.count * 2)) == .success else { + log("Timed out creating colors") + return false + } + log("Finished all colors") + return true } func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) { @@ -537,65 +951,6 @@ final class Database { } } - func downloadClassifier(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void) { - download.classifier(progress: progress) { url in - guard let url = url else { - self.log("Failed to download classifier") - completion(false) - return - } - let compiledUrl: URL - do { - compiledUrl = try MLModel.compileModel(at: url) - } catch { - self.log("Failed to compile downloaded classifier: \(error)") - completion(false) - return - } - - guard app.storage.save(recognitionModelAt: compiledUrl) else { - self.log("Failed to save classifier") - completion(false) - return - } - completion(true) - self.download.classifierVersion { version in - guard let version = version else { - self.log("Failed to download classifier version") - return - } - self.classifierVersion = version - } - } - } - - func downloadImageCounts(completion: @escaping (_ success: Bool) -> Void) { - log("Refreshing server image counts") - download.imageCounts { counts in - guard let counts = counts else { - self.log("Failed to download server image counts") - DispatchQueue.main.async { - completion(false) - } - return - } - let newCaps = self.didDownload(imageCounts: counts) - - guard newCaps.count > 0 else { - DispatchQueue.main.async { - completion(true) - } - return - } - self.log("Found \(newCaps.count) new caps on the server.") - self.downloadInfo(for: newCaps) { success in - DispatchQueue.main.async { - completion(success) - } - } - } - } - private func didDownload(imageCounts newCounts: [Int]) -> [Int : Int] { let capsCounts = capDict if newCounts.count != capsCounts.count { @@ -662,119 +1017,6 @@ final class Database { } } - private func uploadNextItem() { - let capUploads = self.pendingCapUploads - if let id = capUploads.first { - - return - } - let imageUploads = pendingImageUploads - guard imageUploads.count > 0 else { - log("No pending image uploads") - return - } - uploadRemainingImages() - } - - private func upload(cap: Int) { - - } - - func uploadRemainingData() { - guard !isInOfflineMode else { - log("Not uploading pending data due to offline mode") - return - } - let uploads = self.pendingCapUploads - guard uploads.count > 0 else { - log("No pending cap uploads") - uploadRemainingImages() - return - } - log("\(uploads.count) cap uploads pending") - - var remaining = uploads.count - DispatchQueue.global(qos: .background).async { - let group = DispatchGroup() - for cap in uploads { - group.enter() - self.upload.upload(name: cap.name, for: cap.id) { success in - group.leave() - if success { - self.log("Uploaded cap \(cap.id)") - self.update(uploaded: true, for: cap.id) - } else { - self.log("Failed to upload cap \(cap.id)") - return - } - - remaining -= 1 - - } - guard group.wait(timeout: .now() + .seconds(60)) == .success else { - self.log("Timed out uploading cap \(cap.id)") - return - } - } - DispatchQueue.main.async { - self.uploadRemainingImages() - } - } - - } - - private func uploadRemainingImages() { - let uploads = pendingImageUploads - guard uploads.count > 0 else { - log("No pending image uploads") - return - } - log("\(uploads.count) image uploads pending") - - DispatchQueue.global(qos: .background).async { - let group = DispatchGroup() - for (id, version) in uploads { - guard let cap = self.cap(for: id) else { - self.log("No cap \(id) to upload image \(version)") - self.removePendingUpload(of: id, version: version) - continue - } - guard cap.uploaded else { - self.log("Cap \(id) not uploaded, skipping image upload") - continue - } - group.enter() - self.upload.uploadImage(for: id, version: version) { count in - group.leave() - guard let _ = count else { - self.log("Failed to upload version \(version) of cap \(id)") - return - } - self.log("Uploaded version \(version) of cap \(id)") - self.removePendingUpload(of: id, version: version) - } - guard group.wait(timeout: .now() + .seconds(60)) == .success else { - self.log("Timed out uploading version \(version) of cap \(id)") - return - } - } - } - - } - - @discardableResult - func removePendingUpload(of cap: Int, version: Int) -> Bool { - do { - let query = upload.table.filter(upload.rowCapId == cap && upload.rowCapVersion == version).delete() - try db.run(query) - log("Deleted pending upload of cap \(cap) version \(version)") - return true - } catch { - log("Failed to delete pending upload of cap \(cap) version \(version)") - return false - } - } - func setMainImage(of cap: Int, to version: Int) { guard version != 0 else { log("No need to switch main image with itself for cap \(cap)") @@ -785,7 +1027,7 @@ final class Database { self.log("Could not make \(version) the main image for cap \(cap)") return } - guard app.storage.switchMainImage(to: version, for: cap) else { + guard self.storage.switchMainImage(to: version, for: cap) else { self.log("Could not switch \(version) to main image for cap \(cap)") return } diff --git a/CapCollector/Data/Download.swift b/CapCollector/Data/Download.swift index 7cd10d5..a961c1f 100644 --- a/CapCollector/Data/Download.swift +++ b/CapCollector/Data/Download.swift @@ -111,6 +111,25 @@ final class Download { // MARK: Downloading data + func image(for cap: Int, to url: URL, timeout: TimeInterval = 30) -> Bool { + let group = DispatchGroup() + group.enter() + var result = true + let success = image(for: cap, version: 0, to: url) { success in + result = success + group.leave() + } + guard success else { + log("Already downloading image for cap \(cap)") + return false + } + guard group.wait(timeout: .now() + timeout) == .success else { + log("Timed out downloading image for cap \(cap)") + return false + } + return result + } + /** Download an image for a cap. - Parameter cap: The id of the cap. @@ -119,7 +138,7 @@ final class Download { - Returns: `true`, of the file download was started, `false`, if the image is already downloading. */ @discardableResult - func image(for cap: Int, version: Int = 0, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { + func image(for cap: Int, version: Int = 0, to url: URL, queue: DispatchQueue = .main, completion: @escaping (Bool) -> Void) -> Bool { // Check if main image, and already being downloaded if version == 0 { guard !downloadingMainImages.contains(cap) else { @@ -127,24 +146,28 @@ final class Download { } downloadingMainImages.insert(cap) } - let url = serverImageUrl(for: cap, version: version) + let serverUrl = serverImageUrl(for: cap, version: version) let query = "Image of cap \(cap) version \(version)" - let task = session.downloadTask(with: url) { fileUrl, response, error in + let task = session.downloadTask(with: serverUrl) { fileUrl, response, error in if version == 0 { - DispatchQueue.main.async { + queue.async { self.downloadingMainImages.remove(cap) } } guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else { - completion(nil) + completion(false) return } - guard let image = app.storage.saveImage(at: fileUrl, for: cap, version: version) else { - self.log("Request '\(query)' could not move downloaded file") - completion(nil) - return + do { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + try FileManager.default.moveItem(at: fileUrl, to: url) + } catch { + self.log("Failed to move downloaded image for cap \(cap): \(error)") + completion(false) } - completion(image) + completion(true) } task.resume() return true diff --git a/CapCollector/Data/Storage.swift b/CapCollector/Data/Storage.swift index a98ed6f..ce7dcef 100644 --- a/CapCollector/Data/Storage.swift +++ b/CapCollector/Data/Storage.swift @@ -11,7 +11,30 @@ import UIKit import CoreML import Vision -final class Storage { + + +protocol ImageProvider: class { + + func image(for cap: Int) -> UIImage? + + func image(for cap: Int, version: Int) -> UIImage? + + func ciImage(for cap: Int) -> CIImage? +} + +protocol ThumbnailCreationDelegate { + + func thumbnailCreation(progress: Int, total: Int) + + func thumbnailCreationFailed() + + func thumbnailCreationIsMissingImages() + + func thumbnailCreationCompleted() +} + + +final class Storage: ImageProvider { // MARK: Paths @@ -35,7 +58,7 @@ final class Storage { baseUrl.appendingPathComponent("model.mlmodel") } - private func localImageUrl(for cap: Int, version: Int) -> URL { + func localImageUrl(for cap: Int, version: Int = 0) -> URL { baseUrl.appendingPathComponent("\(cap)-\(version).jpg") } @@ -114,7 +137,7 @@ final class Storage { - parameter cap: The cap id - returns: True, if the image was saved */ - func save(thumbnailData: Data, for cap: Int) -> Bool { + private func save(thumbnailData: Data, for cap: Int) -> Bool { write(thumbnailData, to: thumbnailUrl(for: cap)) } @@ -227,7 +250,7 @@ final class Storage { - note: Removes invalid image data on disk, if the data is not a valid image - note: Must be called on the main thread */ - func image(for cap: Int, version: Int = 0) -> UIImage? { + func image(for cap: Int, version: Int) -> UIImage? { guard let data = imageData(for: cap, version: version) else { return nil } @@ -239,6 +262,10 @@ final class Storage { return image } + func image(for cap: Int) -> UIImage? { + image(for: cap, version: 0) + } + /** Get the thumbnail data for a cap. If the image exists on disk, it is returned. diff --git a/CapCollector/Data/Upload.swift b/CapCollector/Data/Upload.swift index 390b0d0..170e922 100644 --- a/CapCollector/Data/Upload.swift +++ b/CapCollector/Data/Upload.swift @@ -57,6 +57,10 @@ struct Upload { } } + func existsQuery(for cap: Int, version: Int) -> ScalarQuery { + table.filter(rowCapId == cap && rowCapVersion == version).count + } + func insertQuery(for cap: Int, version: Int) -> Insert { table.insert(rowCapId <- cap, rowCapVersion <- version) } @@ -97,36 +101,55 @@ struct Upload { task.resume() } - func uploadImage(for cap: Int, version: Int, completion: @escaping (_ count: Int?) -> Void) { - guard let url = app.storage.existingImageUrl(for: cap, version: version) else { - completion(nil) - return + func upload(_ cap: Cap, timeout: TimeInterval = 30) -> Bool { + upload(name: cap.name, for: cap.id, timeout: timeout) + } + + func upload(name: String, for cap: Int, timeout: TimeInterval = 30) -> Bool { + let group = DispatchGroup() + group.enter() + var result = true + upload(name: name, for: cap) { success in + if success { + self.log("Uploaded cap \(cap)") + } else { + result = false + } + group.leave() } + guard group.wait(timeout: .now() + timeout) == .success else { + log("Timed out uploading cap \(cap)") + return false + } + return result + } + + func upload(imageAt url: URL, for cap: Int, completion: @escaping (_ count: Int?) -> Void) { var request = URLRequest(url: serverImageUploadUrl(for: cap)) request.httpMethod = "POST" let task = URLSession.shared.uploadTask(with: request, fromFile: url) { data, response, error in if let error = error { - self.log("Failed to upload image \(version) of cap \(cap): \(error)") + self.log("Failed to upload image of cap \(cap): \(error)") completion(nil) return } guard let response = response else { - self.log("Failed to upload image \(version) of cap \(cap): No response") + self.log("Failed to upload image of cap \(cap): No response") completion(nil) return } guard let urlResponse = response as? HTTPURLResponse else { - self.log("Failed to upload image \(version) of cap \(cap): \(response)") + self.log("Failed to upload image of cap \(cap): \(response)") completion(nil) return } guard urlResponse.statusCode == 200 else { - self.log("Failed to upload image \(version) of cap \(cap): Response \(urlResponse.statusCode)") + self.log("Failed to upload image of cap \(cap): Response \(urlResponse.statusCode)") completion(nil) return } guard let d = data, let string = String(data: d, encoding: .utf8), let int = Int(string) else { - self.log("Failed to upload image \(version) of cap \(cap): Invalid response") + self.log("Failed to upload image of cap \(cap): Invalid response") completion(nil) return } @@ -135,6 +158,21 @@ struct Upload { task.resume() } + func upload(imageAt url: URL, of cap: Int, timeout: TimeInterval = 30) -> Int? { + let group = DispatchGroup() + group.enter() + var result: Int? = nil + upload(imageAt: url, for: cap) { count in + result = count + group.leave() + } + guard group.wait(timeout: .now() + timeout) == .success else { + log("Timed out uploading image of \(cap)") + return nil + } + return result + } + /** Sets the main image for a cap to a different version. diff --git a/CapCollector/Extensions/DispatchGroup+Extensions.swift b/CapCollector/Extensions/DispatchGroup+Extensions.swift new file mode 100644 index 0000000..7b5dad0 --- /dev/null +++ b/CapCollector/Extensions/DispatchGroup+Extensions.swift @@ -0,0 +1,28 @@ +// +// DispatchGroup+Extensions.swift +// CapCollector +// +// Created by iMac on 13.01.21. +// Copyright © 2021 CH. All rights reserved. +// + +import Foundation + +extension DispatchGroup { + + typealias AsyncSuccessCallback = (Bool) -> Void + + static func singleTask(timeout: TimeInterval = 30, _ block: (@escaping AsyncSuccessCallback) -> Void) -> Bool { + let group = DispatchGroup() + group.enter() + var result = true + block { success in + result = success + group.leave() + } + guard group.wait(timeout: .now() + timeout) == .success else { + return false + } + return result + } +} diff --git a/CapCollector/Presentation/GridViewController.swift b/CapCollector/Presentation/GridViewController.swift index 973f876..d7bf2f7 100644 --- a/CapCollector/Presentation/GridViewController.swift +++ b/CapCollector/Presentation/GridViewController.swift @@ -60,7 +60,7 @@ class GridViewController: UIViewController { view.backgroundColor = tileColor(tile: tile) } else { let id = tiles[tile] - if let image = app.storage.thumbnail(for: id) { + if let image = app.database.storage.thumbnail(for: id) { view.image = image continue } @@ -216,7 +216,7 @@ class GridViewController: UIViewController { return } - if let image = app.storage.thumbnail(for: tiles[tile]) { + if let image = app.database.storage.thumbnail(for: tiles[tile]) { view.image = image return } @@ -238,7 +238,7 @@ class GridViewController: UIViewController { self.log("No installed tile for downloaded image \(id)") return } - guard let image = app.storage.thumbnail(for: id) else { + guard let image = app.database.storage.thumbnail(for: id) else { self.log("Failed to load image for cap \(id) after successful download") return } diff --git a/CapCollector/Presentation/ImageSelector.swift b/CapCollector/Presentation/ImageSelector.swift index 560c060..198d7bb 100644 --- a/CapCollector/Presentation/ImageSelector.swift +++ b/CapCollector/Presentation/ImageSelector.swift @@ -36,6 +36,8 @@ class ImageSelector: UIViewController { private var images = [UIImage?]() var cap: Cap! + + weak var imageProvider: ImageProvider? @IBOutlet weak var collection: UICollectionView! @@ -97,7 +99,7 @@ class ImageSelector: UIViewController { images = [UIImage?](repeating: nil, count: cap.count) log("\(cap.count) images for cap \(cap.id)") for version in 0.. 0, !app.database.isInOfflineMode else { + guard let touch = event.allTouches?.first, touch.tapCount > 0 else { + return + } + guard !app.database.isInOfflineMode else { showOfflineDialog() return } - downloadCapNames() + app.database.startInitialDownload() } @IBAction func showMosaic(_ sender: UIBarButtonItem) { @@ -136,12 +153,10 @@ class TableView: UITableViewController { let count = app.database.capCount if count == 0 { log("No caps found, downloading names") - downloadCapNames() - showProcessingScreen() + app.database.startInitialDownload() } else { log("Loaded \(count) caps") reloadCapsFromDatabase() - loadClassifier() } } @@ -151,6 +166,7 @@ class TableView: UITableViewController { (navigationController as? NavigationController)?.allowLandscape = false isUnlocked = app.isUnlocked log(isUnlocked ? "App is unlocked" : "App is locked") + app.database.startBackgroundWork() } override func didMove(toParent parent: UIViewController?) { @@ -197,6 +213,13 @@ class TableView: UITableViewController { let longPress = UILongPressGestureRecognizer(target: self, action: #selector(attemptChangeOfUserPermissions)) stackView.addGestureRecognizer(longPress) } + + private func set(title: String, subtitle: String) { + DispatchQueue.main.async { + self.titleLabel?.text = title + self.subtitleLabel?.text = subtitle + } + } private func updateNavigationItemTitleView() { DispatchQueue.main.async { @@ -208,114 +231,14 @@ class TableView: UITableViewController { // MARK: Starting updates private func checkThumbnailsAndColorsBeforShowingGrid() { - let missingImageCount = app.database.capCountWithoutImages - guard missingImageCount == 0 else { - askUserToDownload(capImages: missingImageCount) + let colors = app.database.pendingCapsForColorCreation + let thumbs = app.database.pendingCapForThumbnailCreation + guard colors == 0 && thumbs == 0 else { + app.database.startBackgroundWork() + showAlert("Please wait until all background work is completed. \(colors) colors and \(thumbs) thumbnails need to be created.", title: "Mosaic not ready") return } - createMissingThumbnailsBeforeShowingGrid() - } - - private func createMissingThumbnailsBeforeShowingGrid() { - let missing = app.database.capsWithoutThumbnails.map { $0.id } - guard missing.count > 0 else { - log("No thumbnails missing, checking colors") - checkColorsBeforeShowingGrid() - return - } - log("Generating \(missing.count) thumbnails") - let hud = JGProgressHUD(style: traitCollection.userInterfaceStyle == .dark ? .dark : .light) - hud.indicatorView = JGProgressHUDPieIndicatorView() - hud.detailTextLabel.text = "0 % complete (0 / \(missing.count)" - hud.textLabel.text = "Generating thumbnails" - hud.show(in: self.view) - - let group = DispatchGroup() - var done = 0 - let split = 50 - DispatchQueue.global(qos: .background).async { - for part in missing.split(intoPartsOf: split) { - for id in part { - group.enter() - defer { - done += 1 - let ratio = Float(done) / Float(missing.count) - let percent = Int((ratio * 100).rounded()) - DispatchQueue.main.async { - hud.progress = ratio - hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(missing.count))" - } - group.leave() - } - guard let image = app.storage.image(for: id) else { - return - } - let thumbnail = Cap.thumbnail(for: image) - _ = app.storage.save(thumbnail: thumbnail, for: id) - } - if group.wait(timeout: .now() + .seconds(30)) != .success { - self.log("Timed out waiting for thumbnails to be generated") - } - } - DispatchQueue.main.async { - hud.dismiss() - self.checkColorsBeforeShowingGrid() - } - } - } - - private func checkColorsBeforeShowingGrid() { - let missing = Array(app.database.capsWithoutColors) - - guard missing.count > 0 else { - log("No missing colors, showing grid") - showGrid() - return - } - log("Generating \(missing.count) colors") - let hud = JGProgressHUD(style: traitCollection.userInterfaceStyle == .dark ? .dark : .light) - hud.indicatorView = JGProgressHUDPieIndicatorView() - hud.detailTextLabel.text = "0 % complete (0 / \(missing.count)" - hud.textLabel.text = "Generating colors" - hud.show(in: self.view) - - let group = DispatchGroup() - var done = 0 - let split = 50 - let context = CIContext(options: [.workingColorSpace: kCFNull!]) - - DispatchQueue.global(qos: .background).async { - for part in missing.split(intoPartsOf: split) { - for id in part { - group.enter() - defer { - done += 1 - let ratio = Float(done) / Float(missing.count) - let percent = Int((ratio * 100).rounded()) - DispatchQueue.main.async { - hud.progress = ratio - hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(missing.count))" - } - group.leave() - } - guard let image = app.storage.ciImage(for: id) else { - return - } - guard let color = image.averageColor(context: context) else { - return - } - _ = app.database.set(color: color, for: id) - } - if group.wait(timeout: .now() + .seconds(30)) != .success { - self.log("Timed out waiting for colors to be generated") - } - context.clearCaches() - } - DispatchQueue.main.async { - hud.dismiss() - self.showGrid() - } - } + showGrid() } private func showGrid() { @@ -340,7 +263,7 @@ class TableView: UITableViewController { if offline { print("Marking as online") app.database.isInOfflineMode = false - app.database.uploadRemainingData() + app.database.startBackgroundWork() self.showAlert("Offline mode was disabled", title: "Online") } else { print("Marking as offline") @@ -349,52 +272,6 @@ class TableView: UITableViewController { } } - private func downloadCapNames() { - app.database.downloadCapNames { success in - guard success else { - self.hideProcessingScreen() - self.showAlert("Failed to download cap names", title: "Sync failed") - return - } - self.downloadImageCounts() - } - } - - private func downloadImageCounts() { - app.database.downloadImageCounts { success in - guard success else { - self.hideProcessingScreen() - self.showAlert("Failed to download image counts", title: "Sync failed") - return - } - self.hideProcessingScreen() - self.checkIfCapImagesNeedDownload() - } - } - - private func checkIfCapImagesNeedDownload() { - let count = app.database.capCountWithoutImages - guard count > 0 else { - log("No cap images to download") - self.downloadNewestClassifierIfNeeded() - return - } - DispatchQueue.main.async { - self.askUserToDownload(capImages: count) - } - } - - private func downloadNewestClassifierIfNeeded() { - app.database.hasNewClassifier { version, size in - guard let version = version else { - return - } - DispatchQueue.main.async { - self.askUserToDownload(classifier: version, size: size) - } - } - } - private func rename(cap: Cap, at indexPath: IndexPath) { let detail = "Choose a new name for the cap" askUserForText("Enter new name", detail: detail, existingText: cap.name, yesText: "Save") { text in @@ -441,26 +318,6 @@ class TableView: UITableViewController { // MARK: User interaction - private func showProcessingScreen() { - guard processingScreenHud == nil else { - log("Already showing processing screen") - return - } - - let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .extraLight - let hud = JGProgressHUD(style: style) - hud.indicatorView = JGProgressHUDIndeterminateIndicatorView() - hud.detailTextLabel.text = "Please wait until the app has finished processing." - hud.textLabel.text = "Processing..." - hud.show(in: self.view) - self.processingScreenHud = hud - } - - private func hideProcessingScreen() { - processingScreenHud?.dismiss() - processingScreenHud = nil - } - @objc private func attemptChangeOfUserPermissions() { guard isUnlocked else { attemptAppUnlock() @@ -497,48 +354,11 @@ class TableView: UITableViewController { updateNavigationItemTitleView() } - private func loadClassifier(reload: Bool = false) { - guard classifier == nil || reload else { - return - } - guard let model = app.storage.recognitionModel else { - downloadNewestClassifierIfNeeded() - return - } - classifier = Classifier(model: model) - } - - private func askUserToDownload(capImages: Int) { - let detail = "\(capImages) caps have no image. Would you like to download them now? (\(ByteCountFormatter.string(fromByteCount: Int64(capImages * 10000), countStyle: .file)))" - presentUserBinaryChoice("Download images", detail: detail, yesText: "Download", noText: "Later", dismissed: { - self.downloadNewestClassifierIfNeeded() - }) { - self.downloadAllCapImages() - } - } - - private func askUserToDownload(classifier version: Int, size: Int64?) { - let oldVersion = app.database.classifierVersion - let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : "" - guard oldVersion > 0 else { - askUserToDownloadFirst(classifier: version, sizeText: sizeText) - return - } - askUserToDownloadNew(classifier: version, sizeText: sizeText, oldVersion: oldVersion) - } - - private func askUserToDownloadNew(classifier version: Int, sizeText: String, oldVersion: Int) { - let detail = "Version \(version) of the classifier is available for download (You have version \(oldVersion)). Would you like to download it now?" - presentUserBinaryChoice("New classifier", detail: detail + sizeText, yesText: "Download") { - self.downloadClassifier() - } - } - - private func askUserToDownloadFirst(classifier version: Int, sizeText: String) { - let detail = "A classifier to match caps is available for download (version \(version). Would you like to download it now?" - presentUserBinaryChoice("Download classifier", detail: detail + sizeText, yesText: "Download") { - self.downloadClassifier() + private func loadClassifier() -> Classifier? { + guard let model = app.database.storage.recognitionModel else { + return nil } + return Classifier(model: model) } private func askUserForText(_ title: String, detail: String, existingText: String? = nil, placeholder: String? = "Cap name", yesText: String, noText: String = "Cancel", confirmed: @escaping (_ text: String) -> Void) { @@ -580,55 +400,8 @@ class TableView: UITableViewController { } alert.addAction(confirm) alert.addAction(cancel) - self.present(alert, animated: true) - - } - - // MARK: Starting downloads - - private func downloadClassifier() { - let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light - let hud = JGProgressHUD(style: style) - //hud.vibrancyEnabled = true - hud.indicatorView = JGProgressHUDPieIndicatorView() - hud.detailTextLabel.text = "0 % complete" - hud.textLabel.text = "Downloading image classifier" - hud.show(in: self.view) - - app.database.downloadClassifier(progress: { progress, received, total in - DispatchQueue.main.async { - hud.progress = progress - let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) - let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) - hud.detailTextLabel.text = String(format: "%.0f", progress * 100) + " % (\(r) / \(t))" - } - }) { success in - DispatchQueue.main.async { - hud.dismiss() - self.didDownloadClassifier(successfully: success) - } - } - } - - private func downloadAllCapImages() { - let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light - let hud = JGProgressHUD(style: style) - //hud.vibrancyEnabled = true - hud.indicatorView = JGProgressHUDPieIndicatorView() - hud.detailTextLabel.text = "0 % complete" - hud.textLabel.text = "Downloading cap images" - hud.show(in: self.view) - - app.database.downloadMainCapImages { done, total in - let progress = Float(done) / Float(total) - let percent = Int((progress * 100).rounded()) - hud.detailTextLabel.text = "\(percent) % (\(done) / \(total))" - hud.progress = progress - - if done >= total { - hud.dismiss(afterDelay: 1.0) - self.downloadNewestClassifierIfNeeded() - } + DispatchQueue.main.async { + self.present(alert, animated: true) } } @@ -688,13 +461,12 @@ class TableView: UITableViewController { // MARK: Finishing downloads - private func didDownloadClassifier(successfully success: Bool) { - guard success else { - self.log("Failed to download classifier") + private func didDownloadClassifier() { + guard let model = app.database.storage.recognitionModel else { + classifier = nil return } - loadClassifier(reload: true) - self.log("Classifier was downloaded.") + classifier = Classifier(model: model) guard let image = accessory!.currentImage else { classifyDummyImage() return @@ -853,7 +625,7 @@ extension TableView { cell.set(matchLabel: matchText) cell.set(countLabel: countText) - if let image = app.storage.image(for: cap.id) { + if let image = imageProvider.image(for: cap.id) { cell.set(image: image) } else { cell.set(image: nil) @@ -933,6 +705,7 @@ extension TableView { let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewController(withIdentifier: "ImageSelector") as! ImageSelector controller.cap = cap + controller.imageProvider = self.imageProvider self.navigationController?.pushViewController(controller, animated: true) success(true) } @@ -961,7 +734,7 @@ extension TableView { let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in self.giveFeedback(.medium) self.accessory?.hideImageView() - guard let image = app.storage.image(for: cap.id) else { + guard let image = self.imageProvider.image(for: cap.id, version: 0) else { success(false) return } @@ -983,6 +756,37 @@ extension TableView: Logger { } extension TableView: DatabaseDelegate { + func database(needsUserConfirmation title: String, body: String, shouldProceed: @escaping (Bool) -> Void) { + presentUserBinaryChoice(title, detail: body, yesText: "Download", noText: "Later", dismissed: { + shouldProceed(false) + }) { + shouldProceed(true) + } + } + + func databaseHasNewClassifier() { + didDownloadClassifier() + } + + func database(completedBackgroundWorkItem title: String, subtitle: String) { + set(title: title, subtitle: subtitle) + } + + func database(didFailBackgroundWork title: String, subtitle: String) { + set(title: title, subtitle: subtitle) + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { + self.updateNavigationItemTitleView() + } + } + + func databaseDidFinishBackgroundWork() { +// set(title: "All tasks completed", subtitle: titleText) + self.updateNavigationItemTitleView() +// DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { +// self.updateNavigationItemTitleView() +// } + } + func database(didAddCap cap: Cap) { caps.append(cap) updateNavigationItemTitleView() @@ -1021,14 +825,17 @@ extension TableView: DatabaseDelegate { } func database(didLoadImageForCap cap: Int) { - guard let cell = visibleCell(for: cap) else { - return + DispatchQueue.main.async { + guard let cell = self.visibleCell(for: cap) else { + return + } + guard let image = self.imageProvider.image(for: cap) else { + self.log("No image for cap \(cap), although it should be loaded") + return + } + cell.set(image: image) } - guard let image = app.storage.image(for: cap) else { - log("No image for cap \(cap), although it should be loaded") - return - } - cell.set(image: image) + } func databaseNeedsFullRefresh() { @@ -1076,11 +883,3 @@ extension TableView: CapAccessoryDelegate { } } - -extension TableView { - - - private func switchBetweenNavigationTitleViewsIfNeeded() { - - } -}