From 1db6f3cc6e2826a7464fac139bdad9a55475b9ed Mon Sep 17 00:00:00 2001 From: Zac West <74188+zacwest@users.noreply.github.com> Date: Sun, 20 Jun 2021 00:38:57 -0700 Subject: [PATCH] Local push on iOS (#1656) Refs #1570 and home-assistant/core#50750. Fixes #1382. ## Summary Adds a new app extension to do local push notifications on iOS 14 when connected to internal SSIDs. ## Screenshots Adds a default-on setting to Internal URL: | Light | Dark | | -- | -- | | ![Simulator Screen Shot - iPhone 12 Pro - 2021-06-19 at 23 13 04](https://user-images.githubusercontent.com/74188/122664142-5cd73d80-d154-11eb-8378-600f0b82b3e4.png) | ![Simulator Screen Shot - iPhone 12 Pro - 2021-06-19 at 23 13 06](https://user-images.githubusercontent.com/74188/122664145-62cd1e80-d154-11eb-840d-0a0e86255bcb.png) | ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant#539 ## Any other notes - Updates the "you need always permission" warning in Internal URL editing to be vibrantly red, to really point out its importance. - Sets the code signing for the app and the push target to 'manual' on device, hopefully for our internal team only. Special entitlements really do not play well with open source. Worth noting that it is not possible to test this feature without being on the HA team since it does not work in simulator (as far as I can tell) and running on-device requires entitlements. - Moves commands into Shared in a slightly-easier registration mechanism, so we can share them in the local push extension. --- .github/workflows/ci.yml | 3 +- .../activate_special_entitlements.sh | 20 ++ Configuration/HomeAssistant.release.xcconfig | 3 - Configuration/HomeAssistant.xcconfig | 13 +- ...HomeAssistant.PushProvider.mobileprovision | Bin 0 -> 12745 bytes ....robbie.HomeAssistant.dev.mobileprovision} | Bin 14784 -> 21175 bytes ...ssistant.dev.PushProvider.mobileprovision} | Bin 14289 -> 20217 bytes HomeAssistant.xcodeproj/project.pbxproj | 259 +++++++++++++++++- .../xcshareddata/xcschemes/App-Beta.xcscheme | 2 +- .../xcshareddata/xcschemes/App-Debug.xcscheme | 2 +- .../xcschemes/App-Release.xcscheme | 2 +- .../xcshareddata/xcschemes/Codegen.xcscheme | 2 +- .../xcschemes/Extensions-Intents.xcscheme | 2 +- .../Extensions-NotificationContent.xcscheme | 2 +- .../Extensions-NotificationService.xcscheme | 2 +- .../xcschemes/Extensions-Share.xcscheme | 2 +- .../xcschemes/Extensions-Today.xcscheme | 2 +- .../xcschemes/Extensions-Widgets.xcscheme | 2 +- .../xcshareddata/xcschemes/Launcher.xcscheme | 2 +- .../xcschemes/Shared-iOS.xcscheme | 2 +- .../xcshareddata/xcschemes/Tests-UI.xcscheme | 2 +- .../xcschemes/Tests-Unit.xcscheme | 2 +- .../WatchApp (Complication).xcscheme | 2 +- .../WatchApp (Notification).xcscheme | 2 +- .../xcshareddata/xcschemes/WatchApp.xcscheme | 2 +- Podfile | 1 + Podfile.lock | 2 +- .../Notifications/NotificationManager.swift | 94 +------ ...otificationManagerLocalPushInterface.swift | 13 + ...ationManagerLocalPushInterfaceDirect.swift | 50 ++++ ...onManagerLocalPushInterfaceExtension.swift | 152 ++++++++++ ...ManagerLocalPushInterfaceUnsupported.swift | 12 + .../AuthenticationViewController.swift | 3 +- .../Resources/en.lproj/Localizable.strings | 3 + .../ComplicationListViewController.swift | 4 +- .../ConnectionURLViewController.swift | 24 ++ .../App/Settings/Eureka/InfoLabelRow.swift | 7 + .../NotificationSettingsViewController.swift | 41 +-- .../PushProvider/PushProvider.swift | 120 ++++++++ .../PushProvider/Resources/Info.plist | 36 +++ Sources/Shared/API/ConnectionInfo.swift | 28 +- Sources/Shared/API/HACancellable+App.swift | 19 ++ .../Extensions/XCGLogger+UNNotification.swift | 12 +- Sources/Shared/Environment/Constants.swift | 1 + .../LocalPush/LocalPushManager.swift | 46 +++- .../LocalPush/UserDefaultsValueSync.swift | 71 +++++ .../NotificationsCommandManager.swift | 106 +++++++ .../Shared/Resources/Swiftgen/Strings.swift | 6 + Tests/Shared/LocalPushManager.test.swift | 3 +- .../NotificationAttachmentManager.test.swift | 3 +- .../Shared/Webhook/WebhookManager.test.swift | 6 +- 51 files changed, 1055 insertions(+), 140 deletions(-) create mode 100755 Configuration/Entitlements/activate_special_entitlements.sh create mode 100644 Configuration/Provisioning/AppStore_ea44bb7f-931e-4007-b33f-154d7af53668_io.robbie.HomeAssistant.PushProvider.mobileprovision rename Configuration/Provisioning/{Development_def2c250-2a45-4a49-a0c9-661078b1eca8_io.robbie.HomeAssistant.dev.mobileprovision => Development_6f630310-b478-48fe-af05-cfb797e64f32_io.robbie.HomeAssistant.dev.mobileprovision} (66%) rename Configuration/Provisioning/{Development_ae334e8f-a5d0-4372-b154-55810196e2d8_io.robbie.HomeAssistant.dev.PushProvider.mobileprovision => Development_c4a97b33-7eb5-4b23-ad7f-a85be0086892_io.robbie.HomeAssistant.dev.PushProvider.mobileprovision} (67%) create mode 100644 Sources/App/Notifications/NotificationManagerLocalPushInterface.swift create mode 100644 Sources/App/Notifications/NotificationManagerLocalPushInterfaceDirect.swift create mode 100644 Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift create mode 100644 Sources/App/Notifications/NotificationManagerLocalPushInterfaceUnsupported.swift create mode 100644 Sources/Extensions/PushProvider/PushProvider.swift create mode 100644 Sources/Extensions/PushProvider/Resources/Info.plist create mode 100644 Sources/Shared/API/HACancellable+App.swift create mode 100644 Sources/Shared/Notifications/LocalPush/UserDefaultsValueSync.swift create mode 100644 Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8aa988e..2043bd73c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,7 @@ jobs: - uses: actions/cache@v2 name: "Cache: Gems" id: cache_gems + if: matrix.xcode == '12.5' with: path: vendor/bundle key: >- @@ -104,7 +105,7 @@ jobs: run: bundle exec pod install --repo-update - name: Install Pods Beta - if: steps.cache_pods.outputs.cache-hit != 'true' && matrix.xcode == '13.0' + if: matrix.xcode == '13.0' run: XCODE_BETA=true bundle exec pod install --repo-update - name: Run tests diff --git a/Configuration/Entitlements/activate_special_entitlements.sh b/Configuration/Entitlements/activate_special_entitlements.sh new file mode 100755 index 000000000..b2a92ab43 --- /dev/null +++ b/Configuration/Entitlements/activate_special_entitlements.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +if [[ $CI && $CONFIGURATION != "Release" ]]; then + echo "warning: Critical alerts disabled for CI" +elif [[ ${ENABLE_CRITICAL_ALERTS} -eq 1 ]]; then + entitlements_file="${TARGET_TEMP_DIR}/${FULL_PRODUCT_NAME}.xcent" + /usr/libexec/PlistBuddy -c "add com.apple.developer.usernotifications.critical-alerts bool true" "$entitlements_file" +else + echo "warning: Critical alerts disabled" +fi + +if [[ $CI && $CONFIGURATION != "Release" ]]; then + echo "warning: Push provider disabled for CI" +elif [[ ${ENABLE_PUSH_PROVIDER} -eq 1 ]]; then + entitlements_file="${TARGET_TEMP_DIR}/${FULL_PRODUCT_NAME}.xcent" + /usr/libexec/PlistBuddy -c "add com.apple.developer.networking.networkextension array" "$entitlements_file" + /usr/libexec/PlistBuddy -c "add com.apple.developer.networking.networkextension:0 string 'app-push-provider'" "$entitlements_file" +else + echo "warning: Push provider disabled" +fi diff --git a/Configuration/HomeAssistant.release.xcconfig b/Configuration/HomeAssistant.release.xcconfig index 0e8b509e3..34587cd26 100644 --- a/Configuration/HomeAssistant.release.xcconfig +++ b/Configuration/HomeAssistant.release.xcconfig @@ -1,6 +1,3 @@ -// indirect so external team changes don't require entitlement -ENABLE_CRITICAL_ALERTS_QMQYCKL255 = 1 - #include "HomeAssistant.xcconfig" CODE_SIGN_STYLE = Manual diff --git a/Configuration/HomeAssistant.xcconfig b/Configuration/HomeAssistant.xcconfig index c22304c4a..7994c503d 100644 --- a/Configuration/HomeAssistant.xcconfig +++ b/Configuration/HomeAssistant.xcconfig @@ -3,6 +3,10 @@ VERSIONING_SYSTEM = apple-generic DEVELOPMENT_TEAM = QMQYCKL255 BUNDLE_ID_PREFIX = io.robbie +ENABLE_CRITICAL_ALERTS_QMQYCKL255 = 1 +ENABLE_PUSH_PROVIDER_QMQYCKL255 = 1 +CODE_SIGN_STYLE_QMQYCKL255_App = Manual +CODE_SIGN_STYLE_QMQYCKL255_Extensions_PushProvider = Manual // cascades down PRODUCT_BUNDLE_IDENTIFIER = ${BUNDLE_ID_PREFIX}.HomeAssistant${BUNDLE_ID_SUFFIX}${PROVISIONING_SUFFIX} @@ -15,7 +19,7 @@ DEAD_CODE_STRIPPING[sdk=watchos*] = NO // release builds override this CODE_SIGN_IDENTITY = Apple Development -CODE_SIGN_STYLE = Automatic +CODE_SIGN_STYLE_DEFAULT = Automatic // Create this file to override configuration. It's ignored by git #include? "HomeAssistant.overrides.xcconfig" @@ -23,6 +27,13 @@ CODE_SIGN_STYLE = Automatic // set after overrides in case the team is changed by them - this is customized in e.g. the Release xcconfig // only iOS, not catalyst, because no critical alerts provisioning for us exists on mac ENABLE_CRITICAL_ALERTS[sdk=iphoneos*] = $(ENABLE_CRITICAL_ALERTS_$(DEVELOPMENT_TEAM)) +ENABLE_PUSH_PROVIDER[sdk=iphoneos*] = $(ENABLE_PUSH_PROVIDER_$(DEVELOPMENT_TEAM)) + +// We mutate the entitlements at build time to support other development teams +CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES +// Mutate certain targets' default signing style because we need manual on ios sdk +CODE_SIGN_STYLE = $(CODE_SIGN_STYLE_DEFAULT) +CODE_SIGN_STYLE[sdk=iphoneos*] = $(CODE_SIGN_STYLE_$(DEVELOPMENT_TEAM)_$(TARGET_NAME:c99extidentifier):default=$CODE_SIGN_STYLE_DEFAULT) // Baseline information SDKROOT = iphoneos diff --git a/Configuration/Provisioning/AppStore_ea44bb7f-931e-4007-b33f-154d7af53668_io.robbie.HomeAssistant.PushProvider.mobileprovision b/Configuration/Provisioning/AppStore_ea44bb7f-931e-4007-b33f-154d7af53668_io.robbie.HomeAssistant.PushProvider.mobileprovision new file mode 100644 index 0000000000000000000000000000000000000000..66eb6098bb1f6f0348cd0b624442fde372741af0 GIT binary patch literal 12745 zcmd6O3AhvG)j#(F;st?P6cj}-h#QwnW+ux`5U8_dGD#-6lYOG4PG)i^lT7wyl2{e6 z;znz2wN;^d5!AX>5yiFEh0mq>fot8UpH{6_)VfurYW<%{?h4rU|2+Sv-=F6R$-Fc3 zzUO`4^E>Bv&Ur7g2K}!Cx)!Z&HRv{E4SHK=M+Z6$={jI?&y0zkb0<$g z)+~sPKGrd~W&t~C%>sJa_v%@#yOu1K)qHOG0(1$opgWn9^0Jy!mM;j0oW1r1-&-+t z*?h`RhJ=2)yP&D%N_T&l@Umq0f?kseFBG(-$wY;y?tU*D40S^vCKK&j(7hm4sT7u) zOuDWw5sjyoNcpVsVA<4P$`_KQO2Z4C^}-8FsgqrMj<+Dk=T!!Ao97tO&r(ndIDkn?JOheC) z_NS?mSb^!Yaxzy@2h?P#zmyzM>mwtGrII*yD4yd5(&NRf*0H^rMjkT+BTLCvr<7P3 z6;+0d6&Qk{y@;(Bv4;@LQp~y(#p26Mty@Df^=o2fAYaPvBf!XHRe$i?hi1z$*uW$w z=0qhak90C1YUQM9cpa%^EvXrsK*B!2q+tn?<&kHHr_!|ylBmN2`^ahF_(GhrpbM6h_YUXz$egLS&K z1mi@+sG}-1oisf7@;TN`3UA{B$$9%j0QB84rp>VQcjo&O;U*<^plbq zt(M4f3Xeq^@H;C;eeE`tAmmse4`UZ(S4MZGBp;W?bzhUUaunZy4-##)(c|0FB#{Zx zB1b3;sbquhd^TiP>ls)Rj?|pKa73emh~Dsp!gW7|W9_x!I4|PVC8lo3Sdh$th8T6D zE!om}C6slla*&{em@nmp9e}k`JkD`_6~?Tq7uGcpia6suQsKCC807+z?i^Wjt6zY= zS=yJ9FuKgrT2*M$UJlQ+){aPVWF*4ribAU#jxc1f$OPGhnc`^z=fgN|VF^lyy;W!r zo(DbPDI$;8_WEfoPvaE+ou2<_MGbt9XV8T;nHZN5d9>yaMsWhKx%2=^Mx}VXnqXaS+NAhIFBvuW4G4K$ zjw^|Jk7_DYKExTsQ$)gBiiu`B>x{^`zPgYuHh4x2R0*Gt^ad@7U{NbmFb(rgII{EMtL&fd|k7 zxg}Q+r{Q-PWF=^dBynCJ(H#Nfop^{OOn?nt7g&!j5Ck8FIkLKv5I7~_Gq6|?qSiFn zf?7f)5RuWEEaTIpT%ZuoYH1+`_+{KymT^{-K|&W|ZUnwllG%qQq7iu3nHI2!F2j8R z3o8jsP-G^hv93Tq9>N&^G2XdMd?6mJd&6NHP5 za~NH+Y4S5Jr==J(=lr@<=7T~BcX$HPV4zsGC~VnQiHCwxf3q(xmBea3 zn$r`RxPu^YPr>Ol@h-359b_VQQ^D%vgjBKC$5+UFu^RHLqTs>Bm{QAB#Z;r*BUJlv zb2eTn=|WGW!eoMZuCHM!RefPnNNT=-CuEg`8t)*h8Qm0j`6+9ITi&7%qT$+yNyreB zpy)s^ZP2--+zSk7K-I>Pk(Ouo2l27qTO#u;>-UF9l1hgG$#k7+;yyyrlvG9`;yg#= zU@>q&Ic(<$%E3dpa1oGi;=mI0*EoTtY)zb_a5)MHYZ_jfR_Hp|FYttc0-!dWK>u+9*wa`vb9*F2>w##*hiXcQ{6c*g8i^4Sxtnz(OKEXI^J}glR1+EQ2lCyC1)h#GjfrZ?L16jYX04j+l50}7Qb=T_Y6AL_ zf#r%=#3F;v!n)QHX}}EO1TTVDBv+&7zEl|nD>8!4&;%3m_7+w{l z_A0ci0$|LWP1V3Y8SKJfGY-e$0o0LBdG%vQ8y z)Rz~cTGiN7@Z3$n$;fzMGq~~kV<}Sj_`Q}HM=2h)De%v3=q(RX7cON-qAsr#v#UO@ zGiVL#qylUTqON8dfvAVm<4roj5p5<9QBZI=3JPZ(HNY9g2k{C^OHD2)=x%%{(jw5| zNQ(kH4a_G#Vs}{D2~h~dbjCV{=n&ZGR+K<0x@WlWAh0%Xdfy156)}Z)u&oBuRO(<4 zu~x*?%#3eK6FDwW)|qyMLQ#Z!Se{@4b083@5G~u&LMzch)V2-;g1EF*}RIS(PO3Z}1`h0q0pDC!DXZ zrkX;GQr%m2j*;q26OXrbh2!YVkgho0wS<{hc!XBGLBho~=`2U`ZJpM2d03~%`x~&Q zT#)1tMs(nP2w($`mkQ2!Z6I#X zA(9147uZCkjD~&LfTs};~|M!b|c2Le1T$2D3@q9{+=Vezw_p5g3&t2ZD zzjmY-GxRb5eobn^IE@65=2l<4g?1`@IGIV>{A@nrrWG|%#(L8Ad?wlCDNi=De=LIy zBh_~12BL2UPtm~H!}#7f!7&Li&XXJw=88feQ?kYjq767xq#4Fhtz6Q)VSy99a+NgK zMQ@#qHI;1DS1Kocc244R$zsy44qzo2JgE@k6nijd>Jg*{q+{7Qg)8K~ zv4SUuVhe-|L8cIJ?h6s3(dG-`5N81HLo~*vBO*sz>Yg;7Z`%@%V)RUzd4BNeI$;Dl)k=l z-NoY(rs&US#A29Z(xmQHY(!A?BqD^}hDAt~DJazxFCaM6gV_XEDuAU44Jo3QY$R4G~awo|67;Ol|Ju3W$3{oV+G7*b_)#0~haCyjN z#cPm-&dR`_fYY|J(6HA=HVWcf9kN&UR{Yi4b0c@su% zIFK1W4a<0L;Fp>dW-e){DeX$wgr156A+nH%EqNpkx3n`uZEgUpf^{hr-cKkQ&z5#?Nu1{LksL0twR+Min6!(16bmXYdWai~I+;E-1GnNje)L(E*xjWX+=_6U3bw z4w*6|Yo2Fag;qsiG@I_mA?rS});eNj)^yj1&KXsdjFaQ-L-_|}p0BeCub@n|8756N#!9BGA!|C445R`A zhNknJDtWPnuU;u~Nu|QuIXygn?0yJnbiRoXjRSh4BVfmkY@%Ub+Sh;;5{D!RWCdIC zN~=nwLt7ZvA(!j)=oa9h(!TeD%HDRL4m1fTuF^PC2-c87jX`-U;upf@pv@jjy3$q_ z@2UEcV1h0?J)BFV%|XypSH$cM^i`V8zJgb$>PnB((#-hdO@%A5O}!Sk<7g!~P>b}} zy(5{luCf1yvS~Dw2O-4ELYZ*1R5nzcY*!}}$%@!pN*3~^O1W2*Dr#*c-`2IPQmVG< zZ|z5+7CKZFm4{1lOLEDIo-bvfoH_h6S+68>Ms0SuG}pCE9I8fkjZ_1nxY}EQ8fR}| z-y-K|N)Vnm3X}UAw^~k?a`~Y$APiPsB9+t%)C9F&QG@#6$ZW?zLW- zP+{yS)%5bv@5|&QcwvN zhbzHLV5!H{O~=q^`)EQ0ooRK34#(n_+@TM^3{*m1Biqmvye8;=uuwEt1< ztt7>4@4vNPqv9AT>#^m1x?WIA|Fm%4*NZt?g>z)7#j;o7e1GQxpj6%shX%TXm3%4L z-P=tMBdXl{ExXfF=nzb1^gzJya*Tp+_}IbdR*X(+%o30EwLj5(Vxrnjie+(eH=C2j zzHb0Eq-K+$ymyQVS*GSHNhMiYVMUgihJO#)rf`_0Mx-3t?4)S1BociCy$&;)?6n|B zUvI)}9_U4_7P(Ixu$pZ)``)8Q+DAb~x*8q^9W&zamMt^2&TFg~TyxNE$eM|tAZt2b z8JyVJ(b?HC2bn%{+N5JL96w34PNPf=PdZ>$XE=z?LJmFvE~d_!3VWj^jrlJ@k3$w3 zjnih$Yd4M_O#qwJjaMtFykQd1*~lTra}#GxZ$B5v=PTVnOOBc|9qB`$!ix4;%$Pks zXF7(BxkMWO4`t{@dfGB9Y|Ehbr}DXE_qT|Qtm#-Vc11cSPnfu-V6qHNrlVuR zrlaovka^(tE5CD!bMsyE@A9vmUpRq2?bIV5er&2No%iKe>IYwhzWU24cOKxIWO_Jb zzJ>C>>si%x`?;T;a{JfvAGelXo3-`pIWOJ!_OIW6Xr1`bqCM~JxMt-a)ZOy5A0K$p zU4#A3u3d+}`PQwMWbgg_^2@frvmW<7_fhhVBLdA`?@W9tH}B=|gx799F~%PBF7v{n z7V~Eh3{vjG{fqBdg!HXja_H`--_(9-4@b6Gdt&cWn~po@gU1HkZ|*$*!{yS=C%$^~ zJ@Ku(uGw_ObzQzyv#rU^=S-JQUm;!a@)>UDdz&1GS?+%1gydoO%{pzvS<#ygdHnqM zH*PyO71;J8-(8QL{obA{55IBigVWYNdR6DdjtL!`R&PL7UyjUxg`GFEqvM-NlR%l) zA~TKaS;qBbWFmYa^NfZWlV(pkW#?o__xHKURD%%=v0f??IQZUrCfcR(Cz0SvmcnjAzB^haCIr)=SKVxW6Ba!=U)cRMdsD7@_&ZlLCqBLQv$sFH zb%uxi`=&ebdtP|@jmX9`&c5-Nzxjf9ANSxn#H{0fJTWxzot3BkX!3W_o=oV0!N>6X zLPxzFx_Q=wXWWn8```t)tUmB3A3CT0?)Yo(-?h`RbXDQ-W3Ww`f$v|*{hDEKJDt9R zH(ys#x_sNTyzBGDcbg|u8T$T;Cx6@8i*H|X>kuL4}`-SvB<0Im%ctZ`c`7+Gcy79UjXc1Ys3D)<)!X7cCP#Q z*u9qOe-n9N6i^G&hoT5-wqn+OjDoO#qj1nz!(>IwpfZyt9<+EI*uk^3j+Ghw3^=#JTY}NvUth{{XEq57&Ym~tN*+C z$QRFk#vGaTeQ?j84>_l}{jZI!p6bh~bFM9iuYTlQ{`41)TzyAJ`i*rLf6!d?)y_*- z?kNtQaOR#HpL>1B4F9E{{QmtrFP(~gbL{1|&o2Ab!VBf-w!eLSKRtiXZ>ZXx@7{Wn zdR6wUGhaA({cZ1(SHJc3am-g+to*2`RdHscWnOoBR{-l`EURDfN52|w$%5- z7iIsVWBGIO+Kk+5Ki@q6h85rc{SU6YaN5b&n6_?ByB~cdc*oJFF23Nr58nK#>8d{l zR?Nm9u^*LMcoEwBidx(`_z5xhgoE9c4cGpm&c>#1zwgLXK3abI5a>4{s|Uwmdh^Q6 zNnbUY(#3_Y2N(Xgh``P;Uhy6gUPKXS~!Z~MlVk9+3*Yd?DbfrBqS{3hnE zMKjdPp8NBkcVymO@LM(so8G&pbzA#3JU{K? z?dZIxdj53kn%|!B)IScq99c8zUcmpRA(!8|*BbrDT)qvlU{(x3Zngos%~k|MAP3dD z{Ex`+uYCKP7hm6X^u@0n#q{r<{Qk;{v4fox@gq z(cks#S@^Fn+w=7A2R1Cs9Q@R#d*3`_(ZfeydS&yV&CG?TJU6?~{KWQ|+>S%kryoDO z^tbQNc>b&(tpE7fiT8vr;=VuqBiVY)p`Js9d0Q{}{Id;L?(Dzk!C1d?(Sf&jfBwWx zi@WoMZQtua*4;_&ocXKepY*UN%zf-1JMOr9#!D-Y{KFH=?^t^Cr!!|v_}R2A^Jg!q zJil!ED~Z`RZYuta35FlM#e{GD;>$M*|NZApUi!?Vy_x=B;OB3zB&L1mZ2VmF1*^9p zvG?MWuyv`e7tMP$S^h>@nY@pEv38aDkmN@FlE-`7FkjQr1cO-H%ex=&&%FD7o%g@= z?rS;>YB{fa(wdF~VZ=%BnJ~mfC3$iOvif6W(E$hc900U*N@vIHqYTSX-shO|u=7V& zKRohqC$f6m+|^qHNlg`VQnDLJX(gqWyQR@1wB3nDx0q{m7eqMS+zo+eHJdDV8;L+!t zea;b=FK7BbzJC45KUw_TmY;t2#rr=kum9i~dFB%hzvacNSL~^OlX+~)duKS#zA)JR ztbO~I-(BIKn)#;ZE8)7yT|b<2a^&EnD$m||;V~yMo1V`v{L}8I?=1Y!O#1cXfB#Xo z{{Gu{%$f7+U1u(^{{GnKUz+)c%yU;SJm%ofH{NtndG%?J9C1pQ>1VfnLAgFW&3@&f ze_MW>(tUwB{rU7YJ73+s$+Rb!K1F^&GV7l&ow7Kx`{kMQXS{q*`EdUoFWJs|@Z9iA z*Ij-$Mtu3=^TjLA+!(m?h~NI4SoqyVkItO8joYwNdd#zGPX7%TJpb~WUp>_|rSH~i zbw0YLa~)8Z^WdaBvih9=MT*inN}EQ{>kqEpg&Z~#LUm0n8=vGbX(d*c1M2%h#3{*PXHDsSWr3^_sb7Y#prc`02`PAASn^9sB4Vsm0IOb@s_SP7*))gSh>fLI01NCziJ9C+xoTy<2=2zH#G*Z7(g}e9Me$ z+;acBuiy7L@!p?uCoR1F!+AR~J7anL(GgFUyHFt?;udnRY_T z@~~`1adkzwgY@<7J^&_DM~jCI`d$sMT{dHCkytH)0H~8jQ`ZIGXZy*ia~a zgCVqV9>SHtJqBsAivXvi6{m&MS$Xk%9%c?8DV zf(l9PRU4pAvTQ_*{s69{WOsytoeHVLnVA#$l${d>HU)RufU%GwQ4o>TT)`Mk)NG_M za@l6gvK?uCJGTbfJ~TIV`vlc4@xW*!~J& zj!hFIUMsO$xngXjL`kHia#<1Oip^*dcmy^?N+jn?ioxd!XIB9EblYge3TJMzwqaMC ztEYus^TkQP?KZ9Ea<1laagtoUdcW6TfTt8g%`_v1jlXKn-Xkk;b6GP`u9-aK891!L zvWOmmnId#I3FV2_-04&(zXT7c5OTSgg`t8WgA*YYGGAQn!mP-MtxggbbBoY(x-w~; zjq`=usV)4p4){otR@`5D9Si#TOHUoZrh3CF;?YdY~ZLuU*>Sir(uc(%OD6q5wBlAr1A~wqhlC(~RAk5C2mE#f z>3g;b#QxOn0!f_IvNmL=|LX@=<}d7y5;m+E47xigH;RxINT*z|qh>?;Zgny3GvVCd<(m!MXIIWz5T|e0 zz%T%|i|1f0^)enG=BUQ8bmTGAqA=iz=I);N*J!O_L#e&46s!ui8hPQh9hOpO>2FfH zA4nu)vy3-lO@Nbf7B5Hz6^r8QyAQ}|?W#NDD~?rO-}q32tD#`C*U)sBHBFP&I;PUv z7@SNi2C8zL^#c~MVAqX>SK9IXDf{KER9U;g=><&#dCdw^^7kI8aGdr=*4q@q;p&1Q zBtV_aea2eA38?}DNRM}a5+-XG9A9Qoi?Im`M^33#Q8 z&W2{X3r&cg&8uUR0Gfj&U}?(kD%0XrK)I#eCb$^#0f4HbjO~xMw1q1T-T?izTbu=uoGxBy!&bz%2-+LQeZpE6$)rALLrA|0M}Q|nU{S<$ zim^OCI~L&~(Po*b?l#16xN*86UiX+fCG!fFSt^9Iv1&vp#-UHu1nSd`MEK>!7;CPU=xp4RU z&2a%z5c^zlQi4Vz%kEEJWN5^Mh=?XjRFVo5>K?#Z7F+@F_TlUA+N^jeo%Sn? z2z$B$pDgTkdBx_av43V8D6t?4%3aRjOQ6%BN-16-!;=w=!8-?faVJJ0AcaxpM^}wP zNS+!E%~DZ6SP+g02o1r4B(X26c4+6+A12Zrg2@@ApWZ#x9TK7d?Z}yq1!sBU)P|H_ zW}>m|Su@&HQmwgmv{8C8ZOGEmpl*f7rFLUdT_sX$eY9*>RXj($p-C&ZF=4_X8z-B= zqMPEhetaTKqVUAe!V@*)vKVCJQg~I;2`_S}6Jq^nrd0bKt}Uk%y_p8p zgJo~AWM;E?l4B8v;S@vFj8z|StX2!TuM`O|voTgpi6BvIrv0sO)4iKL(^^oq)hXbg zAyibQoh_}#1KbycU5CvH8i8gX1(KGv2eqYLYRs+*se)YVdu`G!aHK_c$KP?92i5p) zEw#)Gp=$QxUbQWctOg$rv>9^{twx);m#bVejZeexZ1h3iVF&w?-(~=heID8YCVDln z5#vKtsw}}Knb=3-wBn52YVV|EJLPFq9pRW_q+3Q=z;Jrj<}0ODozK)1{U%SPdqf!YP^J`x&Qj3^48Aeo|8Q_mG?MZA?B0YH1K!^J zwp(AD@lkKrRxl6+w6rD=tcro%Vhkh>lqwLs1`oxW2?nP8kLEJ8+j zbTnH@Dzd~$dlb79lZX+UwZbaa2TjkywJKw=192QK`l?MK@qnq?@0I!*6XArK(38=u zVtRof3U!8N1}l_E@;Y-zWf>dAm725d%Qs%+w5oPADi8W*tg{U<_o~O@Ks!}EwZ#6|Ut_2wu4!lJxEQ0LCo=%a0%F~YWzm8fdIg`8qCDDEWD&&k0H*;+ zlbz}tk`V3-dLa!cO|mc$YoHKzVtjX}j$}okvqQU6iCav44YH>w>=Icj?ibWW30~{+ zSu$?>ImeWy34ItzUe!LB*D0JGZxuS-A zZP*u8CL+Aa$|{#frP@(pfOj8*UxIh$xZmu%eEH1Z66y z0~3v8X-sf<+DF|KjnoFp`PM7$&L{{aK9gPnUeTnD1)|n~v}NcZ6pb7iWNApZfwY_9 zDL{AgD56mrNQ~DuaGQMdYPf>7iqH~=n=5w=Mp;!|$)OrnEMM1IhScK>b(L&oA6oR% z=M>Z`HNrfo;F(@IbZAvO7}u*ag{ZS5+&e%^sx~ew z)xgCgTaudf;ZfKI@bOJ^nUq^&dp>gC+nG$})6!}b9yzp~_9etAD|^B?jR87nbJY7D zIo!3)-*g`SF5h(bo6l|EfAP-k&)of{?OpeNZ+qv34{Ses;koUvUU&e;?|}F7+uQFk zw=Z5q+YjD@cVmBZ_uFop57OSHS#LNS%m>Tf_C5E$=jnUyxX*L_IGm0a!{AcTOW?tD zecG%)|Hyp0=p91a{`{7{_?z25xu>&b?i0_n{kOk(-(zo~80r#5QI$%K=V`u%w(o!7 zo!jg04<4Xv-bv}mbv@5L2`bddQQ0q58LC=hPJ&vQIr4#08LC2W#}C|bIlTWf*WFV+ zsg|e`OKlG`;|HeaK_|m_8^qIRq^U{asUvGZ&^YwRs zx%JR@pSu6Tm%YcIdgfivA^qjW^~-nu8OeX*t_yen)FXdB{l+__3ok$Q(ieYixxVms zF9tuy|LcF;@P!+{GXL4j-~K?umF|2&|MdBTuTP%-&gNscyma}_&wg+j{(9r9_kR9T z`+L89$6wucf%^AP{?=zc@!<6je7N#&|MmVC?(cr^BOC8;|KeNUu6*;KpZQp~_4Jo+ z(SN#1-usWAd;3Q&e)*ri@=*1QANu28dMo$U|2`Rh?SC%5z(4sbdG{xOXZx++fAYzv p+rRe$GZRk delta 529 zcmdnKl<`2Zok5f39yU&`HjlRNyo~Hj3z{s~0)%G zlrb)7(sE#F(z4lTSHQ?)sB3H(Vqj=xY-VM+c^9Lv{N_&bplg(9VybJBXkw|GXpn5FYi4F>U~Z9Qn3|kuv02yoB(J`qv7v#X zv6-Polz}qb)toGbVk{z)_UK%>YdlYV!BGP~?c2Mya7~H1I9WAtQvHqPHkWwL?JQG? zlsmrf+xm4P%Az7~S#=p*7G5@=wfap#n8ArjDXWF2e|)JkEl(rz?=$uPDn9u;E?&Ov zH|JiZdHB1ge@B%#`R>LF{eSjy=d~Hjb}s0%Hdrq1J;TNNO0u<{cNU9J(K*$KW@o#{ z4a!z4jo+?t`6#^b@=0!qq`CbE>(?Ltt3H?S-u7#r$Hl+t9oH0g=GoU*^Gh^wL+mtl zlhej`=U%z`^_|Co;Msf)(Gu4-Ec^Rx0^hdZ)0UlUnQ0qX{%lqMHn}}kOFppvo^2Z> zptoEpz)NbQiT#DYYi~>l+j>|0^0XDcMavn!-?REIrL(nY-cdsne#SKsk3}9N-gS&m IX6obw0CjcLN&o-= diff --git a/Configuration/Provisioning/Development_ae334e8f-a5d0-4372-b154-55810196e2d8_io.robbie.HomeAssistant.dev.PushProvider.mobileprovision b/Configuration/Provisioning/Development_c4a97b33-7eb5-4b23-ad7f-a85be0086892_io.robbie.HomeAssistant.dev.PushProvider.mobileprovision similarity index 67% rename from Configuration/Provisioning/Development_ae334e8f-a5d0-4372-b154-55810196e2d8_io.robbie.HomeAssistant.dev.PushProvider.mobileprovision rename to Configuration/Provisioning/Development_c4a97b33-7eb5-4b23-ad7f-a85be0086892_io.robbie.HomeAssistant.dev.PushProvider.mobileprovision index cf1408b308615c0694b06171c4f9990802a9f5fa..556621ad88920850b92a2bddd70dc43111036441 100644 GIT binary patch delta 6162 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 delta 503 zcmex4m+@l0ok5fNSvF3sHjlRNyo~Hj3!2RL0flxkF)|u*8*s9*YV$EONwG2*G-)It zlrb)7(g4Hk%@tUag>2F+-;mJhGHxt604cdSHyh#ard^w zT(_%SA8y~@uyS&`@1%N(J+IPEX&lWy7W}e-XHlR-*)m!_`mnTa z!R^RM!&C)z<3$QVA6}~I@?QUaXhyocK)Q?D{M$m3ZyaSk#53M){5H?xVY8iOtIIvj z=D?5-LNB&w{(bdOl4->|t9Co@ht3Baa*xQI^jetXHFe9+s|BAfrK#-LUmxwlHCM#5 zu4@(l%_nyyf|>rg|2y%k(eCj1g@4}rEtry1op+;J{o|}2nH@hQ&TwmI#~f-}Dmg#> z<nnc$nR@3dA(6YQqMHwjx^4Js+`r /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 53D3FBBE96A99C3DA7040194 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-iOS-Extensions-PushProvider-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 59C2CF28F00F34975C1AC68B /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -4589,8 +4761,9 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 9C08D123229F187E001B4F73 /* Enable Critical Alerts */ = { + 9C08D123229F187E001B4F73 /* Enable Special Entitlements */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -4598,14 +4771,14 @@ ); inputPaths = ( ); - name = "Enable Critical Alerts"; + name = "Enable Special Entitlements"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = "/bin/sh -e"; - shellScript = "if [[ $CI && $CONFIGURATION != \"Release\" ]]; then\n echo \"warning: Critical alerts disabled for CI\"\nelif [[ ${ENABLE_CRITICAL_ALERTS} -eq 1 ]]; then\n entitlements_file=\"${TARGET_TEMP_DIR}/${FULL_PRODUCT_NAME}.xcent\"\n /usr/libexec/PlistBuddy -c \"add com.apple.developer.usernotifications.critical-alerts bool true\" \"$entitlements_file\"\nelse\n echo \"warning: Critical alerts disabled\"\nfi\n"; + shellScript = "exec Configuration/Entitlements/activate_special_entitlements.sh\n"; showEnvVarsInLog = 0; }; B8028AFF323FD2C0483872EB /* [CP] Check Pods Manifest.lock */ = { @@ -4749,6 +4922,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 11B92905266F145000786588 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 11B9297E266F15B500786588 /* PushProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 11DE9D7F25B6103C0081C0ED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -4790,6 +4971,7 @@ B68EDD05215F12C900DD6B28 /* NotificationActionConfigurator.swift in Sources */, B616B299227ED68E00828165 /* Bonjour.swift in Sources */, 11A48D7F24CA7E820021BDD9 /* Action+Observation.swift in Sources */, + 11195F6B267EFB1F003DF674 /* NotificationManagerLocalPushInterface.swift in Sources */, B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */, 1112AE9B25F71775007A541A /* LocationHistoryListViewController.swift in Sources */, B68EDD03215F0E2900DD6B28 /* NotificationCategoryConfigurator.swift in Sources */, @@ -4801,6 +4983,7 @@ 1127381C2622B6F300F5E312 /* DebugSettingsViewController.swift in Sources */, 11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */, 1127383C2625512600F5E312 /* ButtonRowWithLoading.swift in Sources */, + 11195F71267EFE2C003DF674 /* NotificationManagerLocalPushInterfaceUnsupported.swift in Sources */, 1130F57E253A2ED500F371BE /* ComplicationFamilySelectViewController.swift in Sources */, 1112AEBB25F717E9007A541A /* LocationHistoryDetailViewController.swift in Sources */, 11BD7B4D25B53D7F001826F0 /* AppMacBridgeStatusItemConfiguration.swift in Sources */, @@ -4816,11 +4999,13 @@ B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, + 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, 11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */, 1161C01B24D7634300A0E3C4 /* NFCListViewController.swift in Sources */, 11A71C6B24A463FC00D9565F /* ZoneManagerState.swift in Sources */, 1130F532253A1E7400F371BE /* ComplicationListViewController.swift in Sources */, B6B2E6A5216ACE4400D39A26 /* ActionConfigurator.swift in Sources */, + 11B92AF2266F23DA00786588 /* NotificationManagerLocalPushInterfaceExtension.swift in Sources */, 11B62DC024F2F06100E5CB55 /* UIApplication+OpenSettings.swift in Sources */, 115DA29324F464DC00C00BB1 /* MenuManager.swift in Sources */, 11A71C8924A5844300D9565F /* ZoneManagerCollector.swift in Sources */, @@ -4995,6 +5180,7 @@ B6872E642226841400C475D1 /* MobileAppRegistrationRequest.swift in Sources */, B613936A24F728F8002B8C5D /* InputDeviceSensor.swift in Sources */, 11A3BD2E26192210005237E6 /* LocalPushManager.swift in Sources */, + 116C0C30267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */, B67CE8B422200F220034C1D0 /* URL+Extensions.swift in Sources */, 11C4629724B19FC800031902 /* URLSessionTask+WebhookPersisted.swift in Sources */, 1109F82024A1C011002590F2 /* SensorProvider.swift in Sources */, @@ -5008,12 +5194,14 @@ 119DE934263325C20099F7D8 /* IconDrawable+Settings.swift in Sources */, 11C65CC1249838EB00D07FC7 /* StreamCameraResponse.swift in Sources */, 111858DB24CB7F9900B8CDDC /* SiriIntents+ConvenienceInits.swift in Sources */, + 11195F73267F01E4003DF674 /* HACancellable+App.swift in Sources */, B6B74CBE228399AC00D58A68 /* Action.swift in Sources */, 11358AF024FCA8BE0074C4E2 /* ActiveStateManager.swift in Sources */, 110ED56425A563D600489AF7 /* DisplaySensor.swift in Sources */, B67CE8B922200F220034C1D0 /* Environment.swift in Sources */, 11B7DBFD266BE7550090BD3B /* LocalPushEvent.swift in Sources */, B6723342225DB82E0031D629 /* KeyedDecodingContainer+JSON.swift in Sources */, + 11ADF941267D34B20040A7E3 /* NotificationsCommandManager.swift in Sources */, B67CE8B122200F220034C1D0 /* CLError+DebugDescription.swift in Sources */, 113E73112518457C004006D8 /* LocalizedManager.swift in Sources */, 111D295724F30E2500C8A7D1 /* Updater.swift in Sources */, @@ -5159,6 +5347,7 @@ 11AF4D22249C924B006C74C0 /* GeocoderSensor.swift in Sources */, 11A48D7B24CA7D7F0021BDD9 /* NotificationAction.swift in Sources */, D0EEF320214DE3B300D1D360 /* Strings.swift in Sources */, + 116C0C2F267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */, 11A3BD2D26192210005237E6 /* LocalPushManager.swift in Sources */, 11C4628824B109C100031902 /* WebhookResponseLocation.swift in Sources */, 11AF4D19249C8253006C74C0 /* PedometerSensor.swift in Sources */, @@ -5172,12 +5361,14 @@ 119DE933263325C20099F7D8 /* IconDrawable+Settings.swift in Sources */, D03D893520E0AEF100D4F28D /* Realm+Initialization.swift in Sources */, D0EEF2C9214D89A700D1D360 /* HAAPI+RequestHelpers.swift in Sources */, + 11195F72267F01E4003DF674 /* HACancellable+App.swift in Sources */, 113E73102518457C004006D8 /* LocalizedManager.swift in Sources */, 111D295624F30E2400C8A7D1 /* Updater.swift in Sources */, B672334D225DE1490031D629 /* SubscribeEvents.swift in Sources */, 1104FC9125322C1800B8BE34 /* Dictionary+Additions.swift in Sources */, 11B7DBFC266BE7550090BD3B /* LocalPushEvent.swift in Sources */, 113D29DE24946EDA0014067C /* CLLocationManager+OneShotLocation.swift in Sources */, + 11ADF940267D34B10040A7E3 /* NotificationsCommandManager.swift in Sources */, 11C4627F24B04CB800031902 /* Promise+RetryNetworking.swift in Sources */, D03D893920E0AF8E00D4F28D /* ClientEvent.swift in Sources */, D03D893620E0AEFA00D4F28D /* Environment.swift in Sources */, @@ -5335,6 +5526,17 @@ target = 1167402125198F9A00F51626 /* MacBridge */; targetProxy = 11A31C92252128B900D50A78 /* PBXContainerItemProxy */; }; + 11B92912266F145000786588 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 11B92908266F145000786588 /* Extensions-PushProvider */; + targetProxy = 11B92911266F145000786588 /* PBXContainerItemProxy */; + }; + 11B92A5E266F17AA00786588 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D03D891620E0A85200D4F28D /* Shared-iOS */; + targetProxy = 11B92A5D266F17AA00786588 /* PBXContainerItemProxy */; + }; 11DE9F9825B6173D0081C0ED /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = maccatalyst; @@ -5765,6 +5967,40 @@ }; name = Beta; }; + 11B92914266F145000786588 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6CB9BB87D256D071215B4FF4 /* Pods-iOS-Extensions-PushProvider.debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/Extension-ios.entitlements"; + INFOPLIST_FILE = Sources/Extensions/PushProvider/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Local Developer: Push Provider"; + PROVISIONING_SUFFIX = .PushProvider; + }; + name = Debug; + }; + 11B92915266F145000786588 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 574F428FD5AD613411644AE4 /* Pods-iOS-Extensions-PushProvider.release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/Extension-ios.entitlements"; + INFOPLIST_FILE = Sources/Extensions/PushProvider/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + PROVISIONING_SUFFIX = .PushProvider; + }; + name = Release; + }; + 11B92916266F145000786588 /* Beta */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F0954F3919DBD03AC16B0391 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/Extension-ios.entitlements"; + INFOPLIST_FILE = Sources/Extensions/PushProvider/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + PROVISIONING_SUFFIX = .PushProvider; + }; + name = Beta; + }; 11DE9D9025B6103D0081C0ED /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -6092,6 +6328,7 @@ ); PRODUCT_MODULE_NAME = HomeAssistant; PRODUCT_NAME = "Home Assistant Δ"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Local Developer: Main App"; SKIP_INSTALL = NO; SUPPORTS_MACCATALYST = YES; }; @@ -6600,6 +6837,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 11B92917266F145000786588 /* Build configuration list for PBXNativeTarget "Extensions-PushProvider" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 11B92914266F145000786588 /* Debug */, + 11B92915266F145000786588 /* Release */, + 11B92916266F145000786588 /* Beta */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 11DE9D9325B6103D0081C0ED /* Build configuration list for PBXNativeTarget "Launcher" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Beta.xcscheme b/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Beta.xcscheme index f5d019cf7..5e8fc1b75 100644 --- a/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Beta.xcscheme +++ b/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Beta.xcscheme @@ -1,6 +1,6 @@ Promise { @@ -87,73 +84,12 @@ class NotificationManager: NSObject, LocalPushManagerDelegate { } private func handleRemoteNotification(userInfo: [AnyHashable: Any]) -> Guarantee { - let (promise, seal) = Guarantee.pending() - Current.Log.verbose("remote notification: \(userInfo)") - if let userInfoDict = userInfo as? [String: Any], - let hadict = userInfoDict["homeassistant"] as? [String: Any], let command = hadict["command"] as? String { - switch command { - case "request_location_update": - guard Current.settingsStore.locationSources.pushNotifications else { - Current.Log.info("ignoring request, location source of notifications is disabled") - seal(.noData) - return promise - } - - Current.Log.verbose("Received remote request to provide a location update") - - Current.backgroundTask(withName: "push-location-request") { remaining in - Current.api.then(on: nil) { api in - api.GetAndSendLocation(trigger: .PushNotification, maximumBackgroundTime: remaining) - } - }.map { - UIBackgroundFetchResult.newData - }.recover { _ in - Guarantee.value(.failed) - }.done(seal) - case "clear_badge": - Current.Log.verbose("Setting badge to 0 as requested") - UIApplication.shared.applicationIconBadgeNumber = 0 - seal(.newData) - case "clear_notification": - Current.Log.verbose("clearing notification for \(userInfo)") - let keys = ["tag", "collapseId"].compactMap { hadict[$0] as? String } - if !keys.isEmpty { - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: keys) - } - seal(.newData) - case "update_complications": - firstly { - updateComplications() - }.map { - Current.Log.info("successfully updated complications from notification") - return UIBackgroundFetchResult.newData - }.recover { error in - Current.Log.error("failed to update complications from notification: \(error)") - return Guarantee.value(.failed) - }.done(seal) - default: - Current.Log.warning("Received unknown command via APNS! \(userInfo)") - seal(.noData) - } - } else { - seal(.noData) - } - - return promise - } - - func updateComplications() -> Promise { - Promise { seal in - Communicator.shared.transfer(.init(content: [:])) { result in - switch result { - case .success: seal.fulfill(()) - case let .failure(error): seal.reject(error) - } - } - }.get { - NotificationCenter.default.post(name: Self.didUpdateComplicationsNotification, object: nil) + return commandManager.handle(userInfo).map { + UIBackgroundFetchResult.newData + }.recover { _ in + Guarantee.value(.failed) } } diff --git a/Sources/App/Notifications/NotificationManagerLocalPushInterface.swift b/Sources/App/Notifications/NotificationManagerLocalPushInterface.swift new file mode 100644 index 000000000..9efd96736 --- /dev/null +++ b/Sources/App/Notifications/NotificationManagerLocalPushInterface.swift @@ -0,0 +1,13 @@ +import HAKit +import Shared + +enum NotificationManagerLocalPushStatus { + case allowed(LocalPushManager.State) + case disabled + case unsupported +} + +protocol NotificationManagerLocalPushInterface { + var status: NotificationManagerLocalPushStatus { get } + func addObserver(_ handler: @escaping (NotificationManagerLocalPushStatus) -> Void) -> HACancellable +} diff --git a/Sources/App/Notifications/NotificationManagerLocalPushInterfaceDirect.swift b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceDirect.swift new file mode 100644 index 000000000..94c323d45 --- /dev/null +++ b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceDirect.swift @@ -0,0 +1,50 @@ +import Foundation +import HAKit +import Shared + +class NotificationManagerLocalPushInterfaceDirect: NotificationManagerLocalPushInterface { + var status: NotificationManagerLocalPushStatus { + .allowed(localPushManager.state) + } + + let localPushManager: LocalPushManager + + init(delegate: LocalPushManagerDelegate) { + self.localPushManager = with(LocalPushManager()) { + $0.delegate = delegate + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(pushManagerStateDidChange), + name: LocalPushManager.stateDidChange, + object: localPushManager + ) + } + + func addObserver(_ handler: @escaping (NotificationManagerLocalPushStatus) -> Void) -> HACancellable { + let observer = Observer(identifier: UUID(), handler: handler) + observers.append(observer) + return HABlockCancellable { [weak self] in + self?.observers.removeAll(where: { $0.identifier == observer.identifier }) + } + } + + private struct Observer: Equatable { + let identifier: UUID + let handler: (NotificationManagerLocalPushStatus) -> Void + + static func == (lhs: Observer, rhs: Observer) -> Bool { + lhs.identifier == rhs.identifier + } + } + + private var observers = [Observer]() + + @objc private func pushManagerStateDidChange() { + let status = status + for observer in observers { + observer.handler(status) + } + } +} diff --git a/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift new file mode 100644 index 000000000..28455921f --- /dev/null +++ b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift @@ -0,0 +1,152 @@ +import Foundation +import HAKit +import NetworkExtension +import PromiseKit +import Shared + +@available(iOS 14, *) +final class NotificationManagerLocalPushInterfaceExtension: NSObject, NotificationManagerLocalPushInterface, + NEAppPushDelegate { + var status: NotificationManagerLocalPushStatus { + if let manager = manager, manager.isActive, let value = stateSync.value { + return .allowed(value) + } else { + return .disabled + } + } + + func addObserver(_ handler: @escaping (NotificationManagerLocalPushStatus) -> Void) -> HACancellable { + let identifier = UUID() + observers.append((identifier: identifier, handler: handler)) + return HABlockCancellable { [weak self] in + self?.observers.removeAll(where: { $0.identifier == identifier }) + } + } + + private var observers = [(identifier: UUID, handler: (NotificationManagerLocalPushStatus) -> Void)]() + private func notifyObservers() { + let status = status + for observer in observers { + observer.handler(status) + } + } + + static let settingsKey = "LocalPush:Main" + private let stateSync = LocalPushStateSync(settingsKey: NotificationManagerLocalPushInterfaceExtension.settingsKey) + + private var tokens: [NSKeyValueObservation] = [] + private var manager: NEAppPushManager? { + didSet { + if manager !== oldValue { + tokens.forEach { $0.invalidate() } + if let manager = manager { + tokens = setupObservation(manager: manager) + } else { + tokens = [] + } + notifyObservers() + } + } + } + + override init() { + super.init() + + _ = stateSync.observe { [weak self] (_: LocalPushManager.State) in + self?.notifyObservers() + } + + // future multi-server: move this to container + NEAppPushManager.loadAllFromPreferences { [self] managers, error in + if let manager = managers?.first { + configureManager(manager: manager) + } else { + if let error = error { + Current.Log.error("failed to load local push details: \(error)") + } + updateManager().cauterize() + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(connectionInfoDidChange(_:)), + name: SettingsStore.connectionInfoDidChange, + object: nil + ) + } + + @objc private func connectionInfoDidChange(_ note: Notification) { + updateManager().cauterize() + } + + private func configureManager(manager: NEAppPushManager) { + self.manager = manager + manager.delegate = self + } + + private func setupObservation(manager: NEAppPushManager) -> [NSKeyValueObservation] { + [ + manager.observe(\.isActive) { [weak self] manager, _ in + Current.Log.info("manager is active: \(manager.isActive)") + self?.notifyObservers() + }, + ] + } + + private func updateManager() -> Promise { + guard + let connectionInfo = Current.settingsStore.connectionInfo, + connectionInfo.internalSSIDs?.isEmpty == false, + connectionInfo.internalURL != nil, + connectionInfo.isLocalPushEnabled else { + return Promise { seal in + guard let manager = self.manager else { + Current.Log.info("no local push - no internal info or not enabled") + seal.fulfill(()) + return + } + + Current.Log.info("removing manager \(manager) due to no internal info or not enabled") + manager.removeFromPreferences { error in + Current.Log.info("remove from preferences: \(String(describing: error))") + seal.resolve(error) + } + self.manager = nil + } + } + + let manager: NEAppPushManager + + if let currentManager = self.manager { + manager = currentManager + } else { + manager = NEAppPushManager() + configureManager(manager: manager) + } + + // just toggling isEnabled doesn't seem to kill off the extension reliably + manager.isEnabled = true + + manager.localizedDescription = "HomeAssistant" + manager.providerBundleIdentifier = Constants.BundleID + ".PushProvider" + manager.matchSSIDs = Current.settingsStore.connectionInfo?.internalSSIDs ?? [] + manager.providerConfiguration = [ + LocalPushStateSync.settingsKey: Self.settingsKey, + ] + + return Promise { seal in + manager.saveToPreferences { error in + Current.Log.info("manager \(manager) updated, error: \(String(describing: error))") + seal.resolve(error) + } + } + } + + func appPushManager( + _ manager: NEAppPushManager, + didReceiveIncomingCallWithUserInfo userInfo: [AnyHashable: Any] = [:] + ) { + // we do not have calls + } +} diff --git a/Sources/App/Notifications/NotificationManagerLocalPushInterfaceUnsupported.swift b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceUnsupported.swift new file mode 100644 index 000000000..8aaebefa0 --- /dev/null +++ b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceUnsupported.swift @@ -0,0 +1,12 @@ +import HAKit +import Shared + +class NotificationManagerLocalPushInterfaceDisallowed: NotificationManagerLocalPushInterface { + var status: NotificationManagerLocalPushStatus { + .unsupported + } + + func addObserver(_ handler: @escaping (NotificationManagerLocalPushStatus) -> Void) -> HACancellable { + HANoopCancellable() + } +} diff --git a/Sources/App/Onboarding/AuthenticationViewController.swift b/Sources/App/Onboarding/AuthenticationViewController.swift index 58cfcd3b2..85e3e7e5a 100644 --- a/Sources/App/Onboarding/AuthenticationViewController.swift +++ b/Sources/App/Onboarding/AuthenticationViewController.swift @@ -50,7 +50,8 @@ class AuthenticationViewController: UIViewController { let connInfo = ConnectionInfo( externalURL: baseURL, internalURL: nil, cloudhookURL: nil, remoteUIURL: nil, webhookID: "", webhookSecret: nil, internalSSIDs: Current.connectivity.currentWiFiSSID().map { [$0] }, - internalHardwareAddresses: Current.connectivity.currentNetworkHardwareAddress().map { [$0] } + internalHardwareAddresses: Current.connectivity.currentNetworkHardwareAddress().map { [$0] }, + isLocalPushEnabled: true ) self.connectionInfo = connInfo diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 480f5e8b0..18ffb6d75 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -320,6 +320,8 @@ Your actions and local configuration will still be available after logging in."; "settings_details.location.zones.radius.title" = "Radius"; "settings_details.notifications.local_push.title" = "Local Push"; "settings_details.notifications.local_push.status.establishing" = "Establishing"; +"settings_details.notifications.local_push.status.disabled" = "Disabled"; +"settings_details.notifications.local_push.status.unsupported" = "Unsupported"; "settings_details.notifications.local_push.status.unavailable" = "Unavailable"; /* parameter is a count, the number of received notifications */ "settings_details.notifications.local_push.status.available" = "Available (%1$@)"; @@ -422,6 +424,7 @@ Your actions and local configuration will still be available after logging in."; "settings.connection_section.servers" = "Servers"; "settings.connection_section.ssid_permission_and_accuracy_message" = "Accessing SSIDs in the background requires 'Always' location permission and 'Full' location accuracy. Tap here to change your settings."; "settings.connection_section.ssid_permission_message" = "Accessing SSIDs in the background requires 'Always' location permission. Tap here to change your settings."; +"settings.connection_section.local_push_description" = "Directly connect to the Home Assistant server for push notifications when on internal SSIDs."; "settings.connection_section.validate_error.edit_url" = "Edit URL"; "settings.connection_section.validate_error.title" = "Error Saving URL"; "settings.connection_section.validate_error.use_anyway" = "Use Anyway"; diff --git a/Sources/App/Settings/AppleWatch/ComplicationListViewController.swift b/Sources/App/Settings/AppleWatch/ComplicationListViewController.swift index b1a845d6c..36b2650ff 100644 --- a/Sources/App/Settings/AppleWatch/ComplicationListViewController.swift +++ b/Sources/App/Settings/AppleWatch/ComplicationListViewController.swift @@ -78,7 +78,7 @@ class ComplicationListViewController: HAFormViewController { } let updateToken = NotificationCenter.default.addObserver( - forName: NotificationManager.didUpdateComplicationsNotification, + forName: NotificationCommandManager.didUpdateComplicationsNotification, object: nil, queue: .main ) { _ in @@ -97,7 +97,7 @@ class ComplicationListViewController: HAFormViewController { row.value = true row.updateCell() - Current.notificationManager.updateComplications().ensure { + Current.notificationManager.commandManager.updateComplications().ensure { row.value = false row.updateCell() }.catch { error in diff --git a/Sources/App/Settings/Connection/ConnectionURLViewController.swift b/Sources/App/Settings/Connection/ConnectionURLViewController.swift index 0fe8e2aea..3a9173914 100644 --- a/Sources/App/Settings/Connection/ConnectionURLViewController.swift +++ b/Sources/App/Settings/Connection/ConnectionURLViewController.swift @@ -63,6 +63,7 @@ final class ConnectionURLViewController: HAFormViewController, TypedRowControlle @objc private func save() { let givenURL = (form.rowBy(tag: RowTag.url.rawValue) as? URLRow)?.value let useCloud = (form.rowBy(tag: RowTag.useCloud.rawValue) as? SwitchRow)?.value + let localPush = (form.rowBy(tag: RowTag.localPush.rawValue) as? SwitchRow)?.value func commit() { Current.settingsStore.connectionInfo?.setAddress(givenURL, urlType) @@ -71,6 +72,10 @@ final class ConnectionURLViewController: HAFormViewController, TypedRowControlle Current.settingsStore.connectionInfo?.useCloud = useCloud } + if let localPush = localPush { + Current.settingsStore.connectionInfo?.isLocalPushEnabled = localPush + } + if let section = form.sectionBy(tag: RowTag.ssids.rawValue) as? MultivaluedSection { Current.settingsStore.connectionInfo?.internalSSIDs = section.allRows .compactMap { $0 as? TextRow } @@ -144,6 +149,7 @@ final class ConnectionURLViewController: HAFormViewController, TypedRowControlle case ssids case hardwareAddresses case useCloud + case localPush } private func updateNavigationItems(isChecking: Bool) { @@ -254,6 +260,22 @@ final class ConnectionURLViewController: HAFormViewController, TypedRowControlle valueRules: rules ) } + + if urlType.hasLocalPush { + form +++ Section( + footer: L10n.Settings.ConnectionSection.localPushDescription + ) <<< SwitchRow(RowTag.localPush.rawValue) { + $0.title = L10n.SettingsDetails.Notifications.LocalPush.title + $0.value = Current.settingsStore.connectionInfo?.isLocalPushEnabled + } <<< LearnMoreButtonRow { + $0.onCellSelection { cell, _ in + openURLInBrowser( + URL(string: "https://companion.home-assistant.io/app/ios/local-push")!, + cell.formViewController() + ) + } + } + } } private func locationPermissionSection() -> Section { @@ -301,6 +323,8 @@ final class ConnectionURLViewController: HAFormViewController, TypedRowControlle $0.title = L10n.Settings.ConnectionSection.ssidPermissionMessage } + $0.displayType = .important + $0.cellUpdate { cell, _ in cell.accessibilityTraits.insert(.button) cell.selectionStyle = .default diff --git a/Sources/App/Settings/Eureka/InfoLabelRow.swift b/Sources/App/Settings/Eureka/InfoLabelRow.swift index 9f677a283..b8c640152 100644 --- a/Sources/App/Settings/Eureka/InfoLabelRow.swift +++ b/Sources/App/Settings/Eureka/InfoLabelRow.swift @@ -5,9 +5,16 @@ final class InfoLabelRow: _LabelRow, RowType { enum DisplayType { case primary case secondary + case important var textColor: UIColor { switch self { + case .important: + if #available(iOS 13, *) { + return .systemRed + } else { + return .red + } case .primary: if #available(iOS 13, *) { return .label diff --git a/Sources/App/Settings/Notifications/NotificationSettingsViewController.swift b/Sources/App/Settings/Notifications/NotificationSettingsViewController.swift index da769d12a..04c4828df 100644 --- a/Sources/App/Settings/Notifications/NotificationSettingsViewController.swift +++ b/Sources/App/Settings/Notifications/NotificationSettingsViewController.swift @@ -138,36 +138,37 @@ class NotificationSettingsViewController: HAFormViewController { <<< LabelRow { row in row.title = L10n.SettingsDetails.Notifications.LocalPush.title - row.hidden = .isNotCatalyst + let manager = Current.notificationManager.localPushManager let updateValue = { [weak row] in guard let row = row else { return } - switch Current.notificationManager.localPushManager?.state { - case .none, .unavailable: - row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.unavailable - case .establishing: - row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.establishing - case let .available(received: received): - let formatted = NumberFormatter.localizedString( - from: NSNumber(value: received), - number: .decimal - ) - row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.available(formatted) + switch manager.status { + case .disabled: + row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.disabled + case .unsupported: + row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.unsupported + case let .allowed(state): + switch state { + case .unavailable: + row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.unavailable + case .establishing: + row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.establishing + case let .available(received: received): + let formatted = NumberFormatter.localizedString( + from: NSNumber(value: received), + number: .decimal + ) + row.value = L10n.SettingsDetails.Notifications.LocalPush.Status.available(formatted) + } } row.updateCell() } - let token = NotificationCenter.default.addObserver( - forName: LocalPushManager.stateDidChange, - object: Current.notificationManager.localPushManager, - queue: .main - ) { _ in + let cancel = manager.addObserver { _ in updateValue() } - after(life: self).done { - NotificationCenter.default.removeObserver(token) - } + after(life: self).done(cancel.cancel) updateValue() } diff --git a/Sources/Extensions/PushProvider/PushProvider.swift b/Sources/Extensions/PushProvider/PushProvider.swift new file mode 100644 index 000000000..30feb7f41 --- /dev/null +++ b/Sources/Extensions/PushProvider/PushProvider.swift @@ -0,0 +1,120 @@ +import Foundation +import HAKit +import NetworkExtension +import PromiseKit +import Shared +import UserNotifications + +@objc class PushProvider: NEAppPushProvider, LocalPushManagerDelegate { + private var localPushManager: LocalPushManager? + private let commandManager = NotificationCommandManager() + + private var stateObserver: NSObjectProtocol? { + willSet { + if let stateObserver = stateObserver, stateObserver !== newValue { + NotificationCenter.default.removeObserver(stateObserver) + } + } + } + + override init() { + super.init() + Current.Log.notify("initialized", log: .info) + } + + deinit { + if let stateObserver = stateObserver { + NotificationCenter.default.removeObserver(stateObserver) + } + } + + override func start(completionHandler: @escaping (Error?) -> Void) { + Current.Log.notify("starting", log: .info) + + guard let settingsKey = providerConfiguration?[LocalPushStateSync.settingsKey] as? String else { + Current.Log.notify("aborting due to missing settings key", log: .error) + stop(with: .configurationFailed, completionHandler: { + Current.Log.notify("finished failing due to no settings key", log: .info) + }) + return + } + + let localPushManager = with(LocalPushManager()) { + $0.delegate = self + } + self.localPushManager = localPushManager + + let valueSync = LocalPushStateSync(settingsKey: settingsKey) + valueSync.value = localPushManager.state + stateObserver = NotificationCenter.default.addObserver( + forName: LocalPushManager.stateDidChange, + object: localPushManager, + queue: nil + ) { [localPushManager] _ in + valueSync.value = localPushManager.state + } + + Current.apiConnection.connect() + + // state of the connection dictates our callback to the completion handler + // this wraps it in a way that guarantees we only ever call it once (via the promise's guarantee of that) + firstly { () -> Promise in + let (promise, seal) = Promise.pending() + + func checkState() { + switch Current.apiConnection.state { + case .ready(version: _): + seal.fulfill(()) + case let .disconnected(reason: .waitingToReconnect( + lastError: .some(error), + atLatest: _, + retryCount: _ + )): + seal.reject(error) + case .authenticating, + .connecting, + .disconnected(reason: .disconnected), + .disconnected(reason: .waitingToReconnect(lastError: .none, atLatest: _, retryCount: _)): + break + } + } + + let token = NotificationCenter.default.addObserver( + forName: HAConnectionState.didTransitionToStateNotification, + object: Current.apiConnection, + queue: nil + ) { _ in + checkState() + } + + checkState() + + return promise + .ensure { NotificationCenter.default.removeObserver(token) } + }.done { + Current.Log.notify("reporting we connected", log: .info) + completionHandler(nil) + }.catch { error in + Current.Log.notify("reporting we errored", log: .info) + completionHandler(error) + } + } + + override func stop(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + Current.Log.notify("stopping with reason \(reason)", log: .error) + localPushManager = nil + } + + override func handleTimerEvent() { + // we may be signaled that it's a good time to connect, so do so + Current.apiConnection.connect() + } + + func localPushManager(_ manager: LocalPushManager, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) { + commandManager.handle(userInfo).done { + Current.Log.notify("handled command: \(userInfo)", log: .info) + }.catch { error in + Current.Log.notify("failed: \(error)", log: .info) + } + } +} diff --git a/Sources/Extensions/PushProvider/Resources/Info.plist b/Sources/Extensions/PushProvider/Resources/Info.plist new file mode 100644 index 000000000..e9bf80dd0 --- /dev/null +++ b/Sources/Extensions/PushProvider/Resources/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + PushProvider + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.app-push + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PushProvider + + + diff --git a/Sources/Shared/API/ConnectionInfo.swift b/Sources/Shared/API/ConnectionInfo.swift index 1bafc2d10..c127e409c 100644 --- a/Sources/Shared/API/ConnectionInfo.swift +++ b/Sources/Shared/API/ConnectionInfo.swift @@ -107,6 +107,14 @@ public class ConnectionInfo: Codable { } } + public var isLocalPushEnabled = true { + didSet { + guard oldValue != isLocalPushEnabled else { return } + Current.Log.verbose("updated local push from \(oldValue) to \(isLocalPushEnabled)") + Current.settingsStore.connectionInfo = self + } + } + public init( externalURL: URL?, internalURL: URL?, @@ -115,7 +123,8 @@ public class ConnectionInfo: Codable { webhookID: String, webhookSecret: String?, internalSSIDs: [String]?, - internalHardwareAddresses: [String]? + internalHardwareAddresses: [String]?, + isLocalPushEnabled: Bool ) { self.externalURL = externalURL self.internalURL = internalURL @@ -125,6 +134,7 @@ public class ConnectionInfo: Codable { self.webhookSecret = webhookSecret self.internalSSIDs = internalSSIDs self.internalHardwareAddresses = internalHardwareAddresses + self.isLocalPushEnabled = isLocalPushEnabled if self.internalURL != nil, self.internalSSIDs != nil, isOnInternalNetwork { self.activeURLType = .internal @@ -151,6 +161,7 @@ public class ConnectionInfo: Codable { try container.decodeIfPresent([String].self, forKey: .internalHardwareAddresses) self.activeURLType = try container.decode(URLType.self, forKey: .activeURLType) self.useCloud = try container.decodeIfPresent(Bool.self, forKey: .useCloud) ?? false + self.isLocalPushEnabled = try container.decodeIfPresent(Bool.self, forKey: .isLocalPushEnabled) ?? true } public enum URLType: Int, Codable, CaseIterable, CustomStringConvertible, CustomDebugStringConvertible { @@ -200,6 +211,21 @@ public class ConnectionInfo: Codable { case .remoteUI, .external: return false } } + + public var hasLocalPush: Bool { + switch self { + case .internal: + if Current.isCatalyst { + return false + } + if #available(iOS 14, *) { + return true + } else { + return false + } + default: return false + } + } } private func sanitize(_ url: URL) -> URL { diff --git a/Sources/Shared/API/HACancellable+App.swift b/Sources/Shared/API/HACancellable+App.swift new file mode 100644 index 000000000..87bef1d2c --- /dev/null +++ b/Sources/Shared/API/HACancellable+App.swift @@ -0,0 +1,19 @@ +import HAKit + +public class HABlockCancellable: HACancellable { + private var handler: (() -> Void)? + + public init(handler: @escaping () -> Void) { + self.handler = handler + } + + public func cancel() { + handler?() + handler = nil + } +} + +public class HANoopCancellable: HACancellable { + public init() {} + public func cancel() {} +} diff --git a/Sources/Shared/Common/Extensions/XCGLogger+UNNotification.swift b/Sources/Shared/Common/Extensions/XCGLogger+UNNotification.swift index d1dcff8cb..9097e3b16 100644 --- a/Sources/Shared/Common/Extensions/XCGLogger+UNNotification.swift +++ b/Sources/Shared/Common/Extensions/XCGLogger+UNNotification.swift @@ -6,15 +6,20 @@ public extension XCGLogger { static var shouldNotifyUserDefaultsKey: String { "xcglogger_unnotifications" } func notify( - _ closure: @autoclosure () -> String, + _ closure: @autoclosure @escaping () -> String, functionName: StaticString = #function, - fileName: StaticString = #file, - lineNumber: Int = #line + fileName: StaticString = #fileID, + lineNumber: Int = #line, + log logLevel: XCGLogger.Level? = nil ) { guard !Current.isRunningTests else { return } + if let level = logLevel { + logln(closure, level: level, functionName: functionName, fileName: fileName, lineNumber: lineNumber) + } + guard Current.settingsStore.prefs.bool(forKey: Self.shouldNotifyUserDefaultsKey) else { return } @@ -23,6 +28,7 @@ public extension XCGLogger { identifier: UUID().uuidString, content: with(UNMutableNotificationContent()) { $0.title = String(describing: functionName) + $0.subtitle = String(describing: fileName) $0.body = closure() $0.userInfo[Self.notifyUserInfoKey] = true }, diff --git a/Sources/Shared/Environment/Constants.swift b/Sources/Shared/Environment/Constants.swift index cbde7d1b9..a0d8e1e3a 100644 --- a/Sources/Shared/Environment/Constants.swift +++ b/Sources/Shared/Environment/Constants.swift @@ -47,6 +47,7 @@ public enum Constants { removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".watchkitapp", with: "") removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".Widgets", with: "") removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".ShareExtension", with: "") + removeBundleSuffix = removeBundleSuffix.replacingOccurrences(of: ".PushProvider", with: "") return removeBundleSuffix } diff --git a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift index 77e9ae472..90f013c0a 100644 --- a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift +++ b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift @@ -14,7 +14,7 @@ public class LocalPushManager { public static var stateDidChange: Notification.Name = .init(rawValue: "LocalPushManagerStateDidChange") - public enum State: Equatable { + public enum State: Equatable, Codable { case establishing case unavailable case available(received: Int) @@ -27,6 +27,50 @@ public class LocalPushManager { self = .available(received: originalCount + count) } } + + private enum PrimitiveState: String, Codable { + case establishing + case unavailable + case available + } + + private var primitiveState: PrimitiveState { + switch self { + case .establishing: return .establishing + case .unavailable: return .unavailable + case .available: return .available + } + } + + private var primitiveCount: Int? { + switch self { + case let .available(received: count): return count + case .unavailable, .establishing: return nil + } + } + + private enum CodingKeys: CodingKey { + case primitive + case count + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(primitiveState, forKey: .primitive) + try container.encode(primitiveCount, forKey: .count) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let primitiveState = try container.decode(PrimitiveState.self, forKey: .primitive) + let primitiveCount = try container.decode(Int?.self, forKey: .count) + + switch primitiveState { + case .establishing: self = .establishing + case .unavailable: self = .unavailable + case .available: self = .available(received: primitiveCount ?? 0) + } + } } public var state: State = .establishing { diff --git a/Sources/Shared/Notifications/LocalPush/UserDefaultsValueSync.swift b/Sources/Shared/Notifications/LocalPush/UserDefaultsValueSync.swift new file mode 100644 index 000000000..c790f71f2 --- /dev/null +++ b/Sources/Shared/Notifications/LocalPush/UserDefaultsValueSync.swift @@ -0,0 +1,71 @@ +import Foundation +import HAKit + +public class LocalPushStateSync: UserDefaultsValueSync { + public static let settingsKey = "LocalPushSettingsKey" +} + +public class UserDefaultsValueSync: NSObject { + public let settingsKey: String + public init(settingsKey: String) { + self.settingsKey = settingsKey + super.init() + Current.settingsStore.prefs.addObserver( + self, + forKeyPath: settingsKey, + options: [.initial], + context: nil + ) + } + + public var value: ValueType? { + set { + do { + if let state = newValue { + let json = try JSONEncoder().encode(state) + Current.settingsStore.prefs.set(json, forKey: settingsKey) + } else { + Current.settingsStore.prefs.removeObject(forKey: settingsKey) + } + } catch { + Current.Log.error("failed to encode: \(error)") + } + } + get { + guard let data = Current.settingsStore.prefs.data(forKey: settingsKey) else { + return nil + } + + do { + let value = try JSONDecoder().decode(ValueType.self, from: data) + return value + } catch { + Current.Log.error("failed to decode: \(error)") + return nil + } + } + } + + private var observers = [(identifier: UUID, handler: (ValueType) -> Void)]() + public func observe(_ handler: @escaping (ValueType) -> Void) -> HACancellable { + let identifier = UUID() + observers.append((identifier: identifier, handler: handler)) + return HABlockCancellable { [weak self] in + self?.observers.removeAll(where: { $0.identifier == identifier }) + } + } + + // swiftlint:disable:next block_based_kvo + override public func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + guard let value = value else { return } + + for observer in observers { + observer.handler(value) + } + } +} diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift new file mode 100644 index 000000000..e129b5ab5 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -0,0 +1,106 @@ +import Communicator +import PromiseKit +import UserNotifications + +public protocol NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise +} + +public class NotificationCommandManager { + public static var didUpdateComplicationsNotification: Notification.Name { + .init(rawValue: "didUpdateComplicationsNotification") + } + + public enum CommandError: Error { + case notCommand + case unknownCommand + } + + public init() { + register(command: "request_location_update", handler: HandlerLocationUpdate()) + register(command: "clear_notification", handler: HandlerClearNotification()) + + #if os(iOS) + register(command: "update_complications", handler: HandlerUpdateComplications()) + #endif + } + + private var commands = [String: NotificationCommandHandler]() + + public func register(command: String, handler: NotificationCommandHandler) { + commands[command] = handler + } + + public func handle(_ payload: [AnyHashable: Any]) -> Promise { + guard let hadict = payload["homeassistant"] as? [String: Any], + let command = hadict["command"] as? String else { + return .init(error: CommandError.notCommand) + } + + if let handler = commands[command] { + return handler.handle(hadict) + } else { + return .init(error: CommandError.unknownCommand) + } + } + + public func updateComplications() -> Promise { + #if os(iOS) + HandlerUpdateComplications().handle([:]) + #else + return .value(()) + #endif + } +} + +private struct HandlerLocationUpdate: NotificationCommandHandler { + private enum LocationUpdateError: Error { + case notEnabled + } + + func handle(_ payload: [String: Any]) -> Promise { + guard Current.settingsStore.locationSources.pushNotifications else { + Current.Log.info("ignoring request, location source of notifications is disabled") + return .init(error: LocationUpdateError.notEnabled) + } + + Current.Log.verbose("Received remote request to provide a location update") + + return Current.backgroundTask(withName: "push-location-request") { remaining in + Current.api.then(on: nil) { api in + api.GetAndSendLocation(trigger: .PushNotification, maximumBackgroundTime: remaining) + } + } + } +} + +private struct HandlerClearNotification: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.verbose("clearing notification for \(payload)") + let keys = ["tag", "collapseId"].compactMap { payload[$0] as? String } + if !keys.isEmpty { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: keys) + } + return .value(()) + } +} + +#if os(iOS) +private struct HandlerUpdateComplications: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Promise { seal in + Communicator.shared.transfer(ComplicationInfo(content: [:])) { result in + switch result { + case .success: seal.fulfill(()) + case let .failure(error): seal.reject(error) + } + } + }.get { + NotificationCenter.default.post( + name: NotificationCommandManager.didUpdateComplicationsNotification, + object: nil + ) + } + } +} +#endif diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 8af70af0f..dfbe5e446 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -810,6 +810,8 @@ public enum L10n { public static var details: String { return L10n.tr("Localizable", "settings.connection_section.details") } /// Connection public static var header: String { return L10n.tr("Localizable", "settings.connection_section.header") } + /// Directly connect to the Home Assistant server for push notifications when on internal SSIDs. + public static var localPushDescription: String { return L10n.tr("Localizable", "settings.connection_section.local_push_description") } /// Logged in as public static var loggedInAs: String { return L10n.tr("Localizable", "settings.connection_section.logged_in_as") } /// Servers @@ -1385,10 +1387,14 @@ public enum L10n { public static func available(_ p1: Any) -> String { return L10n.tr("Localizable", "settings_details.notifications.local_push.status.available", String(describing: p1)) } + /// Disabled + public static var disabled: String { return L10n.tr("Localizable", "settings_details.notifications.local_push.status.disabled") } /// Establishing public static var establishing: String { return L10n.tr("Localizable", "settings_details.notifications.local_push.status.establishing") } /// Unavailable public static var unavailable: String { return L10n.tr("Localizable", "settings_details.notifications.local_push.status.unavailable") } + /// Unsupported + public static var unsupported: String { return L10n.tr("Localizable", "settings_details.notifications.local_push.status.unsupported") } } } public enum NewCategory { diff --git a/Tests/Shared/LocalPushManager.test.swift b/Tests/Shared/LocalPushManager.test.swift index 07b00af25..a384f45ee 100644 --- a/Tests/Shared/LocalPushManager.test.swift +++ b/Tests/Shared/LocalPushManager.test.swift @@ -51,7 +51,8 @@ class LocalPushManagerTests: XCTestCase { webhookID: webhookID, webhookSecret: "webhooksecret", internalSSIDs: nil, - internalHardwareAddresses: nil + internalHardwareAddresses: nil, + isLocalPushEnabled: true ) } else { Current.settingsStore.connectionInfo = nil diff --git a/Tests/Shared/NotificationAttachment/NotificationAttachmentManager.test.swift b/Tests/Shared/NotificationAttachment/NotificationAttachmentManager.test.swift index c3956c74d..c76017737 100644 --- a/Tests/Shared/NotificationAttachment/NotificationAttachmentManager.test.swift +++ b/Tests/Shared/NotificationAttachment/NotificationAttachmentManager.test.swift @@ -27,7 +27,8 @@ class NotificationAttachmentManagerTests: XCTestCase { webhookID: "webhookid", webhookSecret: "webhooksecret", internalSSIDs: nil, - internalHardwareAddresses: nil + internalHardwareAddresses: nil, + isLocalPushEnabled: true ) image1 = .init() diff --git a/Tests/Shared/Webhook/WebhookManager.test.swift b/Tests/Shared/Webhook/WebhookManager.test.swift index 8ce752c8e..b9539333a 100644 --- a/Tests/Shared/Webhook/WebhookManager.test.swift +++ b/Tests/Shared/Webhook/WebhookManager.test.swift @@ -29,7 +29,8 @@ class WebhookManagerTests: XCTestCase { webhookID: "given_id", webhookSecret: nil, internalSSIDs: nil, - internalHardwareAddresses: nil + internalHardwareAddresses: nil, + isLocalPushEnabled: true ) webhookURL = connectionInfo.webhookURL @@ -177,7 +178,8 @@ class WebhookManagerTests: XCTestCase { webhookID: "given_id", webhookSecret: nil, internalSSIDs: nil, - internalHardwareAddresses: nil + internalHardwareAddresses: nil, + isLocalPushEnabled: true ) let nextAPI = FakeHassAPI(