From 284357137fb357b403c331139253dcbee16b586c Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 7 Mar 2019 21:05:58 -0800 Subject: [PATCH] Photo/Movie Capture --- Signal.xcodeproj/project.pbxproj | 14 +- .../ic_flash_mode_auto.imageset/Contents.json | 23 + .../flash-auto-32@1x.png | Bin 0 -> 585 bytes .../flash-auto-32@2x.png | Bin 0 -> 1168 bytes .../flash-auto-32@3x.png | Bin 0 -> 1763 bytes .../ic_flash_mode_off.imageset/Contents.json | 23 + .../flash-off-32@1x.png | Bin 0 -> 537 bytes .../flash-off-32@2x.png | Bin 0 -> 1026 bytes .../flash-off-32@3x.png | Bin 0 -> 1556 bytes .../ic_flash_mode_on.imageset/Contents.json | 23 + .../flash-on-32@1x.png | Bin 0 -> 445 bytes .../flash-on-32@2x.png | Bin 0 -> 840 bytes .../flash-on-32@3x.png | Bin 0 -> 1158 bytes .../ic_switch_camera.imageset/Contents.json | 23 + .../switch-camera-32@1x.png | Bin 0 -> 500 bytes .../switch-camera-32@2x.png | Bin 0 -> 954 bytes .../switch-camera-32@3x.png | Bin 0 -> 1511 bytes .../ic_x_with_shadow.imageset/Contents.json | 23 + .../ic_x_with_shadow.imageset/x-24@1x.png | Bin 0 -> 243 bytes .../ic_x_with_shadow.imageset/x-24@2x.png | Bin 0 -> 398 bytes .../ic_x_with_shadow.imageset/x-24@3x.png | Bin 0 -> 573 bytes .../ConversationViewController.m | 67 +- .../ImagePickerController.swift | 0 .../ViewControllers/Photos/PhotoCapture.swift | 673 ++++++++++++++++++ .../Photos/PhotoCaptureViewController.swift | 663 +++++++++++++++++ .../PhotoCollectionPickerController.swift | 2 +- .../PhotoLibrary.swift | 0 SignalMessaging/Views/OWSButton.swift | 8 +- SignalMessaging/categories/UIFont+OWS.h | 2 + SignalMessaging/categories/UIFont+OWS.m | 5 + SignalServiceKit/src/Util/FeatureFlags.swift | 5 + 31 files changed, 1536 insertions(+), 18 deletions(-) create mode 100644 Signal/Images.xcassets/ic_flash_mode_auto.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-32@1x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-32@2x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-32@3x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_off.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@1x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@2x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@3x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_on.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-on-32@1x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-on-32@2x.png create mode 100644 Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-on-32@3x.png create mode 100644 Signal/Images.xcassets/ic_switch_camera.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-32@1x.png create mode 100644 Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-32@2x.png create mode 100644 Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-32@3x.png create mode 100644 Signal/Images.xcassets/ic_x_with_shadow.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@1x.png create mode 100644 Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@2x.png create mode 100644 Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@3x.png rename Signal/src/ViewControllers/{PhotoLibrary => Photos}/ImagePickerController.swift (100%) create mode 100644 Signal/src/ViewControllers/Photos/PhotoCapture.swift create mode 100644 Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift rename Signal/src/ViewControllers/{PhotoLibrary => Photos}/PhotoCollectionPickerController.swift (98%) rename Signal/src/ViewControllers/{PhotoLibrary => Photos}/PhotoLibrary.swift (100%) diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index cbff3b88c..5b751d7eb 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -476,6 +476,7 @@ 4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; }; 4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */; }; 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */; }; + 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C21D5D7223AC60F00EF8A77 /* PhotoCapture.swift */; }; 4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C23A5F1215C4ADE00534937 /* SheetViewController.swift */; }; 4C2F454F214C00E1004871FF /* AvatarTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */; }; 4C3E245C21F29FCE000AE092 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA5F792211E1F06008C2708 /* Toast.swift */; }; @@ -496,6 +497,7 @@ 4C9CA25D217E676900607C63 /* ZXingObjC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C9CA25C217E676900607C63 /* ZXingObjC.framework */; }; 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA46F4B219CCC630038ABDE /* CaptionView.swift */; }; 4CA46F4D219CFDAA0038ABDE /* GalleryRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA46F49219C78050038ABDE /* GalleryRailView.swift */; }; + 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */; }; 4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */; }; 4CB5F26920F7D060004D1B42 /* MessageActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB5F26820F7D060004D1B42 /* MessageActions.swift */; }; 4CB93DC22180FF07004B9764 /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB93DC12180FF07004B9764 /* ProximityMonitoringManager.swift */; }; @@ -1225,6 +1227,7 @@ 4C1D233B218B6D3100A0598F /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = translations/tr.lproj/Localizable.strings; sourceTree = ""; }; 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearchViewController.swift; sourceTree = ""; }; 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIAlerts+iOS9.m"; sourceTree = ""; }; + 4C21D5D7223AC60F00EF8A77 /* PhotoCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCapture.swift; sourceTree = ""; }; 4C23A5F1215C4ADE00534937 /* SheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetViewController.swift; sourceTree = ""; }; 4C2F454E214C00E1004871FF /* AvatarTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarTableViewCell.swift; sourceTree = ""; }; 4C3EF7FC2107DDEE0007EBF7 /* ParamParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParamParserTest.swift; sourceTree = ""; }; @@ -1243,6 +1246,7 @@ 4C9CA25C217E676900607C63 /* ZXingObjC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZXingObjC.framework; path = ThirdParty/Carthage/Build/iOS/ZXingObjC.framework; sourceTree = ""; }; 4CA46F49219C78050038ABDE /* GalleryRailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRailView.swift; sourceTree = ""; }; 4CA46F4B219CCC630038ABDE /* CaptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionView.swift; sourceTree = ""; }; + 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCaptureViewController.swift; sourceTree = ""; }; 4CA5F792211E1F06008C2708 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 4CB5F26820F7D060004D1B42 /* MessageActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActions.swift; sourceTree = ""; }; 4CB93DC12180FF07004B9764 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitoringManager.swift; sourceTree = ""; }; @@ -1847,14 +1851,16 @@ path = mocks; sourceTree = ""; }; - 34969558219B605E00DCFE74 /* PhotoLibrary */ = { + 34969558219B605E00DCFE74 /* Photos */ = { isa = PBXGroup; children = ( 34969559219B605E00DCFE74 /* ImagePickerController.swift */, 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */, 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */, + 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */, + 4C21D5D7223AC60F00EF8A77 /* PhotoCapture.swift */, ); - path = PhotoLibrary; + path = Photos; sourceTree = ""; }; 3496956121A301A100DCFE74 /* Backup */ = { @@ -1912,7 +1918,7 @@ 345BC30A2047030600257B7C /* OWS2FASettingsViewController.h */, 345BC30B2047030600257B7C /* OWS2FASettingsViewController.m */, 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */, - 34969558219B605E00DCFE74 /* PhotoLibrary */, + 34969558219B605E00DCFE74 /* Photos */, 34CE88E51F2FB9A10098030F /* ProfileViewController.h */, 34CE88E61F2FB9A10098030F /* ProfileViewController.m */, 340FC875204DAC8C007AEB0F /* Registration */, @@ -3615,6 +3621,7 @@ 4556FA681F54AA9500AF40DD /* DebugUIProfile.swift in Sources */, 45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */, 34D1F0881F8678AA0066283D /* ConversationViewLayout.m in Sources */, + 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */, 3448E16422135FFA004B052E /* OnboardingPhoneNumberViewController.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, 344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */, @@ -3628,6 +3635,7 @@ 348BB25D20A0C5530047AEC2 /* ContactShareViewHelper.swift in Sources */, 34B3F8801E8DF1700035BE1A /* InviteFlow.swift in Sources */, 457C87B82032645C008D52D6 /* DebugUINotifications.swift in Sources */, + 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */, 4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */, 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, 3448E16022134C89004B052E /* OnboardingSplashViewController.swift in Sources */, diff --git a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/Contents.json b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/Contents.json new file mode 100644 index 000000000..8d25a5404 --- /dev/null +++ b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "flash-auto-32@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "flash-auto-32@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "flash-auto-32@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-32@1x.png b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-32@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..caeae3779cdff6b09bfa83e94ea4794cffaeedff GIT binary patch literal 585 zcmV-P0=E5$P)5FbsX-_}^|oHXs``BXk4G1at#BLc2lO05(WB z2pg0Q!UWeBs{%X0ky#18oJ6_vls^g(z$rA z1O4UaAM~2V91xhU^wr${8jvTBrlHHJqTyePvaAM|tWUE~4 z)gnC#01vKUF|%%1mRAwMCPOR;UMvKYA&4s7+M1K;KqLFqmVGb;MAS)vdCRQv4y9f# zkY4cw;GLOEwIUS)N0^ZW+YTVu{f3}YOoTYB4QAR7$)F+Afp4DVr0tLl8Zr*AVuA@@ zlBA1e&W)(oh={0`nsH|4T}VbEYDBhb7t5q0H<3{rk8q$9DFeor(HtM9 zgVu17=K#I{ XH*8JS^OiGb00000NkvXXu0mjf5~%zQ literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-32@2x.png b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fdc0d600b124a282b6a1e858534a12befb7d78d8 GIT binary patch literal 1168 zcmV;B1aJF^P)1?D82}N-h&V&U z8`W6}@4W|^0wOK};45{H0br6)EsC@7fcVzg;3M2TU~mIOw+Db*ON03yx-;4>3Fh7hLodBo(d?tefGftropaA0j0Q(fx25q*=|&m&t^4?DTv-{gWz>14g8>P8Cw%67@ zh0e^2dHGU>E{~LL}Ih^qBsw&>vfR7eRt;Nl#oOv%}IY`vQs? zfZTW|(%eQiH$0|BYvonFIe?Y7ob;v{(&LaRa0%>t-NUj<%eTJ~Z*6Yrxy13aLAfVx zEIq54!WLh+6oX$`dQO8+R-07}V36LX%NG_NyyT$H8Kn~BnvuS?BE3q3zcIpaYU$=$ zw$u1RX>Iof4E8)L@hnSAucQG^wsyh6&t(`|dc_PtEL(ftZZ(KgNl&(OIv~+l542cf z>ki8*R-*^B-dw4c zLYOJV9yk{RB6<9Suc46RqN+`=IQxNFZvU#Du*NQ*m_=t|%) z_~eeT!3~YorpOQY=bfdmuD67h4Ip!QzE$42+7$XSxyl+ZwjoH66W}A8(&YN$PdY)8 z1Rc0~0mVE&an`u@ID`Szi4Y<^zLOkDZ?3M?u*j4X-$-2{FoXd(yRRwJlV=7kR<~en zuk$IjdG*$Fj(=v54{?mRnQ|0Xfa}UeGl5CQ>cC)TRU# z2QOo@Ri>9}Mxm5z$@_bTfEA0q%3I3+d7-502@zi7#1AuE4Ped5liW&B2J(1~GA<&n znGuo(@SFvt0lJbNN1N58bwd9YJPp9zJf0a`D2?`LbG(Kd<}USHMDOGBQ)1=RL%ZYc i$J0u3Ww>qw0Kh+oHbn-l^;g&c0000$zCXgWdd z4g4l(GC|`BnrvWCz#i3b!bes??Y#H2pd7wgWM3AG0t#bM zKw&HjD2&-Dz&Ur}oV$0B_x?iERs^#qC_%a*o4sPoFa?}*Ye2eh;8s-=Fj-UplkP8~ z;W#6{g7i#(nOB8tiOS@fz9-&%M``XF62qIcf=Cib(B>Z>0XHjqI+Isp{S<`1ysKfORRLCz4t5bvyW!LoA>@wTu~vao&gEc zJ)B0pW%ENdE5JE-nGod4dw(L`Ytd?ifT>mmw1gQxd+$R5uS5@Ban+$p4q(zni>;0K z{x<3xzd-c?xDeIN0O#DDBK#c?zG?GAwJE?k*O-$(M8h%8gG>#oGh=?JCIv9*ig?b+ z;|WMN#G-&4Xd&IZh;&0NCMXjgm6h(F-utzv{g^TF_kf);Q>sOA1vE)i3h6d0-8)hH z0W|tenI9^x0A9uZE*g%ci{d*Wko`AheyF4Z*o8#qB}KX+kOIujfFcXAAhwU}{%6hZ zN52t2xS0T4h*C2^3ok!(6h0dL=FAV3PymzePojb7OY>z*)M6k7m@_|gZUrPrch@tC z7I{D>XTaGwAd$s(;P7&10fG-OO$LM$9Sz_tGoWRqd)rg^*WhIFnA8~ivlXOknKrS^Qi^7;b>4u@|7 znaJ*IqL4*LBKnh)%Hj{f?V#S2QLjuU%47KN`R2~d=oHwwAjP!!`Kj_qrT zsTM7vlQ=VP9BL-0f_DFG?<6WR5tL=&-+}kZM!InsOu}p<{}m0#FvA+`**X{2Lpdiw zx^L`Al(`+)G$=8vg>+Myd808#0j(d; z3t*_Av?KvFGcOx5kT9CIrAqI(Ov02tq5j!PA5~f?O2Ds|t>)}IB>dNNg-*;KR|68W zdhLwKnItdq}*?gte82iDq2(8X)Ei44FYSo@M@x7u~k^s0~{2scl zOR;8xkokcluZ46wXXYu3z8Gjt(FxWRfP^9Je+%jMO%g!&+<<$ceyy0L0=N#5?r#H< z1manD;0S7$XKRNs4iKSl&?oB$b#TwEQa>nsZDs~^*%^~K)OE)n+jHFW#5wUyLY`JD zESVrQ#ovX{cD~%em2T>u!jui+0ZR%{J)oA%yw!-zJR#74Ly-4{o-v8J%mDOZg)(HZ z#R7ddc?y}MXH3G%!L1j-t7*c=dk!xnKYTMLwyzwA6Du>`ojF?*gkEgRb{}s1j?b6N zbkBIy#|rtRoMWfLv;vgWn1j+k$30^Oj5b476cCT9FC6DVrYF~jl~|B(;CB(f^qK)y zqM@;m{i3W!v!sqI_WxCJGY^pifcoe_tFy{hzbmk+eF1gZGO`o-LS0MejoTJX5Sr4l z>3PmTx*^7LNayPMv> z_k{S|)2l|2T?R(&UJw$-2~(wxqtUxnLJ$ALIkyF~W|i1w`HtF65E2G;IMj6vkkVD* zC)&~P2-L0sbPVMbq#Gh$b9C8tgUsM86cC%3?4%n)S!f~Qeg%G>I6Lo(2|Wo5|Dtrm zVTVQ#kJb+7Zo{np)ZZfJwoB3+76lZ>qJY9!6i^aG^dB7{?_+v`njZiF002ovPDHLk FV1nN-GhhG! literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/Contents.json b/Signal/Images.xcassets/ic_flash_mode_off.imageset/Contents.json new file mode 100644 index 000000000..642c424c7 --- /dev/null +++ b/Signal/Images.xcassets/ic_flash_mode_off.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "flash-off-32@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "flash-off-32@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "flash-off-32@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@1x.png b/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..306fa9721947bebc3d5e017b4352a9a7f682ccb4 GIT binary patch literal 537 zcmV+!0_OdRP)0pxvjb@Bup^?1G-V1 z+UGH4lj5Qhc%XY0@v64|hDtGDLHEJTgJb}60!XH3QY&DPv>yVpb;xEh3^<@O^==&b z@&6sP&0+yan%RQRkvAgSh3pmyK%7{__nvO!1eU{ZAs@+YNJM71JOFrS=2uU*Hnmqq znpyx)W?(V1URa^1Jb+cFSTcAFWiVL+Z_+iPGnoPG_k@HQ&RaVrOF^gZ z3JEg;=)F0x4V}9r2;IrH_ZL+O)dpa<;E$~3YMRrACmF$`(iBkD-?4lhn|3p8H;Zoj bdk)|i@{}%Gd2w~y00000NkvXXu0mjf`qA6^ literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@2x.png b/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd5ae27da0aabdbcc9fb86d19706596f49c9eeb GIT binary patch literal 1026 zcmV+d1pWJoP)-_+C0u0exshoEu zz;zseh$AI>HNWp=$h!&P;hDT9Lo9krmG@uy@86?=h%owcMtG(`r5gW4L<=?ITSec> z3>N|raRvZiglR)W6aG9BcGvJfAf!Of!aYo)?88u(>jtXsA@}p>k~IQY^eSZ@6Vd+o zS=G6|0RplSz@k^Fk=`Q_$uO7UzZ1l`A!h_wq8CMj`0U>M2UvM=Y2jh6%-nBGoX8YT&7 z^2v^r5fB>xQT+eg5IqU`1}GzdkVpi(ik|QvD*v(NcM~GmRrJ(%^yn~kY3+by1aKJd z)iC#@ni~<5qjZX@vo%0WZ>8u>T0~Dm4}nvlKIX?sLP$jn^8~dqf!-+KMrJ?Ie1c8b2`7aANtkmDWn* zhMH~n1q|^#3-v6O<(HCxi`h=t_?b>a%P$!Ltg^Y^+npM5sp#o)O$Q`S&IdXyvGs?= zidFalt)5F&TJ?lAXq!3>BZFfe>$DnLe!^dOz})h?r4*3E;a%H~Fd~Mz!)IYy5z#bq zJOkH4K;!%Y5yQaiyD;rio*ybnz{v%Xo=a@!!O9DV{6D!MnoIPqMFU<+OOYBX6jsQ8>`9VbHoFFG$H2@L!4X4AO z#IZS7c%BpuJHr2>3TM&N@Js<~KQR6DwkJFZ7)6$s#y746d7vZ#MMO`-lK>g!LG$>C-vj`_f4G-rZyRgPj{pDw07*qoM6N<$f1^@s6 literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@3x.png b/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-off-32@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..deb4174165bcb6a9ba96e892ff204eec18b911e7 GIT binary patch literal 1556 zcmV+v2J88WP)*0hbEM zTLDQ0Z>|E83b?C4jOUbzv1ISZdDqg;J~RGf`|ftF)$`a|*@r{~E85|!b?n<>H9%vm z255}c0FALY10doVBHkk66Cz%T4#u#kSr8�KgLfd=pjwL`09Gvk@%D1HzLJ0FTMw z2V9HJhOi_G3WNWxs0DZxHHNSV10do(0DLbRj(8C@hTvWU5HV29pQ7OdpNPn;7q~V+ zMs#Bwi0Dkz8iHF2Du`~3CsAVzo;4sRx-nJ*OaVmPDx&+Q{FG?{29E|{(fwI896jQH zAfnX+YJx>qoAKNMKq|J+qL)E@?cpNfLv*z#dPhVd_Onmw&C?G!^8jtSc>sVb4!Wgn znz78mg#lP}zY`4rx4g*GqMj}cV16N1Sm{0!(IxlUCm!%dMCalY24UI*3Zi>>f9gHc z4_Gz;BAyop@{)+&MfXCqIuT&7YJiR~!wnI|1AHlZ-%KuZJg6jGy?lIX_J4B*ZKnk>Yk*uKX5H#y!-M}PTZ3g7h17_2J zLKfSB!^^z~PlFYo5P-_~h#dv^Y`#HmyMMvl) z&CELumIbOI@6UEmqN0*Oc^>?YazE8XH!Y(@m^tzv(QphiY?VD*b74DHa|)vS#uJJ9 zDKDB;dOj(+g-Po&YPuU{H5}bcuYb>jFFsSFEbYLiQHfa{qFc($I~k`Kp!4N<1B@N0 zT#`UGGp`ydh_L3it;*oIOo2+DwEopaU#s*`G6BCqT+P{c+Tg#=6*_TyT#b^LHE3r{ z%_MND>JVqyc%G%2UN<;s|J@EFg7u( zu?X2-gL4)b1W2HMNGp{y08bA}^q5%r$%iXm; z)_5r%kbQy+hq^wr5Zx56M3|}M-=sRbGsdFmrtszg8vUqGe~0Lf%FLSr-VC78kKq3f z(KVGMut)=NohG`!T1yh}rU9J`upvj?Sb!T3=(97XaH#8!U$&crCkiUkPuKZ!R?m>| zWPt1ps3SA)(sE|rQanI=u>#ez*d~CNgImv(SxpEZ?>W4z#r7ZOj_r?ui&+qF@ct{Q zzF`W|GO|N@ibh%H;N|dcjeZ_8yno2_HwiBqkUq_yIVDD;9uqO1zVKpzG@ALs`8+Ai zi*5?@2FPSUXy-+{5Zx3WJU|oElzN^mh8g<6g8?)Th|%YKIkWKa{{bQZ7h%>cwkcr! z{{yrPh#0eGu}uN%6jY+0=~KsNfOP|C0t`J4b#(*l2GCx%yB6IP)(wzSjOp0^7F4UUPFM0G$L{nk&{{UU>ZRw0qg+W zrZfPCk~~{G&;xi4pD$+Bfnot7at3gSLW9}l0c*<=0kom*G51BeSTIKb4S+EzKIYEk zxM~C`Qx2hJu^hA#kb z*^^AwiswR4zH!Wkom+Je_oZ1Tb@b0yvsz z4y-NC5tx?}kLgSv7IYNflexQ0SIYxw`#)5*GrJrp6%ZS6GTpn`@yh~!IF nhd#V3994DPBH*9@I|lF#;yqi~raAL!*4e)ZK=fKg909Nm z@4jc|6BVgNhWwvjTQR^g%_5PR#tMqJ?k~AYyt=%szd=T!0Iv*W`Ky0I@O)2Ji=kk8hYNlGA7+LM7R0gw_I=3{(VdDE#zX)Bw?y=I3{ ziMas^*#!WwA)?P}q92zE$ZCQkOz#<40GVxqG|5&0J2YP_keZVU3>icurgyAR02!;4Tt>G*V|hEq^r~Ts2h^PW z12ZT2$|wQCuQ)>(SuOfZ;U>`iMKo7jDKjn2td@{5Wc zpTjT8>c0C%@>&4EeZ_q*rngkl1d>cJY-?SI>9x3q6C$fgJ4pjP;1KCM?=jf)R9>;5 z#mMlDvdeLwo-Hut2+SQbZ&h>zo&tRBru=^}^IksO5=DzbplkpHB#Q??hZdJ4Z9v;y5hDA-UjrB3PzVbhXzBfB4Vci0K z<-aa%dTp!)=&FD(seAc3G4%id#|vwKpUk{bQ3EVbipqZj{-_CK(`#ed0JHS*xTmhj1k_vDtAaoEq zNGc$y08#;Q16QD3n zfWlNK05dO``O1G*>H=d_br0M4Mu~o@5kpiV05flh=&hRg%dHwQ1iS|@vnw%Q)x2}$Q$dMW~ z1Z)W!Fx@WKYQz}G9a4_Okxn{JB3^CrMl5}0n6R)PYEG2JeAC=0MhiVMKZXOX7c1*-f|X#sra+GjNp z4pjM}k_R#)4%9<1-2t(SA{pRK*X?ko4E{Y@@Tp)43fW#h1YG%{5(0SBeWxbkfiqy5 zTYwCv+XXTMW;5YTruzg!2gELB$$-Qw-CaO*K@Q#jcK@TR*_6OI4ll%oWB)4d(hy<7|CO3;|r@mg5NY=I;f zU_$G74SW-k%cyvzEJ0IR$1A`c{w$(nmP!C`xuoXnBb#L8jkAk`Q;t zv&z+P)ZUnNnA4oy)~y5$b0H$9gI7cnoB@9SW9?J4Ms9KkpOD{{;@%0Dsk?%5nr)j@1xRVY=cQk3{G2o3TM5(c17*Vg z=<~Olm`*X%?Sqy9DaudQ?d7SU36P@vtSmiC_S^*cBqFA|H_$vFW&!3**W4){(rYUh z5y5w+n|*IT1f&uaT7FEKZXYB8LhpcjE6B5pdJ(V_krLi7~ym9|FQ%(8@m$=z_jMH{Cu}{ir`8?J3E^Gyw|J1Sm`spd>`} Y2WiwiRHWc?`Tzg`07*qoM6N<$f|}6_FaQ7m literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_switch_camera.imageset/Contents.json b/Signal/Images.xcassets/ic_switch_camera.imageset/Contents.json new file mode 100644 index 000000000..1ceab7bf7 --- /dev/null +++ b/Signal/Images.xcassets/ic_switch_camera.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "switch-camera-32@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "switch-camera-32@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "switch-camera-32@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-32@1x.png b/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-32@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..a005bca9ed0dfe8f9233eea5d2c1448afd1e1527 GIT binary patch literal 500 zcmV(gLVW)=m;4hBV+`+LAn7W zkPX=2D_6F7{4P8Vgi5vKApCy&?0*}FLI~KC9(MKq7XUmak{KeR5&&~yGSTGGzKqoluz3+KIgYS7<5?GH*fl_7(V0@0w3qo74G29ec5GXbdGT8>o# z401<#lLiFf-Lsu2ZpAz+c>U#G&I>(ryLPXU>|~w;d5BkzUdym^*FB+4pC`|BUPdMD ze%2F#F>qdCAW3fi5&)ZzWI01=C7IBn^g~L@8%nwJy~z7j+W7RC?xFH{x3S#Gh>qU} qv(!nQPe*L+Ed{VP{qsMk0KNeH-Q}D^xZ2(T0000Y#xw<0bmaRC%Z%>+d1~x^wb8UCImPDz$Jm< zF55W|+L|$(EE7aT>)uBI_#sYy&>Ok+-NiL3Sg3qg&I8S3foQ~;A(ZH@AC)_HziSl$ zY_;n`Y_9=;GT=>i%}r>I>>*$kpu4FVp3GeXVbu-+Hwggbs#ixLdt7PqiGV0QYZjV( zHUK=vw=s(JSbM8@-BFXx0sy(a>yD=s@z_&`n%1s0nVbN)=tn6K2}JaBMht{ldD@>s z@>Af>c>ulVf3u;peP3r!RWnl`09uyh+{k!eLNgiav>QakgOlMM=U}|0P9A`W7nYj7 z8Dq9WXrlDc11R=+US0{IIRijx=$-7{uZ6^fsS5ym_Ip;wTM7a|?$4_?vjgB!No>b{ zTlc>Ri4hW&;c7~muaq&W`AQ!E(i9*v6B*w)ukFTqvj)O@2-u4dv8IbT@z>qwdp{sQ zM&p5w8=t?crcXg}L8Sm&@I}f&!m*_+CV!{9jqch&ePG~efIm>D$Xfr0-2Xsm&M@XGB*x7Z7 zA)?J*|FEH-q5mTWcY_lpUcb17j9&r(Cvn!HSph)BTVB3h}Dqd z?JPV15es_V-OG1pV@x3g6`H&Uu<{qLU6w-7Lvsj$+Nl8j>eWA_rH4J!&g7k215@75 zI!3QKw7~%i)X&@}PwH+v=~ST#3b6~v+o7i2NOHy;*UO!INi2wiq literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-32@3x.png b/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-32@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..72b1732a0cf5af16d1c1d40b3957180112477072 GIT binary patch literal 1511 zcmVE)_%_Sm{6=MCm{!s05}0%N6)i zL8J;SRgk+5WhLAX1vhr~R3(UH2^u3DP9AHf@p~xV(z+!bJ{#liSl6L%?AO(p`bdabWJt zJpjeZF{%J$>$ys{;YudykxO5wOb>L=D^s3s*m3D+BS6H{b{La*rhE@EW#rOMUVuQw zMr-1cW!3$}nJH%*o=Wu#R>TP8kJ`y4lsPm=*mLP7CqRJwYUAyM@^6{Gp_m!6A_|eO zE41R$OjLwbM}&Fw0iMy$tjIS+Xt@7|41>&sY4!5E^~Y^Bay`XW$f=G_I?TPs#zp|1c(X2tP^I^D@{2TlRmCWw}mx#ixi zkROjeeE`?-ejR;1edfqALG<+hXGH%X)AMX45IB+B0(Mr+ZPFs-CnC88koUs>)*k@B ziva1!X#pE6=4Sha{0!s>z>V*@4p|U^BopK?KQD+Fau*ahklF&)R?Ka;NRWwC=Ufqh zfl_Ki%hxTM*vV+B33@jDHW5)-7cL0oTKUDO@r71`gCeAwAk%5~WL+WT1Fa4pX{p|e z4@QO?S0bAInqY^XzI^Tl9`ch!col#${1&Sm!4e=dpIIS-{1#ym9Q(Ov({+S4Ud;X8 zwQ)@_pScI7jxoW>1JKNt%j8|6kN}>7ow*y&F?i~jEdr23lFKXXHIYw%K*1*O3l!-k z;i}`2i3{@inlHY2g*g-X1n?AW3FKetr9m@(@A0yGZ&^Aw@rlql1bAe;$d*DrlKEaU z@%sRuZ5W(lhx@k`l z?uL914_o6JuK=NZteXZB`NCb1@8H3zqoZ%M-hFW8W8Kh`$QP`F-Ju8@$A6qhxFvuq zpWtdJO{0$v_d_5T#KrE2an5NeE5uRD(H;>NO+MVXF$ugN;>xy6hzW{E!;lY-w}ql10z`luvHoJN{HO2B=wgBb z@Vty3?SSipv;Yr?d{^f>$otU61yD*gRFPSsL%uoWgh9{2;I_^N+{$aW>*jo5lNkm* zz{!dzKt6Uvn9Gr69PxvbUrYdpr$8GT&PjCw#D|WsEOK<26)|9Co|sB>y~3t;Y^CpH z-+3I`XJzhxqJCAZW@{kd*84oTwNuF*Qz^4=*pGT(_KoE~_lDI9_ov$r%CwjSXBBxr{ z%(o0URYqzNi~od4l@?&4;zxW=G{ULsS~;c9SglBiF+O88nbW=I@FBOgV?itiFVuI) zhur!=K4mB<2HB9Q3=yC*M1aZ=0V+cTs02BR0px{zZ z7sn8f&bOhtTn!35wXa#^ZYq~4CkHIZJIgEcH&fxt=UR_fi>^r-z4j>muhP(=B`A6@ zMkG5zJ7N#}d4>Zlbry_M>dr}>*=zbIi2c0g0`*-^VY4WGsShchAuJR{iW<~ m>yER_Cs;lH?|)y9K|JM*R^O4Q$E1M{X7F_Nb6Mw<&;$VBSYMt1 literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@2x.png b/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ea502285caf8531820a4cffebcb866af3b2bc408 GIT binary patch literal 398 zcmV;90df9`P)bfi!V8w;*bPFyWC? z5LS4a@X95KOM1<-BQz4(0Pjyx5J(e{|41VdCQu`pAP^>?!>~qrny{|9Xltai361lc z#Wm8|1hkG41kePe2n@fZ6psG(6RI>m#!qZC{{F*g=!qfnTLRL&#bj$Gc`3=U%j$ibvB^93G-~AMAJcc sIWaMN0f1J`aZ=wK!$5Bg16@HxZh($ literal 0 HcmV?d00001 diff --git a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@3x.png b/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..cbb9bb263d56a3b51b69b48ace2bb48ad7eb0b3a GIT binary patch literal 573 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ<{->y95KI&fr0V8 zr;B4q#hkZu8aodg2(X05J6in|3!3Hasj2z!^2zcA?g#mgso%Y+c4=;c__PV4ZqF49 zRlNO9SvF_AUVYPPud72F#|Z&ONhJkmmXwYL9>EEUOhzS~0bZG&ns;5C1ExlKpPjx+ zJGj@F?Sn=;;}6CH>*_xl)1|IQot}OC<>$4ny7>>DNGiO2@I-rp%LSu`)a!v69=sO+ zDr#cjvmIQp>6ZL{q^VZy^hrgSzU7eubkbjY5&$a7ew{4&Q(T=h@ zmC8JoaiyBL#fK=182>N(ejdpvYGyt^^=k8kn1>DC=eONqkln*Kb=&giM%%d;Ocu`! z_c+WbSrFHoJxPAatp^>|mP>V6MuwibE!$Oj*vBw`r`9vy>Z38=0?)KMAC0LB zJ9C!1Yv*5AL-PR5XJR%-?;Ke<<936nd)sC1NgPr082$J?lrm=WZ9_y@ literal 0 HcmV?d00001 diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index ad2ecdf66..4ef70c224 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -153,6 +153,7 @@ typedef enum : NSUInteger { UIDocumentPickerDelegate, UIImagePickerControllerDelegate, OWSImagePickerControllerDelegate, + OWSPhotoCaptureViewControllerDelegate, UINavigationControllerDelegate, UITextViewDelegate, ConversationCollectionViewDelegate, @@ -2781,6 +2782,24 @@ typedef enum : NSUInteger { [self showApprovalDialogForAttachment:attachment]; } +#pragma mark - OWSPhotoCaptureViewControllerDelegate + +- (void)photoCaptureViewController:(OWSPhotoCaptureViewController *)photoCaptureViewController + didFinishProcessingAttachment:(SignalAttachment *)attachment +{ + OWSLogDebug(@""); + [self dismissViewControllerAnimated:YES + completion:^{ + [self showApprovalDialogForAttachment:attachment]; + }]; +} + +- (void)photoCaptureViewControllerDidCancel:(OWSPhotoCaptureViewController *)photoCaptureViewController +{ + OWSLogDebug(@""); + [self dismissViewControllerAnimated:YES completion:nil]; +} + #pragma mark - UIImagePickerController /* @@ -2788,20 +2807,48 @@ typedef enum : NSUInteger { */ - (void)takePictureOrVideo { - [self ows_askForCameraPermissions:^(BOOL granted) { - if (!granted) { + [self ows_askForCameraPermissions:^(BOOL cameraGranted) { + if (!cameraGranted) { OWSLogWarn(@"camera permission denied."); return; } + [self ows_askForMicrophonePermissions:^(BOOL micGranted) { + if (!micGranted) { + OWSLogWarn(@"proceeding, though mic permission denied."); + // We can still continue without mic permissions, but any captured video will + // be silent. + } - UIImagePickerController *picker = [OWSImagePickerController new]; - picker.sourceType = UIImagePickerControllerSourceTypeCamera; - picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ]; - picker.allowsEditing = NO; - picker.delegate = self; - - [self dismissKeyBoard]; - [self presentViewController:picker animated:YES completion:nil]; + UIViewController *pickerModal; + + if (SSKFeatureFlags.useCustomPhotoCapture) { + OWSPhotoCaptureViewController *captureVC = [OWSPhotoCaptureViewController new]; + captureVC.delegate = self; + OWSNavigationController *navController = + [[OWSNavigationController alloc] initWithRootViewController:captureVC]; + UINavigationBar *navigationBar = navController.navigationBar; + if (![navigationBar isKindOfClass:[OWSNavigationBar class]]) { + OWSFailDebug(@"navigationBar was nil or unexpected class"); + } else { + OWSNavigationBar *owsNavigationBar = (OWSNavigationBar *)navigationBar; + [owsNavigationBar overrideThemeWithType:NavigationBarThemeOverrideClear]; + } + navController.ows_prefersStatusBarHidden = @(YES); + + pickerModal = navController; + } else { + UIImagePickerController *picker = [OWSImagePickerController new]; + pickerModal = picker; + picker.sourceType = UIImagePickerControllerSourceTypeCamera; + picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ]; + picker.allowsEditing = NO; + picker.delegate = self; + } + OWSAssertDebug(pickerModal); + + [self dismissKeyBoard]; + [self presentViewController:pickerModal animated:YES completion:nil]; + }]; }]; } diff --git a/Signal/src/ViewControllers/PhotoLibrary/ImagePickerController.swift b/Signal/src/ViewControllers/Photos/ImagePickerController.swift similarity index 100% rename from Signal/src/ViewControllers/PhotoLibrary/ImagePickerController.swift rename to Signal/src/ViewControllers/Photos/ImagePickerController.swift diff --git a/Signal/src/ViewControllers/Photos/PhotoCapture.swift b/Signal/src/ViewControllers/Photos/PhotoCapture.swift new file mode 100644 index 000000000..0cbc8bdea --- /dev/null +++ b/Signal/src/ViewControllers/Photos/PhotoCapture.swift @@ -0,0 +1,673 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation +import PromiseKit + +protocol PhotoCaptureDelegate: AnyObject { + func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) + func photoCapture(_ photoCapture: PhotoCapture, processingDidError error: Error) + + func photoCaptureDidBeginVideo(_ photoCapture: PhotoCapture) + func photoCaptureDidCompleteVideo(_ photoCapture: PhotoCapture) + func photoCaptureDidCancelVideo(_ photoCapture: PhotoCapture) + + var zoomScaleReferenceHeight: CGFloat? { get } + var captureOrientation: AVCaptureVideoOrientation { get } +} + +class PhotoCapture: NSObject { + + weak var delegate: PhotoCaptureDelegate? + var flashMode: AVCaptureDevice.FlashMode { + return captureOutput.flashMode + } + let session: AVCaptureSession + + let sessionQueue = DispatchQueue(label: "PhotoCapture.sessionQueue") + + private var currentCaptureInput: AVCaptureDeviceInput? + private let captureOutput: CaptureOutput + var captureDevice: AVCaptureDevice? { + return currentCaptureInput?.device + } + private(set) var desiredPosition: AVCaptureDevice.Position = .back + + override init() { + self.session = AVCaptureSession() + self.captureOutput = CaptureOutput() + } + + func startCapture() -> Promise { + return sessionQueue.async(.promise) { [weak self] in + guard let self = self else { return } + + self.session.beginConfiguration() + defer { self.session.commitConfiguration() } + + try self.updateCurrentInput(position: .back) + + let audioDevice = AVCaptureDevice.default(for: .audio) + // verify works without audio permissions + let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice!) + if self.session.canAddInput(audioDeviceInput) { + self.session.addInput(audioDeviceInput) + } else { + owsFailDebug("Could not add audio device input to the session") + } + + guard let photoOutput = self.captureOutput.photoOutput else { + throw PhotoCaptureError.initializationFailed + } + + guard self.session.canAddOutput(photoOutput) else { + throw PhotoCaptureError.initializationFailed + } + + if let connection = photoOutput.connection(with: .video) { + if connection.isVideoStabilizationSupported { + connection.preferredVideoStabilizationMode = .auto + } + } + + self.session.addOutput(photoOutput) + + let movieOutput = self.captureOutput.movieOutput + + if self.session.canAddOutput(movieOutput) { + self.session.addOutput(movieOutput) + self.session.sessionPreset = .medium + if let connection = movieOutput.connection(with: .video) { + if connection.isVideoStabilizationSupported { + connection.preferredVideoStabilizationMode = .auto + } + } + } + }.done(on: sessionQueue) { + self.session.startRunning() + } + } + + func stopCapture() -> Guarantee { + return sessionQueue.async(.promise) { + self.session.stopRunning() + } + } + + func assertIsOnSessionQueue() { + assertOnQueue(sessionQueue) + } + + func switchCamera() -> Promise { + AssertIsOnMainThread() + let newPosition: AVCaptureDevice.Position + switch desiredPosition { + case .front: + newPosition = .back + case .back: + newPosition = .front + case .unspecified: + newPosition = .front + } + desiredPosition = newPosition + + return sessionQueue.async(.promise) { [weak self] in + guard let self = self else { return } + + self.session.beginConfiguration() + defer { self.session.commitConfiguration() } + try self.updateCurrentInput(position: newPosition) + } + } + + // This method should be called on the serial queue, + // and between calls to session.beginConfiguration/commitConfiguration + func updateCurrentInput(position: AVCaptureDevice.Position) throws { + assertIsOnSessionQueue() + + guard let device = captureOutput.videoDevice(position: position) else { + throw PhotoCaptureError.assertionError(description: description) + } + + let newInput = try AVCaptureDeviceInput(device: device) + + if let oldInput = self.currentCaptureInput { + session.removeInput(oldInput) + NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: oldInput.device) + } + session.addInput(newInput) + NotificationCenter.default.addObserver(self, selector: #selector(subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: newInput.device) + + currentCaptureInput = newInput + + resetFocusAndExposure() + } + + func switchFlashMode() -> Guarantee { + return sessionQueue.async(.promise) { + switch self.captureOutput.flashMode { + case .auto: + Logger.debug("new flashMode: on") + self.captureOutput.flashMode = .on + case .on: + Logger.debug("new flashMode: off") + self.captureOutput.flashMode = .off + case .off: + Logger.debug("new flashMode: auto") + self.captureOutput.flashMode = .auto + } + } + } + + func focus(with focusMode: AVCaptureDevice.FocusMode, + exposureMode: AVCaptureDevice.ExposureMode, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool) { + sessionQueue.async { + guard let device = self.captureDevice else { + owsFailDebug("device was unexpectedly nil") + return + } + do { + try device.lockForConfiguration() + + // Setting (focus/exposure)PointOfInterest alone does not initiate a (focus/exposure) operation. + // Call set(Focus/Exposure)Mode() to apply the new point of interest. + if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) { + device.focusPointOfInterest = devicePoint + device.focusMode = focusMode + } + + if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) { + device.exposurePointOfInterest = devicePoint + device.exposureMode = exposureMode + } + + device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange + device.unlockForConfiguration() + } catch { + owsFailDebug("error: \(error)") + } + } + } + + func resetFocusAndExposure() { + let devicePoint = CGPoint(x: 0.5, y: 0.5) + focus(with: .continuousAutoFocus, exposureMode: .continuousAutoExposure, at: devicePoint, monitorSubjectAreaChange: false) + } + + @objc + func subjectAreaDidChange(notification: NSNotification) { + resetFocusAndExposure() + } + + // MARK: - Zoom + + let minimumZoom: CGFloat = 1.0 + let maximumZoom: CGFloat = 3.0 + var previousZoomFactor: CGFloat = 1.0 + + func updateZoom(alpha: CGFloat) { + assert(alpha >= 0 && alpha <= 1) + sessionQueue.async { + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + // we might want this to be non-linear + let scale = CGFloatLerp(self.minimumZoom, self.maximumZoom, alpha) + let zoomFactor = self.clampZoom(scale, device: captureDevice) + self.updateZoom(factor: zoomFactor) + } + } + + func updateZoom(scaleFromPreviousZoomFactor scale: CGFloat) { + sessionQueue.async { + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + let zoomFactor = self.clampZoom(scale * self.previousZoomFactor, device: captureDevice) + self.updateZoom(factor: zoomFactor) + } + } + + func completeZoom(scaleFromPreviousZoomFactor scale: CGFloat) { + sessionQueue.async { + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + let zoomFactor = self.clampZoom(scale * self.previousZoomFactor, device: captureDevice) + + Logger.debug("ended with scaleFactor: \(zoomFactor)") + + self.previousZoomFactor = zoomFactor + self.updateZoom(factor: zoomFactor) + } + } + + private func updateZoom(factor: CGFloat) { + assertIsOnSessionQueue() + + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + do { + try captureDevice.lockForConfiguration() + captureDevice.videoZoomFactor = factor + captureDevice.unlockForConfiguration() + } catch { + owsFailDebug("error: \(error)") + } + } + + private func clampZoom(_ factor: CGFloat, device: AVCaptureDevice) -> CGFloat { + return min(factor.clamp(minimumZoom, maximumZoom), device.activeFormat.videoMaxZoomFactor) + } +} + +extension PhotoCapture: CaptureButtonDelegate { + + // MARK: - Photo + + func didTapCaptureButton(_ captureButton: CaptureButton) { + Logger.verbose("") + sessionQueue.async { + self.captureOutput.takePhoto(delegate: self) + } + } + + // MARK: - Video + + func didBeginLongPressCaptureButton(_ captureButton: CaptureButton) { + AssertIsOnMainThread() + + Logger.verbose("") + sessionQueue.async { + self.captureOutput.beginVideo(delegate: self) + + DispatchQueue.main.async { + self.delegate?.photoCaptureDidBeginVideo(self) + } + } + } + + func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) { + Logger.verbose("") + sessionQueue.async { + self.captureOutput.completeVideo(delegate: self) + } + AssertIsOnMainThread() + // immediately inform UI that capture is stopping + delegate?.photoCaptureDidCompleteVideo(self) + } + + func didCancelLongPressCaptureButton(_ captureButton: CaptureButton) { + Logger.verbose("") + AssertIsOnMainThread() + delegate?.photoCaptureDidCancelVideo(self) + } + + var zoomScaleReferenceHeight: CGFloat? { + return delegate?.zoomScaleReferenceHeight + } + + func longPressCaptureButton(_ captureButton: CaptureButton, didUpdateZoomAlpha zoomAlpha: CGFloat) { + Logger.verbose("zoomAlpha: \(zoomAlpha)") + updateZoom(alpha: zoomAlpha) + } +} + +extension PhotoCapture: CaptureOutputDelegate { + + var captureOrientation: AVCaptureVideoOrientation { + guard let delegate = delegate else { return .portrait } + + return delegate.captureOrientation + } + + // MARK: - Photo + + func captureOutputDidFinishProcessing(photoData: Data?, error: Error?) { + Logger.verbose("") + AssertIsOnMainThread() + + if let error = error { + delegate?.photoCapture(self, processingDidError: error) + return + } + + guard let photoData = photoData else { + owsFailDebug("photoData was unexpectedly nil") + delegate?.photoCapture(self, processingDidError: PhotoCaptureError.captureFailed) + + return + } + + let dataSource = DataSourceValue.dataSource(with: photoData, utiType: kUTTypeJPEG as String) + + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium) + delegate?.photoCapture(self, didFinishProcessingAttachment: attachment) + } + + // MARK: - Movie + + func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) { + Logger.verbose("") + AssertIsOnMainThread() + } + + func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + Logger.verbose("") + AssertIsOnMainThread() + + if let error = error { + delegate?.photoCapture(self, processingDidError: error) + return + } + + let dataSource = DataSourcePath.dataSource(with: outputFileURL, shouldDeleteOnDeallocation: true) + + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) + delegate?.photoCapture(self, didFinishProcessingAttachment: attachment) + } +} + +// MARK: - Capture Adapter + +protocol CaptureOutputDelegate: AVCaptureFileOutputRecordingDelegate { + var session: AVCaptureSession { get } + func assertIsOnSessionQueue() + func captureOutputDidFinishProcessing(photoData: Data?, error: Error?) + var captureOrientation: AVCaptureVideoOrientation { get } +} + +protocol ImageCaptureOutput: AnyObject { + var avOutput: AVCaptureOutput { get } + var flashMode: AVCaptureDevice.FlashMode { get set } + func videoDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? + + func takePhoto(delegate: CaptureOutputDelegate) +} + +class CaptureOutput { + + let imageOutput: ImageCaptureOutput + let movieOutput: AVCaptureMovieFileOutput + + init() { + if #available(iOS 10.0, *) { + imageOutput = PhotoCaptureOutputAdaptee() + } else { + imageOutput = StillImageCaptureOutput() + } + + movieOutput = AVCaptureMovieFileOutput() + } + + var photoOutput: AVCaptureOutput? { + return imageOutput.avOutput + } + + var flashMode: AVCaptureDevice.FlashMode { + get { return imageOutput.flashMode } + set { imageOutput.flashMode = newValue } + } + + func videoDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? { + return imageOutput.videoDevice(position: position) + } + + func takePhoto(delegate: CaptureOutputDelegate) { + delegate.assertIsOnSessionQueue() + + guard let photoOutput = photoOutput else { + owsFailDebug("photoOutput was unexpectedly nil") + return + } + + guard let photoVideoConnection = photoOutput.connection(with: .video) else { + owsFailDebug("photoVideoConnection was unexpectedly nil") + return + } + + let videoOrientation = delegate.captureOrientation + photoVideoConnection.videoOrientation = videoOrientation + Logger.verbose("videoOrientation: \(videoOrientation)") + + return imageOutput.takePhoto(delegate: delegate) + } + + // MARK: - Movie Output + + func beginVideo(delegate: CaptureOutputDelegate) { + delegate.assertIsOnSessionQueue() + guard let videoConnection = movieOutput.connection(with: .video) else { + owsFailDebug("movieOutputConnection was unexpectedly nil") + return + } + + let videoOrientation = delegate.captureOrientation + videoConnection.videoOrientation = videoOrientation + + let outputFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4") + movieOutput.startRecording(to: URL(fileURLWithPath: outputFilePath), recordingDelegate: delegate) + } + + func completeVideo(delegate: CaptureOutputDelegate) { + delegate.assertIsOnSessionQueue() + movieOutput.stopRecording() + } + + func cancelVideo(delegate: CaptureOutputDelegate) { + delegate.assertIsOnSessionQueue() + // There's currently no user-visible way to cancel, if so, we may need to do some cleanup here. + owsFailDebug("video was unexpectedly canceled.") + } +} + +@available(iOS 10.0, *) +class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { + + let photoOutput = AVCapturePhotoOutput() + var avOutput: AVCaptureOutput { + return photoOutput + } + + var flashMode: AVCaptureDevice.FlashMode = .off + + override init() { + photoOutput.isLivePhotoCaptureEnabled = false + photoOutput.isHighResolutionCaptureEnabled = true + } + + private var photoProcessors: [Int64: PhotoProcessor] = [:] + + func takePhoto(delegate: CaptureOutputDelegate) { + delegate.assertIsOnSessionQueue() + + let settings = buildCaptureSettings() + + let photoProcessor = PhotoProcessor(delegate: delegate, completion: { [weak self] in + self?.photoProcessors[settings.uniqueID] = nil + }) + photoProcessors[settings.uniqueID] = photoProcessor + photoOutput.capturePhoto(with: settings, delegate: photoProcessor) + } + + func videoDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? { + // use dual camera where available + return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) + } + + // MARK: - + + private func buildCaptureSettings() -> AVCapturePhotoSettings { + let photoSettings = AVCapturePhotoSettings() + photoSettings.flashMode = flashMode + + photoSettings.isAutoStillImageStabilizationEnabled = + photoOutput.isStillImageStabilizationSupported + + return photoSettings + } + + private class PhotoProcessor: NSObject, AVCapturePhotoCaptureDelegate { + weak var delegate: CaptureOutputDelegate? + let completion: () -> Void + + init(delegate: CaptureOutputDelegate, completion: @escaping () -> Void) { + self.delegate = delegate + self.completion = completion + } + + @available(iOS 11.0, *) + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + let data = photo.fileDataRepresentation()! + DispatchQueue.main.async { + self.delegate?.captureOutputDidFinishProcessing(photoData: data, error: error) + } + completion() + } + + // for legacy (iOS10) devices + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photoSampleBuffer: CMSampleBuffer?, previewPhoto previewPhotoSampleBuffer: CMSampleBuffer?, resolvedSettings: AVCaptureResolvedPhotoSettings, bracketSettings: AVCaptureBracketedStillImageSettings?, error: Error?) { + if #available(iOS 11, *) { + owsFailDebug("unexpectedly calling legacy method.") + } + + guard let photoSampleBuffer = photoSampleBuffer else { + owsFailDebug("sampleBuffer was unexpectedly nil") + return + } + + let data = AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation(photoSampleBuffer) + DispatchQueue.main.async { + self.delegate?.captureOutputDidFinishProcessing(photoData: data, error: error) + } + completion() + } + } +} + +class StillImageCaptureOutput: ImageCaptureOutput { + var flashMode: AVCaptureDevice.FlashMode = .off + + let stillImageOutput = AVCaptureStillImageOutput() + var avOutput: AVCaptureOutput { + return stillImageOutput + } + + init() { + stillImageOutput.isHighResolutionStillImageOutputEnabled = true + } + + // MARK: - + + func takePhoto(delegate: CaptureOutputDelegate) { + guard let videoConnection = stillImageOutput.connection(with: .video) else { + owsFailDebug("videoConnection was unexpectedly nil") + return + } + + stillImageOutput.captureStillImageAsynchronously(from: videoConnection) { [weak delegate] (sampleBuffer, error) in + guard let sampleBuffer = sampleBuffer else { + owsFailDebug("sampleBuffer was unexpectedly nil") + return + } + + let data = AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation(sampleBuffer) + DispatchQueue.main.async { + delegate?.captureOutputDidFinishProcessing(photoData: data, error: error) + } + } + } + + func videoDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? { + let captureDevices = AVCaptureDevice.devices() + guard let device = (captureDevices.first { $0.hasMediaType(.video) && $0.position == position }) else { + Logger.debug("unable to find desired position: \(position)") + return captureDevices.first + } + + return device + } +} + +extension AVCaptureVideoOrientation { + init?(deviceOrientation: UIDeviceOrientation) { + switch deviceOrientation { + case .portrait: self = .portrait + case .portraitUpsideDown: self = .portraitUpsideDown + case .landscapeLeft: self = .landscapeRight + case .landscapeRight: self = .landscapeLeft + default: return nil + } + } +} + +extension AVCaptureVideoOrientation: CustomStringConvertible { + public var description: String { + switch self { + case .portrait: + return "AVCaptureVideoOrientation.portrait" + case .portraitUpsideDown: + return "AVCaptureVideoOrientation.portraitUpsideDown" + case .landscapeRight: + return "AVCaptureVideoOrientation.landscapeRight" + case .landscapeLeft: + return "AVCaptureVideoOrientation.landscapeLeft" + } + } +} + +extension UIDeviceOrientation: CustomStringConvertible { + public var description: String { + switch self { + case .unknown: + return "UIDeviceOrientation.unknown" + case .portrait: + return "UIDeviceOrientation.portrait" + case .portraitUpsideDown: + return "UIDeviceOrientation.portraitUpsideDown" + case .landscapeLeft: + return "UIDeviceOrientation.landscapeLeft" + case .landscapeRight: + return "UIDeviceOrientation.landscapeRight" + case .faceUp: + return "UIDeviceOrientation.faceUp" + case .faceDown: + return "UIDeviceOrientation.faceDown" + } + } +} + +extension UIImageOrientation: CustomStringConvertible { + public var description: String { + switch self { + case .up: + return "UIImageOrientation.up" + case .down: + return "UIImageOrientation.down" + case .left: + return "UIImageOrientation.left" + case .right: + return "UIImageOrientation.right" + case .upMirrored: + return "UIImageOrientation.upMirrored" + case .downMirrored: + return "UIImageOrientation.downMirrored" + case .leftMirrored: + return "UIImageOrientation.leftMirrored" + case .rightMirrored: + return "UIImageOrientation.rightMirrored" + } + } +} diff --git a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift new file mode 100644 index 000000000..104df7d12 --- /dev/null +++ b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift @@ -0,0 +1,663 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation +import AVFoundation +import PromiseKit + +@objc(OWSPhotoCaptureViewControllerDelegate) +protocol PhotoCaptureViewControllerDelegate: AnyObject { + func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) + func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController) +} + +enum PhotoCaptureError: Error { + case assertionError(description: String) + case initializationFailed + case captureFailed +} + +extension PhotoCaptureError: LocalizedError { + var localizedDescription: String { + switch self { + case .initializationFailed: + return NSLocalizedString("PHOTO_CAPTURE_UNABLE_TO_INITIALIZE_CAMERA", comment: "alert title") + case .captureFailed: + return NSLocalizedString("PHOTO_CAPTURE_UNABLE_TO_CAPTURE_IMAGE", comment: "alert title") + case .assertionError: + return NSLocalizedString("PHOTO_CAPTURE_GENERIC_ERROR", comment: "alert title, generic error preventing user from capturing a photo") + } + } +} + +@objc(OWSPhotoCaptureViewController) +class PhotoCaptureViewController: OWSViewController { + + @objc + weak var delegate: PhotoCaptureViewControllerDelegate? + + private var photoCapture: PhotoCapture! + + deinit { + UIDevice.current.endGeneratingDeviceOrientationNotifications() + if let photoCapture = photoCapture { + photoCapture.stopCapture().done { + Logger.debug("stopCapture completed") + }.retainUntilComplete() + } + } + + // MARK: - Dependencies + + var audioActivity: AudioActivity? + var audioSession: OWSAudioSession { + return Environment.shared.audioSession + } + + // MARK: - Overrides + + override func loadView() { + self.view = UIView() + self.view.backgroundColor = Theme.darkThemeBackgroundColor + + let audioActivity = AudioActivity(audioDescription: "PhotoCaptureViewController", behavior: .playAndRecord) + self.audioActivity = audioActivity + if !self.audioSession.startAudioActivity(audioActivity) { + owsFailDebug("unexpectedly unable to start audio activity") + } + } + + override func viewDidLoad() { + super.viewDidLoad() + setupPhotoCapture() + setupOrientationMonitoring() + + updateNavigationItems() + updateFlashModeControl() + + let initialCaptureOrientation = AVCaptureVideoOrientation(deviceOrientation: UIDevice.current.orientation) ?? .portrait + updateIconOrientations(isAnimated: false, captureOrientation: initialCaptureOrientation) + + view.addGestureRecognizer(pinchZoomGesture) + view.addGestureRecognizer(focusGesture) + view.addGestureRecognizer(doubleTapToSwitchCameraGesture) + } + + override var prefersStatusBarHidden: Bool { + return true + } + + // MARK - + var isRecordingMovie: Bool = false + let recordingTimerView = RecordingTimerView() + + func updateNavigationItems() { + if isRecordingMovie { + navigationItem.leftBarButtonItem = nil + navigationItem.rightBarButtonItems = nil + navigationItem.titleView = recordingTimerView + recordingTimerView.sizeToFit() + } else { + navigationItem.titleView = nil + navigationItem.leftBarButtonItem = dismissControl.barButtonItem + let fixedSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + fixedSpace.width = 16 + + navigationItem.rightBarButtonItems = [flashModeControl.barButtonItem, fixedSpace, switchCameraControl.barButtonItem] + } + } + + // HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does. + // If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible + // the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder. + override public var canBecomeFirstResponder: Bool { + Logger.debug("") + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } + + // MARK: - Views + + let captureButton = CaptureButton() + var previewView: CapturePreviewView! + + class PhotoControl { + let button: OWSButton + let barButtonItem: UIBarButtonItem + + init(imageName: String, block: @escaping () -> Void) { + self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block) + if #available(iOS 10, *) { + button.autoPinToSquareAspectRatio() + } else { + button.sizeToFit() + } + + button.layer.shadowOffset = CGSize.zero + button.layer.shadowOpacity = 0.35 + button.layer.shadowRadius = 4 + + self.barButtonItem = UIBarButtonItem(customView: button) + } + + func setImage(imageName: String) { + button.setImage(imageName: imageName) + } + } + private lazy var dismissControl: PhotoControl = { + return PhotoControl(imageName: "ic_x_with_shadow") { [weak self] in + self?.didTapClose() + } + }() + + private lazy var switchCameraControl: PhotoControl = { + return PhotoControl(imageName: "ic_switch_camera") { [weak self] in + self?.didTapSwitchCamera() + } + }() + + private lazy var flashModeControl: PhotoControl = { + return PhotoControl(imageName: "ic_flash_mode_auto") { [weak self] in + self?.didTapFlashMode() + } + }() + + lazy var pinchZoomGesture: UIPinchGestureRecognizer = { + return UIPinchGestureRecognizer(target: self, action: #selector(didPinchZoom(pinchGesture:))) + }() + + lazy var focusGesture: UITapGestureRecognizer = { + return UITapGestureRecognizer(target: self, action: #selector(didTapFocusExpose(tapGesture:))) + }() + + lazy var doubleTapToSwitchCameraGesture: UITapGestureRecognizer = { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapToSwitchCamera(tapGesture:))) + tapGesture.numberOfTapsRequired = 2 + return tapGesture + }() + + // MARK: - Events + + @objc + func didTapClose() { + self.delegate?.photoCaptureViewControllerDidCancel(self) + } + + @objc + func didTapSwitchCamera() { + Logger.debug("") + switchCamera() + } + + @objc + func didDoubleTapToSwitchCamera(tapGesture: UITapGestureRecognizer) { + Logger.debug("") + switchCamera() + } + + private func switchCamera() { + UIView.animate(withDuration: 0.2) { + let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001 + self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation) + } + photoCapture.switchCamera().catch { error in + self.showFailureUI(error: error) + }.retainUntilComplete() + } + + @objc + func didTapFlashMode() { + Logger.debug("") + photoCapture.switchFlashMode().done { + self.updateFlashModeControl() + }.retainUntilComplete() + } + + @objc + func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) { + switch pinchGesture.state { + case .began: fallthrough + case .changed: + photoCapture.updateZoom(scaleFromPreviousZoomFactor: pinchGesture.scale) + case .ended: + photoCapture.completeZoom(scaleFromPreviousZoomFactor: pinchGesture.scale) + default: + break + } + } + + @objc + func didTapFocusExpose(tapGesture: UITapGestureRecognizer) { + let viewLocation = tapGesture.location(in: view) + let devicePoint = previewView.previewLayer.captureDevicePointConverted(fromLayerPoint: viewLocation) + photoCapture.focus(with: .autoFocus, exposureMode: .autoExpose, at: devicePoint, monitorSubjectAreaChange: true) + } + + // MARK: - Orientation + + private func setupOrientationMonitoring() { + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + + NotificationCenter.default.addObserver(self, + selector: #selector(didChangeDeviceOrientation), + name: .UIDeviceOrientationDidChange, + object: UIDevice.current) + } + + var lastKnownCaptureOrientation: AVCaptureVideoOrientation = .portrait + + @objc + func didChangeDeviceOrientation(notification: Notification) { + let currentOrientation = UIDevice.current.orientation + + if let captureOrientation = AVCaptureVideoOrientation(deviceOrientation: currentOrientation) { + // since the "face up" and "face down" orientations aren't reflected in the photo output, + // we need to capture the last known _other_ orientation so we can reflect the appropriate + // portrait/landscape in our captured photos. + Logger.verbose("lastKnownCaptureOrientation: \(lastKnownCaptureOrientation)->\(captureOrientation)") + lastKnownCaptureOrientation = captureOrientation + updateIconOrientations(isAnimated: true, captureOrientation: captureOrientation) + } + } + + // MARK: - + + private func updateIconOrientations(isAnimated: Bool, captureOrientation: AVCaptureVideoOrientation) { + Logger.verbose("captureOrientation: \(captureOrientation)") + + let transformFromOrientation: CGAffineTransform + switch captureOrientation { + case .portrait: + transformFromOrientation = .identity + case .portraitUpsideDown: + transformFromOrientation = CGAffineTransform(rotationAngle: .pi) + case .landscapeLeft: + transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi) + case .landscapeRight: + transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi) + } + + // Don't "unrotate" the switch camera icon if the front facing camera had been selected. + let tranformFromCameraType: CGAffineTransform = photoCapture.desiredPosition == .front ? CGAffineTransform(rotationAngle: -.pi) : .identity + + let updateOrientation = { + self.flashModeControl.button.transform = transformFromOrientation + self.switchCameraControl.button.transform = transformFromOrientation.concatenating(tranformFromCameraType) + } + + if isAnimated { + UIView.animate(withDuration: 0.3, animations: updateOrientation) + } else { + updateOrientation() + } + } + + private func setupPhotoCapture() { + photoCapture = PhotoCapture() + photoCapture.delegate = self + captureButton.delegate = photoCapture + previewView = CapturePreviewView(session: photoCapture.session) + + photoCapture.startCapture().done { [weak self] in + guard let self = self else { return } + + self.showCaptureUI() + }.catch { [weak self] error in + guard let self = self else { return } + + self.showFailureUI(error: error) + }.retainUntilComplete() + } + + private func showCaptureUI() { + Logger.debug("") + view.addSubview(previewView) + if UIDevice.current.hasIPhoneXNotch { + previewView.autoPinEdgesToSuperviewEdges() + } else { + previewView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 0, leading: 0, bottom: 40, trailing: 0)) + } + + view.addSubview(captureButton) + captureButton.autoHCenterInSuperview() + let captureButtonDiameter: CGFloat = 80 + captureButton.autoSetDimensions(to: CGSize(width: captureButtonDiameter, height: captureButtonDiameter)) + // on iPhoneX 12.1 + captureButton.autoPinEdge(toSuperviewMargin: .bottom, withInset: 10) + } + + private func showFailureUI(error: Error) { + Logger.error("error: \(error)") + + OWSAlerts.showAlert(title: nil, + message: error.localizedDescription, + buttonTitle: CommonStrings.dismissButton, + buttonAction: { [weak self] _ in self?.dismiss(animated: true) }) + } + + private func updateFlashModeControl() { + let imageName: String + switch photoCapture.flashMode { + case .auto: + imageName = "ic_flash_mode_auto" + case .on: + imageName = "ic_flash_mode_on" + case .off: + imageName = "ic_flash_mode_off" + } + + self.flashModeControl.setImage(imageName: imageName) + } +} + +extension PhotoCaptureViewController: PhotoCaptureDelegate { + + // MARK: - Photo + + func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) { + delegate?.photoCaptureViewController(self, didFinishProcessingAttachment: attachment) + } + + func photoCapture(_ photoCapture: PhotoCapture, processingDidError error: Error) { + showFailureUI(error: error) + } + + // MARK: - Video + + func photoCaptureDidBeginVideo(_ photoCapture: PhotoCapture) { + isRecordingMovie = true + updateNavigationItems() + recordingTimerView.startCounting() + } + + func photoCaptureDidCompleteVideo(_ photoCapture: PhotoCapture) { + // Stop counting, but keep visible + recordingTimerView.stopCounting() + } + + func photoCaptureDidCancelVideo(_ photoCapture: PhotoCapture) { + owsFailDebug("If we ever allow this, we should test.") + isRecordingMovie = false + recordingTimerView.stopCounting() + updateNavigationItems() + } + + // MARK: - + + var zoomScaleReferenceHeight: CGFloat? { + return view.bounds.height + } + + var captureOrientation: AVCaptureVideoOrientation { + return lastKnownCaptureOrientation + } +} + +// MARK: - Views + +protocol CaptureButtonDelegate: AnyObject { + // MARK: Photo + func didTapCaptureButton(_ captureButton: CaptureButton) + + // MARK: Video + func didBeginLongPressCaptureButton(_ captureButton: CaptureButton) + func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) + func didCancelLongPressCaptureButton(_ captureButton: CaptureButton) + + var zoomScaleReferenceHeight: CGFloat? { get } + func longPressCaptureButton(_ captureButton: CaptureButton, didUpdateZoomAlpha zoomAlpha: CGFloat) +} + +class CaptureButton: UIView { + + let innerButton = CircleView() + + var tapGesture: UITapGestureRecognizer! + + var longPressGesture: UILongPressGestureRecognizer! + let longPressDuration = 0.5 + + let zoomIndicator = CircleView() + + weak var delegate: CaptureButtonDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + + tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + innerButton.addGestureRecognizer(tapGesture) + + longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) + longPressGesture.minimumPressDuration = longPressDuration + innerButton.addGestureRecognizer(longPressGesture) + + addSubview(innerButton) + innerButton.backgroundColor = UIColor.ows_white.withAlphaComponent(0.33) + innerButton.layer.shadowOffset = .zero + innerButton.layer.shadowOpacity = 0.33 + innerButton.layer.shadowRadius = 2 + innerButton.autoPinEdgesToSuperviewEdges() + + zoomIndicator.isUserInteractionEnabled = false + addSubview(zoomIndicator) + zoomIndicator.layer.borderColor = UIColor.ows_white.cgColor + zoomIndicator.layer.borderWidth = 1.5 + zoomIndicator.autoPin(toEdgesOf: innerButton) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Gestures + + @objc + func didTap(_ gesture: UITapGestureRecognizer) { + delegate?.didTapCaptureButton(self) + } + + var initialTouchLocation: CGPoint? + + @objc + func didLongPress(_ gesture: UILongPressGestureRecognizer) { + Logger.verbose("") + + guard let gestureView = gesture.view else { + owsFailDebug("gestureView was unexpectedly nil") + return + } + + switch gesture.state { + case .possible: break + case .began: + initialTouchLocation = gesture.location(in: gesture.view) + zoomIndicator.transform = .identity + delegate?.didBeginLongPressCaptureButton(self) + case .changed: + guard let referenceHeight = delegate?.zoomScaleReferenceHeight else { + owsFailDebug("referenceHeight was unexpectedly nil") + return + } + + guard referenceHeight > 0 else { + owsFailDebug("referenceHeight was unexpectedly <= 0") + return + } + + guard let initialTouchLocation = initialTouchLocation else { + owsFailDebug("initialTouchLocation was unexpectedly nil") + return + } + + let currentLocation = gesture.location(in: gestureView) + let minDistanceBeforeActivatingZoom: CGFloat = 30 + let distance = initialTouchLocation.y - currentLocation.y - minDistanceBeforeActivatingZoom + let distanceForFullZoom = referenceHeight / 4 + let ratio = distance / distanceForFullZoom + + let alpha = ratio.clamp(0, 1) + + Logger.verbose("distance: \(distance), alpha: \(alpha)") + + let transformScale = CGFloatLerp(1, 0.1, alpha) + zoomIndicator.transform = CGAffineTransform(scaleX: transformScale, y: transformScale) + zoomIndicator.superview?.layoutIfNeeded() + + delegate?.longPressCaptureButton(self, didUpdateZoomAlpha: alpha) + case .ended: + zoomIndicator.transform = .identity + delegate?.didCompleteLongPressCaptureButton(self) + case .cancelled, .failed: + zoomIndicator.transform = .identity + delegate?.didCancelLongPressCaptureButton(self) + } + } +} + +class CapturePreviewView: UIView { + + let previewLayer: AVCaptureVideoPreviewLayer + + override var bounds: CGRect { + didSet { + previewLayer.frame = bounds + } + } + + init(session: AVCaptureSession) { + previewLayer = AVCaptureVideoPreviewLayer(session: session) + super.init(frame: .zero) + self.contentMode = .scaleAspectFill + previewLayer.frame = bounds + layer.addSublayer(previewLayer) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class RecordingTimerView: UIView { + + let stackViewSpacing: CGFloat = 4 + + override init(frame: CGRect) { + super.init(frame: frame) + + let stackView = UIStackView(arrangedSubviews: [icon, label]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = stackViewSpacing + + addSubview(stackView) + stackView.autoPinEdgesToSuperviewMargins() + + updateView() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Subviews + + private lazy var label: UILabel = { + let label = UILabel() + label.font = UIFont.ows_monospacedDigitFont(withSize: 20) + label.textAlignment = .center + label.textColor = UIColor.white + label.layer.shadowOffset = CGSize.zero + label.layer.shadowOpacity = 0.35 + label.layer.shadowRadius = 4 + + return label + }() + + static let iconWidth: CGFloat = 6 + + private let icon: UIView = { + let icon = CircleView() + icon.layer.shadowOffset = CGSize.zero + icon.layer.shadowOpacity = 0.35 + icon.layer.shadowRadius = 4 + + icon.backgroundColor = .red + icon.autoSetDimensions(to: CGSize(width: iconWidth, height: iconWidth)) + icon.alpha = 0 + + return icon + }() + + // MARK: - Overrides // + + override func sizeThatFits(_ size: CGSize) -> CGSize { + if #available(iOS 10, *) { + return super.sizeThatFits(size) + } else { + // iOS9 manual layout sizing required for items in the navigation bar + var baseSize = label.frame.size + baseSize.width = baseSize.width + stackViewSpacing + RecordingTimerView.iconWidth + layoutMargins.left + layoutMargins.right + baseSize.height = baseSize.height + layoutMargins.top + layoutMargins.bottom + return baseSize + } + } + + // MARK: - + var recordingStartTime: TimeInterval? + + func startCounting() { + recordingStartTime = CACurrentMediaTime() + timer = Timer.weakScheduledTimer(withTimeInterval: 0.1, target: self, selector: #selector(updateView), userInfo: nil, repeats: true) + UIView.animate(withDuration: 0.5, + delay: 0, + options: [.autoreverse, .repeat], + animations: { self.icon.alpha = 1 }) + } + + func stopCounting() { + timer?.invalidate() + timer = nil + icon.layer.removeAllAnimations() + UIView.animate(withDuration: 0.4) { + self.icon.alpha = 0 + } + } + + // MARK: - + + private var timer: Timer? + + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "mm:ss" + formatter.timeZone = TimeZone(identifier: "UTC")! + + return formatter + }() + + // This method should only be called when the call state is "connected". + var recordingDuration: TimeInterval { + guard let recordingStartTime = recordingStartTime else { + return 0 + } + + return CACurrentMediaTime() - recordingStartTime + } + + @objc + private func updateView() { + let recordingDuration = self.recordingDuration + Logger.verbose("recordingDuration: \(recordingDuration)") + let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration) + label.text = timeFormatter.string(from: durationDate) + if #available(iOS 10, *) { + // do nothing + } else { + label.sizeToFit() + } + } +} diff --git a/Signal/src/ViewControllers/PhotoLibrary/PhotoCollectionPickerController.swift b/Signal/src/ViewControllers/Photos/PhotoCollectionPickerController.swift similarity index 98% rename from Signal/src/ViewControllers/PhotoLibrary/PhotoCollectionPickerController.swift rename to Signal/src/ViewControllers/Photos/PhotoCollectionPickerController.swift index c0b79e506..de5b6d2d3 100644 --- a/Signal/src/ViewControllers/PhotoLibrary/PhotoCollectionPickerController.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCollectionPickerController.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation diff --git a/Signal/src/ViewControllers/PhotoLibrary/PhotoLibrary.swift b/Signal/src/ViewControllers/Photos/PhotoLibrary.swift similarity index 100% rename from Signal/src/ViewControllers/PhotoLibrary/PhotoLibrary.swift rename to Signal/src/ViewControllers/Photos/PhotoLibrary.swift diff --git a/SignalMessaging/Views/OWSButton.swift b/SignalMessaging/Views/OWSButton.swift index 7985be1dd..e9fc08787 100644 --- a/SignalMessaging/Views/OWSButton.swift +++ b/SignalMessaging/Views/OWSButton.swift @@ -13,7 +13,7 @@ public class OWSButton: UIButton { // MARK: - @objc - init(block: @escaping () -> Void = { }) { + public init(block: @escaping () -> Void = { }) { super.init(frame: .zero) self.block = block @@ -21,7 +21,7 @@ public class OWSButton: UIButton { } @objc - init(title: String, block: @escaping () -> Void = { }) { + public init(title: String, block: @escaping () -> Void = { }) { super.init(frame: .zero) self.block = block @@ -30,8 +30,8 @@ public class OWSButton: UIButton { } @objc - init(imageName: String, - tintColor: UIColor, + public init(imageName: String, + tintColor: UIColor?, block: @escaping () -> Void = { }) { super.init(frame: .zero) diff --git a/SignalMessaging/categories/UIFont+OWS.h b/SignalMessaging/categories/UIFont+OWS.h index 88da5beb9..2e4dc57b1 100644 --- a/SignalMessaging/categories/UIFont+OWS.h +++ b/SignalMessaging/categories/UIFont+OWS.h @@ -18,6 +18,8 @@ NS_ASSUME_NONNULL_BEGIN + (UIFont *)ows_boldFontWithSize:(CGFloat)size; ++ (UIFont *)ows_monospacedDigitFontWithSize:(CGFloat)size; + #pragma mark - Icon Fonts + (UIFont *)ows_fontAwesomeFont:(CGFloat)size; diff --git a/SignalMessaging/categories/UIFont+OWS.m b/SignalMessaging/categories/UIFont+OWS.m index 28e26021d..8f3675773 100644 --- a/SignalMessaging/categories/UIFont+OWS.m +++ b/SignalMessaging/categories/UIFont+OWS.m @@ -34,6 +34,11 @@ NS_ASSUME_NONNULL_BEGIN return [UIFont boldSystemFontOfSize:size]; } ++ (UIFont *)ows_monospacedDigitFontWithSize:(CGFloat)size; +{ + return [self monospacedDigitSystemFontOfSize:size weight:UIFontWeightRegular]; +} + #pragma mark - Icon Fonts + (UIFont *)ows_fontAwesomeFont:(CGFloat)size diff --git a/SignalServiceKit/src/Util/FeatureFlags.swift b/SignalServiceKit/src/Util/FeatureFlags.swift index 539085932..afe797338 100644 --- a/SignalServiceKit/src/Util/FeatureFlags.swift +++ b/SignalServiceKit/src/Util/FeatureFlags.swift @@ -19,4 +19,9 @@ public class FeatureFlags: NSObject { public static var sendingMediaWithOversizeText: Bool { return false } + + @objc + public static var useCustomPhotoCapture: Bool { + return true + } }