diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index ef953fe5..07c421b7 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ 305A76E22FCA8C7000227D26 /* GroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76252FCA8C7000227D26 /* GroupViewModel.swift */; }; 305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76292FCA8C7000227D26 /* HomeViewController.swift */; }; 305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762B2FCA8C7000227D26 /* LaunchViewController.swift */; }; + 55B2A1013022F0A000784901 /* LaunchPrivacyConsentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B2A1003022F0A000784900 /* LaunchPrivacyConsentView.swift */; }; 305A76E72FCA8C7000227D26 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762D2FCA8C7000227D26 /* LoginView.swift */; }; 305A76E82FCA8C7000227D26 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762E2FCA8C7000227D26 /* LoginViewController.swift */; }; 305A76E92FCA8C7000227D26 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A762F2FCA8C7000227D26 /* LoginViewModel.swift */; }; @@ -288,15 +289,10 @@ 55B217A33022F0B100784706 /* UIDevice+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217A33022F0B100784707 /* UIDevice+Extension.swift */; }; 55B217B13022F0A000784711 /* GroupItineraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217B03022F0A000784710 /* GroupItineraryView.swift */; }; 55B217C13022F0A000784721 /* MinePhotoWallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C03022F0A000784720 /* MinePhotoWallView.swift */; }; - 55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */; }; 55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.swift */; }; - 55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D03022F0A000784726 /* UnlockCountManager.swift */; }; 55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D13023010000784730 /* TodayTrackDetailVC.swift */; }; 55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D33023010000784732 /* TodayTrackDetailView.swift */; }; 55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */; }; - 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D73023010000784736 /* PhoneReportDetailVC.swift */; }; - 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D93023010000784738 /* PhoneReportDetailView.swift */; }; - 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */; }; 55B218A13024A00100784702 /* AboutVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784701 /* AboutVC.swift */; }; 55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.swift */; }; 55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784705 /* CancelAccountVC.swift */; }; @@ -530,6 +526,7 @@ 305A76252FCA8C7000227D26 /* GroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViewModel.swift; sourceTree = ""; }; 305A76292FCA8C7000227D26 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; 305A762B2FCA8C7000227D26 /* LaunchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchViewController.swift; sourceTree = ""; }; + 55B2A1003022F0A000784900 /* LaunchPrivacyConsentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchPrivacyConsentView.swift; sourceTree = ""; }; 305A762D2FCA8C7000227D26 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 305A762E2FCA8C7000227D26 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; 305A762F2FCA8C7000227D26 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; @@ -724,15 +721,10 @@ 55B217A33022F0B100784707 /* UIDevice+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extension.swift"; sourceTree = ""; }; 55B217B03022F0A000784710 /* GroupItineraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupItineraryView.swift; sourceTree = ""; }; 55B217C03022F0A000784720 /* MinePhotoWallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinePhotoWallView.swift; sourceTree = ""; }; - 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberPhoneReportView.swift; sourceTree = ""; }; 55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = ""; }; - 55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = ""; }; 55B217D13023010000784730 /* TodayTrackDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailVC.swift; sourceTree = ""; }; 55B217D33023010000784732 /* TodayTrackDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailView.swift; sourceTree = ""; }; 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailViewModel.swift; sourceTree = ""; }; - 55B217D73023010000784736 /* PhoneReportDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailVC.swift; sourceTree = ""; }; - 55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = ""; }; - 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = ""; }; 55B218A13024A00100784701 /* AboutVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutVC.swift; sourceTree = ""; }; 55B218A13024A00100784703 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 55B218A13024A00100784705 /* CancelAccountVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountVC.swift; sourceTree = ""; }; @@ -821,29 +813,21 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 30CCDF8E2FE3E63B00F5214A /* sound */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = sound; sourceTree = ""; }; 30CCDF902FE3E63B00F5214A /* video */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = video; sourceTree = ""; }; 30CCE01E2FE3E64700F5214A /* lotties */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = lotties; sourceTree = ""; }; 30D87CEF2FDFF52100E958FD /* TTGTagCollectionView */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = TTGTagCollectionView; sourceTree = ""; }; @@ -1193,7 +1177,6 @@ 305A75262FCA8C7000227D26 /* Money */, 305A75282FCA8C7000227D26 /* Notification */, 305A752A2FCA8C7000227D26 /* Theme */, - 55B217D23022F0A000784728 /* Unlock */, 305A752C2FCA8C7000227D26 /* URL */, 30D87CD52FDF9F1900E958FD /* MQTT */, 2F518AB6587CC227CC515D8C /* AppRestrict */, @@ -1297,10 +1280,8 @@ 55B250013050000100784901 /* LockedAppPopView.swift */, 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */, 55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */, - 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */, 55B217C43022F0A000784724 /* MemberAppUsageView.swift */, 55B217DD302301000078473C /* TodayTrackDetail */, - 55B217DE302301000078473D /* PhoneReportDetail */, 55B240063040000100784906 /* AppUsageReport */, 30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */, 30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */, @@ -1318,6 +1299,7 @@ isa = PBXGroup; children = ( 305A762B2FCA8C7000227D26 /* LaunchViewController.swift */, + 55B2A1003022F0A000784900 /* LaunchPrivacyConsentView.swift */, ); path = Launch; sourceTree = ""; @@ -1967,14 +1949,6 @@ path = Font; sourceTree = ""; }; - 55B217D23022F0A000784728 /* Unlock */ = { - isa = PBXGroup; - children = ( - 55B217D03022F0A000784726 /* UnlockCountManager.swift */, - ); - path = Unlock; - sourceTree = ""; - }; 55B217DD302301000078473C /* TodayTrackDetail */ = { isa = PBXGroup; children = ( @@ -1985,16 +1959,6 @@ path = TodayTrackDetail; sourceTree = ""; }; - 55B217DE302301000078473D /* PhoneReportDetail */ = { - isa = PBXGroup; - children = ( - 55B217D73023010000784736 /* PhoneReportDetailVC.swift */, - 55B217D93023010000784738 /* PhoneReportDetailView.swift */, - 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */, - ); - path = PhoneReportDetail; - sourceTree = ""; - }; 55B218A13024A00100784710 /* About */ = { isa = PBXGroup; children = ( @@ -2129,7 +2093,6 @@ 51E818049FA5A8557C434E37 /* ImageCropView.swift */, C39ADD47D3C81F01F6289AC4 /* ImageCropVC.swift */, ); - name = ImageCrop; path = ImageCrop; sourceTree = ""; }; @@ -2509,14 +2472,10 @@ 305A76C22FCA8C7000227D26 /* BaseViewModel.swift in Sources */, 55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */, 55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */, - 55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */, 55C1A0213034102000786021 /* PrivilegeDeniedOverlay.swift in Sources */, 55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */, 55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */, 55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */, - 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */, - 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */, - 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */, 55B240013040000100784901 /* AppUsageReportVC.swift in Sources */, 55B240033040000100784903 /* AppUsageReportView.swift in Sources */, 55B240053040000100784905 /* AppUsageReportViewModel.swift in Sources */, @@ -2569,7 +2528,6 @@ 305A76D02FCA8C7000227D26 /* MoneyFormatter.swift in Sources */, 305A76D12FCA8C7000227D26 /* TimeSpecificNotificationManager.swift in Sources */, 305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */, - 55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */, 305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */, 305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */, 55B230073032000100785008 /* PigeonModel.swift in Sources */, @@ -2615,6 +2573,7 @@ 30BAB84F2FCD2FED00C33B5C /* InviteJoinVC.swift in Sources */, 305A76E52FCA8C7000227D26 /* HomeViewController.swift in Sources */, 305A76E62FCA8C7000227D26 /* LaunchViewController.swift in Sources */, + 55B2A1013022F0A000784901 /* LaunchPrivacyConsentView.swift in Sources */, 3062E8B52FCE6BBA00CEF511 /* ScanVC.swift in Sources */, 30A1C0A12FEB000100CA0001 /* CameraCaptureView.swift in Sources */, 30A1C0A32FEB000100CA0001 /* CameraCaptureVC.swift in Sources */, @@ -2794,9 +2753,12 @@ buildSettings = { CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = DeviceActivityMonitorExtension/DeviceActivityMonitorExtension.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = LRDLWZ2Y83; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = DeviceActivityMonitorExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -2808,6 +2770,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.DeviceActivityMonitor; PRODUCT_NAME = DeviceActivityMonitorExtension; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "jsdw DeviceActivityMonitor AppStore"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; @@ -2852,11 +2816,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 5; + DEVELOPMENT_TEAM = LRDLWZ2Y83; ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = QuickLocation/Info.plist; @@ -2883,7 +2845,6 @@ PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_dev; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -2907,9 +2868,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)"; @@ -2938,7 +2899,7 @@ PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_dev; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_AppStore; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -3142,7 +3103,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)"; @@ -3285,9 +3246,12 @@ buildSettings = { CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShieldConfigurationExtension/ShieldConfigurationExtension.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = LRDLWZ2Y83; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShieldConfigurationExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -3299,6 +3263,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.ShieldConfiguration; PRODUCT_NAME = ShieldConfigurationExtension; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "jsdw ShieldConfiguration AppStore"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; diff --git a/QuickLocation/API/APIProvider.swift b/QuickLocation/API/APIProvider.swift index 9d0996b2..7a71ed6d 100644 --- a/QuickLocation/API/APIProvider.swift +++ b/QuickLocation/API/APIProvider.swift @@ -148,8 +148,6 @@ enum GatewayStatusCode: Int { case userLoginExpair3 = 1001005 // 权限不足 case noAuthority = 500 - // 审核中 - case review = 201 /** ============== 未知错误 ============== */ case unknownError = -9999 diff --git a/QuickLocation/API/UserAPI.swift b/QuickLocation/API/UserAPI.swift index b322e633..2b698e88 100644 --- a/QuickLocation/API/UserAPI.swift +++ b/QuickLocation/API/UserAPI.swift @@ -31,9 +31,6 @@ enum UserAPI { /// 成员 APP 周期使用报告 case phoneUsagePeriod(userId: String, groupKey: String, period: String) - /// 成员手机使用报告 - case phoneUsageReport(userId: String, groupKey: String) - /// 成员历史行程 case phoneUsageTrips(userId: String, date: Int64) @@ -164,8 +161,6 @@ extension UserAPI: MultiTargetProtocol { return "mapi/phone/usage/today" case .phoneUsagePeriod: return "mapi/phone/usage/period" - case .phoneUsageReport: - return "mapi/phone/usage/report" case .phoneUsageTrips: return "mapi/phone/usage/trips" case .queryPhoneUsageIcon, .savePhoneUsageIcon: @@ -229,7 +224,7 @@ extension UserAPI: MultiTargetProtocol { var method: Moya.Method { switch self { - case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageReport, .phoneUsageTrips, .queryPhoneUsageIcon, .phoneLockApps, .phoneLocked, .phoneUnlockRequests, .phoneUnlockAllow, .signInInfo, .notice, .followList, .relations: + case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageTrips, .queryPhoneUsageIcon, .phoneLockApps, .phoneLocked, .phoneUnlockRequests, .phoneUnlockAllow, .signInInfo, .notice, .followList, .relations: return .get case .changePhone, .changeAvater: return .put @@ -270,13 +265,6 @@ extension UserAPI: MultiTargetProtocol { ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) - case let .phoneUsageReport(userId, groupKey): - let params: Parameters = [ - "user_id": userId, - "group_key": groupKey - ] - return .requestParameters(parameters: params, encoding: URLEncoding.queryString) - case let .phoneUsageTrips(userId, date): let params: Parameters = [ "user_id": userId, diff --git a/QuickLocation/AppDelegate.swift b/QuickLocation/AppDelegate.swift index 5537a9d8..d2b53627 100644 --- a/QuickLocation/AppDelegate.swift +++ b/QuickLocation/AppDelegate.swift @@ -18,12 +18,11 @@ import SwiftDate class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + private var didSetupPrivacyGrantedSDKs = false func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - UnlockCountManager.shared.start(launchOptions: launchOptions) FontManager.registerCustomFonts() - setupLocation() ApiManager.shared.setup() AppRouter.shared.setup() AppUnlockCoordinator.shared.start() @@ -42,17 +41,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate { IQKeyboardManager.shared.enableAutoToolbar = false IQKeyboardManager.shared.resignOnTouchOutside = true - // 微信 WXApi.registerApp(AppSettings.kAppsWXApiAppId, universalLink: AppSettings.kAppsUniversalLink) + window = UIWindow(frame: UIScreen.main.bounds) + window?.backgroundColor = .white + window?.overrideUserInterfaceStyle = .light + window?.rootViewController = LaunchViewController() + window?.makeKeyAndVisible() + + return true + } + + /// 用户同意隐私协议后再初始化会采集数据的 SDK。 + func setupPrivacyGrantedSDKs() { + guard !didSetupPrivacyGrantedSDKs else { return } + didSetupPrivacyGrantedSDKs = true - // 高德地图 #if !targetEnvironment(simulator) AMapServices.shared().apiKey = AppSettings.kAppsAMapAppId - #endif - // 个推 - #if !targetEnvironment(simulator) GeYanSdk.setPreLoginTimeout(10) GeYanSdk.setEloginTimeout(10) GeYanSdk.start(withAppId: AppSettings.kAppsGeTuiAppId) { isSuccess, error, gtcid in @@ -63,19 +70,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } #endif - // OpenIM GroupIMService.shared.initSDK() - - // 友盟 UMConfigure.initWithAppkey(AppSettings.kAppsUMengAppKey, channel: "App Store") - - window = UIWindow(frame: UIScreen.main.bounds) - window?.backgroundColor = .white - window?.overrideUserInterfaceStyle = .light - window?.rootViewController = LaunchViewController() - window?.makeKeyAndVisible() - - return true } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { @@ -98,15 +94,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { print("📱 continue userActivity: \(userActivity.webpageURL?.absoluteString ?? "")") return WXApi.handleOpenUniversalLink(userActivity, delegate: self) } - - func setupLocation() { - let manager = AuthorizeManager.manager(type: .locationWhenInUse) - if manager?.authorizeStatus() != .authorized { - manager?.authorize(nil) - Permission.openAppSetting(title: "获取位置失败", - message: "请在iPhone的“设置-隐私-定位”选项中允许\(kAppName)访问你的位置。") - } - } } extension AppDelegate { diff --git a/QuickLocation/Common/Constant.swift b/QuickLocation/Common/Constant.swift index df9ddddb..5151644b 100644 --- a/QuickLocation/Common/Constant.swift +++ b/QuickLocation/Common/Constant.swift @@ -21,30 +21,10 @@ extension DefaultsKeys { var isReLogin: DefaultsKey { .init("isReLogin", defaultValue: false) } /// 显示引导页 var isShowGuide: DefaultsKey { .init("isShowGuide", defaultValue: true) } + /// 启动页已同意服务协议及隐私政策 + var didAgreeLaunchPrivacy: DefaultsKey { .init("didAgreeLaunchPrivacy", defaultValue: false) } /// 气泡持续时间 var bubbleKeepTimes: DefaultsKey { .init("bubbleKeepTimes") } - /// 解锁统计:今日次数 - var unlockCountToday: DefaultsKey { .init("unlockCountToday", defaultValue: 0) } - /// 解锁统计:累计次数 - var unlockCountTotal: DefaultsKey { .init("unlockCountTotal", defaultValue: 0) } - /// 解锁统计:日期 yyyy-MM-dd - var unlockCountDate: DefaultsKey { .init("unlockCountDate", defaultValue: "") } - /// 解锁统计:按自然日累计次数,key = yyyy-MM-dd - var unlockCountByDay: DefaultsKey<[String: Int]> { .init("unlockCountByDay", defaultValue: [:]) } - /// 上次锁屏态(杀进程补计) - var unlockWasLocked: DefaultsKey { .init("unlockWasLocked", defaultValue: false) } - var unlockLastCountedAt: DefaultsKey { .init("unlockLastCountedAt") } - var unlockLastHeartbeatAt: DefaultsKey { .init("unlockLastHeartbeatAt") } - var unlockLastLatitude: DefaultsKey { .init("unlockLastLatitude") } - var unlockLastLongitude: DefaultsKey { .init("unlockLastLongitude") } - /// 手机报告:按自然日累计屏幕时长(秒),key = yyyy-MM-dd - var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) } - /// 手机报告:当前进程屏幕时长 session 起点(仅用于杀进程后结算到最后心跳,不跨进程续计) - var screenSessionStartAt: DefaultsKey { .init("screenSessionStartAt") } - /// 手机报告:最后一次确认亮屏的时间(锁屏结算用,避免用 Date() 把灭屏空档算进去) - var screenLastOnAt: DefaultsKey { .init("screenLastOnAt") } - /// 手机报告:本 App 今日进入前台次数 - var appUsageToday: DefaultsKey { .init("appUsageToday", defaultValue: 0) } /// 屏幕使用时间权限曾开启的账号 var appRestrictApprovedUserIds: DefaultsKey<[String]> { .init("appRestrictApprovedUserIds", defaultValue: []) } /// 等待执行服务端配对全删的账号 @@ -73,8 +53,6 @@ extension Notification.Name { static let resumePopupQueue = Notification.Name("resumePopupQueue") /// 终止弹窗队列(登录成功后) static let invalidatePopupQueue = Notification.Name("invalidatePopupQueue") - /// 本机解锁次数变化 - static let unlockCountDidChange = Notification.Name("unlockCountDidChange") /// 本机应用配对数据变化 static let appRestrictPairingDataDidChange = Notification.Name("appRestrictPairingDataDidChange") /// MQTT 应用锁定状态变化 diff --git a/QuickLocation/Info.plist b/QuickLocation/Info.plist index e4881142..923da73a 100644 --- a/QuickLocation/Info.plist +++ b/QuickLocation/Info.plist @@ -37,13 +37,6 @@ NSIncludesSubdomains - 38.207.176.65 - - NSExceptionAllowsInsecureHTTPLoads - - NSIncludesSubdomains - - imapi.zuom8.cn NSExceptionAllowsInsecureHTTPLoads diff --git a/QuickLocation/Main/BaseViewController/BaseViewController.swift b/QuickLocation/Main/BaseViewController/BaseViewController.swift index 07676362..3f12989d 100644 --- a/QuickLocation/Main/BaseViewController/BaseViewController.swift +++ b/QuickLocation/Main/BaseViewController/BaseViewController.swift @@ -186,16 +186,16 @@ extension BaseViewController { // MARK: - FLEX extension BaseViewController { - private func setupFlex() { #if DEBUG + private func setupFlex() { FLEXManager.shared.showExplorer() -#endif } override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { print("摇晃结束") setupFlex() } +#endif } // MAKR: - UIView查看大图 diff --git a/QuickLocation/Manager/Account/UserConfigResponse.swift b/QuickLocation/Manager/Account/UserConfigResponse.swift index c38ae291..4162926b 100644 --- a/QuickLocation/Manager/Account/UserConfigResponse.swift +++ b/QuickLocation/Manager/Account/UserConfigResponse.swift @@ -201,52 +201,6 @@ struct PhoneUsagePeriodModel: Mappable { } } -struct PhoneUsageReportResponse: BaseModelProtocol { - var code: String? - var message: String? - var model: PhoneUsageReportModel? - - init?(map: Map) {} - - mutating func mapping(map: Map) { - code <- (map["code"], kIntTransformStr) - message <- map["message"] - model <- map["data"] - } -} - -struct PhoneUsageReportModel: Mappable { - var phoneInfo: PhoneUsageInfoModel? - var brand: String = "" - var brandIcon: String = "" - var screenUseTimes: [PhoneUsageReportDayModel] = [] - var appUseCounts: [PhoneUsageReportDayModel] = [] - - init?(map: Map) {} - - mutating func mapping(map: Map) { - phoneInfo <- map["phone_info"] - brand <- (map["brand"], kIntTransformStr) - brandIcon <- (map["brand_icon"], kIntTransformStr) - screenUseTimes <- map["screen_use_times"] - appUseCounts <- map["app_use_counts"] - } -} - -struct PhoneUsageReportDayModel: Mappable { - var day: String = "" - var useTime: Int? - var count: Int? - - init?(map: Map) {} - - mutating func mapping(map: Map) { - day <- (map["day"], kIntTransformStr) - useTime <- (map["use_time"], kStrTransformInt) - count <- (map["count"], kStrTransformInt) - } -} - struct PhoneUsageInfoModel: Mappable { var brand: String = "" var model: String = "" diff --git a/QuickLocation/Manager/App/AppSettings.swift b/QuickLocation/Manager/App/AppSettings.swift index 210bc706..194ac739 100644 --- a/QuickLocation/Manager/App/AppSettings.swift +++ b/QuickLocation/Manager/App/AppSettings.swift @@ -29,7 +29,7 @@ class AppSettings: NSObject { }() /// AppStore ID - var appId: String { "" } + var appId: String { "6790610409" } /// UUID var uuid: String = "" diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 24ea7e38..0ea21acb 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -75,8 +75,6 @@ enum Route: String { case itineraryTrace = "itineraryTrace" /// 今日轨迹详情 case todayTrackDetail = "todayTrackDetail" - /// 手机报告详情 - case phoneReportDetail = "phoneReportDetail" /// APP 使用报告 case appUsageReport = "appUsageReport" /// 关于我们 @@ -378,11 +376,6 @@ extension AppRouter: AppRouterProtocol { TodayTrackDetailVC(userInfo: parameters) } - // MARK: - 手机报告详情 - AppRouter.register(Route.phoneReportDetail) { url, parameters in - PhoneReportDetailVC(userInfo: parameters) - } - // MARK: - APP 使用报告 AppRouter.register(Route.appUsageReport) { _, parameters in AppUsageReportVC(userInfo: parameters) diff --git a/QuickLocation/Manager/MQTT/MQTTService.swift b/QuickLocation/Manager/MQTT/MQTTService.swift index b47ddcd6..2fd628f0 100644 --- a/QuickLocation/Manager/MQTT/MQTTService.swift +++ b/QuickLocation/Manager/MQTT/MQTTService.swift @@ -29,8 +29,6 @@ enum MqttType: String, Codable { case bubbleQuit = "bubblequit" // 退出气泡 case needTrack = "needtrack" // 追踪上报 case emote = "emote" // 接收表情 - case phone = "phone" // 手机信息上报 - case phoneUsage = "phoneUsage" // 当前 App 使用次数上报 case lockApp = "lockApp" // 远程锁 App case appUnlock = "appUnlock" // 远程解锁 App case lockAppsChanged = "lockAppsChanged" // App 锁状态已同步 @@ -629,76 +627,6 @@ final class AppUnlockCoordinator { } } -/// 手机信息上报数据 -private struct MqttPhoneInfo: Codable { - let brand: String - let model: String - let memory_total: String - let memory_used: String - let battery: String - let network: String - let brightness: String - let volume: String - let weather: String - let use_time: String - let unlock_count: String -} - -private struct MqttPhoneReportData: Codable { - let user_id: String - let group_key: String - let phone: MqttPhoneInfo -} - -private struct MqttPhoneReportPayload: Codable { - let type: String - let data: MqttPhoneReportData - let extra: String -} - -private struct MqttPhoneUsageExtra: Codable { - let count: Int -} - -private struct MqttPhoneUsageItem: Codable { - let day: String - let appName: String - let packageName: String - let usageTime: Int - let extra: MqttPhoneUsageExtra - - enum CodingKeys: String, CodingKey { - case day - case appName = "app_name" - case packageName = "package" - case usageTime = "usage_time" - case extra - } -} - -private struct MqttPhoneUsageData: Codable { - let userId: String - let groupKey: String - let phoneUsage: [MqttPhoneUsageItem] - - enum CodingKeys: String, CodingKey { - case userId = "user_id" - case groupKey = "group_key" - case phoneUsage = "phone_usage" - } -} - -private struct MqttPhoneUsagePayload: Codable { - let type: String - let data: MqttPhoneUsageData - let extra: String -} - -private struct MqttPhoneUsageSnapshot: Equatable { - let day: String - let count: Int -} - // MARK: - MQTTService /// MQTT 5.0 服务,管理连接、订阅和消息收发 @@ -811,39 +739,9 @@ final class MQTTService: NSObject { } } - // MARK: - 手机信息上报 - private let phoneReportInterval: TimeInterval = 30 * 60 - private let phoneReportRetryInterval: TimeInterval = 60 - private var phoneReportGroupKey = "" - private var phoneReportLocation: CLLocation? - private var phoneReportTimer: Timer? - private var lastPhoneReportDate: Date? - private var isPhoneReportInFlight = false - private var phoneReportGeneration = 0 - private var lastObservedPhoneUsage: MqttPhoneUsageSnapshot? - private var pendingPhoneUsage: MqttPhoneUsageSnapshot? - override private init() { super.init() UIDevice.current.isBatteryMonitoringEnabled = true - PhoneStatusCollector.shared.start() - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appUsageCountDidChange), - name: .unlockCountDidChange, - object: nil - ) - let usage = Self.currentPhoneUsageSnapshot() - lastObservedPhoneUsage = usage - if usage.count > 0 { - pendingPhoneUsage = usage - } } // MARK: - 连接 @@ -883,13 +781,11 @@ final class MQTTService: NSObject { func disconnect() { mqtt?.disconnect() isConnected = false - invalidatePhoneReportTimer() } // MARK: - 切换用户 /// 更新 clientID 并重连(切换用户后调用) func updateClientID(_ newID: String) { - resetPhoneReportState() clientID = newID disconnect() connect() @@ -1064,206 +960,6 @@ final class MQTTService: NSObject { publish(topic: "\(topic)\(AppContextManager.shared.userId)", message: payload.toJsonString()) } - // MARK: - 手机信息上报 - - /// 首页提供当前默认圈子和最新定位。切换圈子只更新上下文,不额外触发重复上报。 - func updatePhoneReportContext(groupKey: String?, location: CLLocation?) { - performOnMain { [weak self] in - guard let self else { return } - if let groupKey { - self.phoneReportGroupKey = groupKey - } - if let location, CLLocationCoordinate2DIsValid(location.coordinate) { - self.phoneReportLocation = location - } - self.reportPhoneUsageIfNeeded() - self.reportPhoneIfNeeded() - } - } - - /// MQTT 重连、Timer 和 App 回到前台都通过此入口检查是否到期。 - func reportPhoneIfNeeded(force: Bool = false) { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.reportPhoneIfNeeded(force: force) - } - return - } - - guard isConnected, - !isPhoneReportInFlight, - !phoneReportGroupKey.isEmpty, - !AppContextManager.shared.userId.isEmpty else { return } - - if !force, - let lastPhoneReportDate, - Date().timeIntervalSince(lastPhoneReportDate) < phoneReportInterval { - schedulePhoneReportTimer(after: phoneReportInterval - Date().timeIntervalSince(lastPhoneReportDate)) - return - } - - isPhoneReportInFlight = true - phoneReportGeneration += 1 - let generation = phoneReportGeneration - - guard let location = phoneReportLocation else { - finishPhoneReport(weather: "未知", generation: generation) - return - } - - MemberWeatherService.shared.weather(for: location) { [weak self] result in - let weather = (try? result.get().text) ?? "未知" - self?.finishPhoneReport(weather: weather, generation: generation) - } - } - - @objc func applicationDidBecomeActive() { - capturePhoneUsageChange() - reportPhoneUsageIfNeeded() - reportPhoneIfNeeded() - } - - @objc private func appUsageCountDidChange() { - performOnMain { [weak self] in - self?.capturePhoneUsageChange() - } - } - - private func capturePhoneUsageChange() { - let usage = Self.currentPhoneUsageSnapshot() - guard usage != lastObservedPhoneUsage else { return } - lastObservedPhoneUsage = usage - pendingPhoneUsage = usage - reportPhoneUsageIfNeeded() - } - - private func reportPhoneUsageIfNeeded() { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.reportPhoneUsageIfNeeded() - } - return - } - - let userId = AppContextManager.shared.userId - let groupKey = phoneReportGroupKey - guard isConnected, - let usage = pendingPhoneUsage, - !userId.isEmpty, - !groupKey.isEmpty else { return } - - let item = MqttPhoneUsageItem( - day: usage.day, - appName: Self.appDisplayName, - packageName: "cn.zuom8.jisuloca", - usageTime: 0, - extra: MqttPhoneUsageExtra(count: usage.count) - ) - let payload = MqttPhoneUsagePayload( - type: MqttType.phoneUsage.rawValue, - data: MqttPhoneUsageData( - userId: userId, - groupKey: groupKey, - phoneUsage: [item] - ), - extra: "" - ) - guard let data = try? JSONEncoder().encode(payload), - let message = String(data: data, encoding: .utf8) else { return } - - let messageId = publish( - topic: "\(topic)\(userId)", - message: message - ) - if messageId >= 0, pendingPhoneUsage == usage { - pendingPhoneUsage = nil - } - } - - private func finishPhoneReport(weather: String, generation: Int) { - performOnMain { [weak self] in - guard let self, - generation == self.phoneReportGeneration else { return } - self.isPhoneReportInFlight = false - - let userId = AppContextManager.shared.userId - let groupKey = self.phoneReportGroupKey - guard self.isConnected, !userId.isEmpty, !groupKey.isEmpty else { return } - - let payload = MqttPhoneReportPayload( - type: MqttType.phone.rawValue, - data: MqttPhoneReportData( - user_id: userId, - group_key: groupKey, - phone: self.collectPhoneInfo(weather: weather) - ), - extra: "" - ) - guard let data = try? JSONEncoder().encode(payload), - let message = String(data: data, encoding: .utf8) else { - self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval) - return - } - - let messageId = self.publish( - topic: "\(self.topic)\(userId)", - message: message - ) - if messageId >= 0 { - self.lastPhoneReportDate = Date() - self.schedulePhoneReportTimer(after: self.phoneReportInterval) - } else { - self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval) - } - } - } - - private func collectPhoneInfo(weather: String) -> MqttPhoneInfo { - let snap = PhoneStatusCollector.shared.snapshot() - return MqttPhoneInfo( - brand: "iphone", - model: snap.model, - memory_total: snap.memoryTotal ?? "未知", - memory_used: snap.memoryUsed ?? "未知", - battery: snap.batteryText, - network: snap.network, - brightness: snap.brightnessText, - volume: snap.volumeText, - weather: weather, - use_time: "\(max(0, UnlockCountManager.shared.todayScreenTimeSeconds))", - unlock_count: "\(UnlockCountManager.shared.todayCount)" - ) - } - - private func schedulePhoneReportTimer(after interval: TimeInterval) { - invalidatePhoneReportTimer() - let timer = Timer(timeInterval: max(1, interval), repeats: false) { [weak self] _ in - self?.phoneReportTimer = nil - self?.reportPhoneIfNeeded() - } - RunLoop.main.add(timer, forMode: .common) - phoneReportTimer = timer - } - - private func invalidatePhoneReportTimer() { - performOnMain { [weak self] in - self?.phoneReportTimer?.invalidate() - self?.phoneReportTimer = nil - } - } - - private func resetPhoneReportState() { - performOnMain { [weak self] in - guard let self else { return } - self.phoneReportGeneration += 1 - self.phoneReportGroupKey = "" - self.phoneReportLocation = nil - self.lastPhoneReportDate = nil - self.isPhoneReportInFlight = false - self.invalidatePhoneReportTimer() - } - } - private func performOnMain(_ work: @escaping () -> Void) { if Thread.isMainThread { work() @@ -1272,24 +968,6 @@ final class MQTTService: NSObject { } } - private static func currentPhoneUsageSnapshot() -> MqttPhoneUsageSnapshot { - let formatter = DateFormatter() - formatter.calendar = Calendar.current - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd" - return MqttPhoneUsageSnapshot( - day: formatter.string(from: Date()), - count: max(0, UnlockCountManager.shared.todayAppUsageCount) - ) - } - - private static var appDisplayName: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? "极速定位" - } - } // MARK: - CocoaMQTT5Delegate @@ -1309,8 +987,6 @@ extension MQTTService: CocoaMQTT5Delegate { } onConnected?() flushPendingLockAppsChangedEvents() - reportPhoneUsageIfNeeded() - reportPhoneIfNeeded() } func mqtt5(_ mqtt5: CocoaMQTT5, didPublishMessage message: CocoaMQTT5Message, id: UInt16) { @@ -1359,7 +1035,6 @@ extension MQTTService: CocoaMQTT5Delegate { func mqtt5DidDisconnect(_ mqtt5: CocoaMQTT5, withError err: Error?) { isConnected = false - invalidatePhoneReportTimer() print("MQTT5 disconnected: \(err?.localizedDescription ?? "")") onDisconnected?() } diff --git a/QuickLocation/Manager/MQTT/MemberWeatherService.swift b/QuickLocation/Manager/MQTT/MemberWeatherService.swift index 80898ee3..c0fe9a0a 100644 --- a/QuickLocation/Manager/MQTT/MemberWeatherService.swift +++ b/QuickLocation/Manager/MQTT/MemberWeatherService.swift @@ -80,10 +80,6 @@ final class MemberWeatherService { let snapshot = Self.snapshot(from: weather) self?.finish(key: key, result: .success(snapshot)) } catch { - #if DEBUG - let error = error as NSError - print("WeatherKit request failed [\(error.domain):\(error.code)] at \(key): \(error.localizedDescription)") - #endif self?.finish(key: key, result: .failure(error)) } } diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index 8086442b..df3fc95d 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -10,10 +10,7 @@ import SwiftyUserDefaults import RxSwift extension DefaultsKeys { - // 接口环境 0:测试环境 1:正式环境 - var apiEnvKey: DefaultsKey { .init("ApiEnvKey", defaultValue: -99) } - // 接口地址 - var apiServerURL: DefaultsKey { .init("ApiServerURL", defaultValue: "") } + } @objcMembers class URLManager: NSObject { @@ -47,64 +44,27 @@ extension DefaultsKeys { } func setupNetworkMode() { - #if DEBUG - setupApiEnv(1) - openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002 - openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010 - #elseif AdHoc setupApiEnv(1) openIM_API = "https://imapi.zuom8.cn" openIM_WS = "ws://imws.zuom8.cn" - #else - setupApiEnv(1) - openIM_API = "https://imapi.zuom8.cn" - openIM_WS = "ws://imws.zuom8.cn" - #endif } private func setupApiEnv(_ env: Int) { - // env - if Defaults[\.apiEnvKey] != -99 { - apiEnv = Defaults[\.apiEnvKey] - } - else { - apiEnv = env - } - // api - if Defaults[\.apiServerURL].isEmpty { - apiServerURL = apiServerURL(apiEnv) - } else { - apiServerURL = Defaults[\.apiServerURL] - } + apiServerURL = apiServerURL(apiEnv) } // MARK: - 接口地址 func apiServerURL(_ env: Int) -> String { switch env { case 1: // 正式 - return "https://jsapi.zuom8.cn/" + return "https://speed.batiao8.com/" case -1: // UAT - return "https://jsapi.zuom8.cn/" + return "https://speed.batiao8.com/" default: // SIT - return "http://172.16.10.23:9243/" + return "https://speed.batiao8.com/" } } - static let presetServers: [(title: String, url: String, env: Int)] = [ - ("开发", "http://172.16.10.23:9243/", 0), - ("正式", "https://jsapi.zuom8.cn/", 1) - ] - - func switchServer(url: String, env: Int? = nil) { - let normalized = Self.normalizedServerURL(url) - guard !normalized.isEmpty else { return } - let resolvedEnv = env ?? Self.env(for: normalized) - Defaults[\.apiEnvKey] = resolvedEnv - Defaults[\.apiServerURL] = normalized - apiEnv = resolvedEnv - apiServerURL = normalized - } - static func normalizedServerURL(_ raw: String) -> String { var url = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !url.isEmpty else { return "" } @@ -116,21 +76,9 @@ extension DefaultsKeys { } return url } - - static func env(for url: String) -> Int { - let normalized = normalizedServerURL(url) - if let preset = presetServers.first(where: { normalizedServerURL($0.url) == normalized }) { - return preset.env - } - return 99 - } // MARK: - 大对象服务器地址 func uploadServerURL() -> String { -//#if DEBUG -// return "" -//#else return "" -//#endif } } diff --git a/QuickLocation/Manager/Unlock/UnlockCountManager.swift b/QuickLocation/Manager/Unlock/UnlockCountManager.swift deleted file mode 100644 index e18be8e5..00000000 --- a/QuickLocation/Manager/Unlock/UnlockCountManager.swift +++ /dev/null @@ -1,889 +0,0 @@ -// -// UnlockCountManager.swift -// QuickLocation -// - -import UIKit -import CoreLocation -import SwiftyUserDefaults - -/// 本机解锁次数:protectedData 状态机 + 始终定位保持后台运行窗口(不上报) -final class UnlockCountManager: NSObject { - - static let shared = UnlockCountManager() - - private let locationManager = CLLocationManager() - private let coarseLocationManager = CLLocationManager() - private let logPrefix = "[DEBUG-UNLOCK]" - private let debugLogDefaultsKey = "unlockDebugLogs" - private let geofenceIdPrefix = "unlock.keepAlive." - private let geofenceCenterId = "unlock.keepAlive.center" - private let geofenceRadius: CLLocationDistance = 50 - /// 四向卫星围栏相对中心的偏移(米),用于静止时 GPS 抖动触发 - private let geofenceSatelliteOffset: CLLocationDistance = 40 - /// 相对围栏中心移动超过该距离才刷新围栏网格,避免频繁重注册 - private let geofenceRefreshDistance: CLLocationDistance = 40 - - private var didStart = false - private var isSessionLocked = false - private var hasSeenLockInSession = false - private var protectedDataPollTimer: Timer? - private var lastGeofenceCenter: CLLocationCoordinate2D? - private var lastKnownLocation: CLLocation? - private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid - private var backgroundPollTask: UIBackgroundTaskIdentifier = .invalid - /// 当前解锁 session 起点(锁屏结算屏幕时长,仅当前进程) - private var screenSessionStart: Date? - /// 最后一次确认亮屏(锁屏结算用,不用 Date() 把灭屏空档算进去) - private var lastScreenOnAt: Date? - /// 亮屏心跳超过该间隔视为观察中断(可能错过锁屏) - private let screenOnStaleInterval: TimeInterval = 10 - /// 本次进程里用户是否真正打开过 App;杀进程后被定位拉起不算 - private var hasUserActivatedApp = false - - private override init() { - super.init() - locationManager.delegate = self - configureLocationManager( - locationManager, - accuracy: kCLLocationAccuracyNearestTenMeters, - distanceFilter: kCLDistanceFilterNone - ) - coarseLocationManager.delegate = self - configureLocationManager( - coarseLocationManager, - accuracy: 800, - distanceFilter: 100 - ) - } - - private func configureLocationManager( - _ manager: CLLocationManager, - accuracy: CLLocationAccuracy, - distanceFilter: CLLocationDistance - ) { - manager.desiredAccuracy = accuracy - manager.distanceFilter = distanceFilter - manager.pausesLocationUpdatesAutomatically = false - manager.activityType = .otherNavigation - if #available(iOS 11.0, *) { - manager.showsBackgroundLocationIndicator = false - } - } - - // MARK: - Public - - var todayCount: Int { - rollDayIfNeeded() - return Defaults[\.unlockCountToday] - } - - var totalCount: Int { - rollDayIfNeeded() - return Defaults[\.unlockCountTotal] - } - - /// 今日屏幕使用时长(秒),含当前解锁 session 的今日部分 - var todayScreenTimeSeconds: Int { - rollDayIfNeeded() - let today = todayString() - var total = Defaults[\.screenTimeByDay][today] ?? 0 - if let start = screenSessionStart { - total += screenTimeSeconds(from: start, to: resolvedScreenSessionEnd(), onDay: today) - } - return total - } - - /// 本 App 今日进入前台次数 - var todayAppUsageCount: Int { - rollDayIfNeeded() - return Defaults[\.appUsageToday] - } - - var hasAlwaysLocationAuthorization: Bool { - authorizationStatus() == .authorizedAlways - } - - func start(launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) { - let appState = UIApplication.shared.applicationState - let launchedByLocation = launchOptions?[.location] != nil - let nonActiveLaunch = appState != .active - - if nonActiveLaunch { - beginBootBackgroundTask() - debugLog("coldBootNonActive appState=\(appState.rawValue) locationKey=\(launchedByLocation) bgMode=\(isLocationBackgroundModeEnabled) auth=\(authorizationStatus().rawValue) bootTask=1") - } - - guard !didStart else { - restartLocationMonitoringIfPossible(reassertContinuous: true) - handleNonActiveLaunchIfNeeded(launchOptions) - return - } - didStart = true - lastScreenOnAt = Defaults[\.screenLastOnAt] - closeOrphanScreenSessionIfNeeded() - rollDayIfNeeded() - - let available = UIApplication.shared.isProtectedDataAvailable - isSessionLocked = !available - dumpStoredDebugLogs() - debugLog("start auth=\(authorizationStatus().rawValue) appState=\(appState.rawValue) protected=\(available) bgMode=\(isLocationBackgroundModeEnabled)") - // 冷启动已解锁:不计数,只同步状态 - if !available { - hasSeenLockInSession = true - Defaults[\.unlockWasLocked] = true - } - - NotificationCenter.default.addObserver( - self, - selector: #selector(protectedDataWillBecomeUnavailable), - name: UIApplication.protectedDataWillBecomeUnavailableNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(protectedDataDidBecomeAvailable), - name: UIApplication.protectedDataDidBecomeAvailableNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - - restartLocationMonitoringIfPossible(reassertContinuous: true) - handleNonActiveLaunchIfNeeded(launchOptions) - reconcileUnlockIfNeeded(reason: "coldStartAfterLock") - if UIApplication.shared.isProtectedDataAvailable { - noteScreenOn() - } - Defaults[\.unlockLastHeartbeatAt] = Date() - startProtectedDataPolling() - } - - func refreshAuthorizationAndMonitoring() { - restartLocationMonitoringIfPossible(reassertContinuous: true) - debugLog("refresh auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)") - notifyChange() - } - - // MARK: - Day roll - - private func todayString() -> String { - let formatter = DateFormatter() - formatter.calendar = Calendar.current - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: Date()) - } - - @discardableResult - private func rollDayIfNeeded() -> Bool { - migrateScreenTimeStorageIfNeeded() - let today = todayString() - let previousDate = Defaults[\.unlockCountDate] - guard previousDate != today else { return false } - - flushScreenSession() - - // 切日前落盘旧日解锁次数 - if !previousDate.isEmpty { - var byDay = Defaults[\.unlockCountByDay] - byDay[previousDate] = Defaults[\.unlockCountToday] - Defaults[\.unlockCountByDay] = byDay - } - - Defaults[\.unlockCountDate] = today - Defaults[\.unlockCountToday] = 0 - Defaults[\.appUsageToday] = 0 - return true - } - - private func dayString(for date: Date) -> String { - let formatter = DateFormatter() - formatter.calendar = Calendar.current - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) - } - - private func migrateScreenTimeStorageIfNeeded() { - var byDay = Defaults[\.screenTimeByDay] - guard byDay.isEmpty else { return } - - let legacyKey = "screenTimeTodaySeconds" - let legacyToday = UserDefaults.standard.integer(forKey: legacyKey) - if legacyToday > 0 { - let dateKey = Defaults[\.unlockCountDate].isEmpty ? todayString() : Defaults[\.unlockCountDate] - byDay[dateKey] = legacyToday - Defaults[\.screenTimeByDay] = byDay - } - } - - private func addScreenTime(from start: Date, to end: Date) { - guard end > start else { return } - var cursor = start - let calendar = Calendar.current - var byDay = Defaults[\.screenTimeByDay] - - while cursor < end { - let dayStart = calendar.startOfDay(for: cursor) - guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { break } - let segmentEnd = min(end, nextDay) - let seconds = max(0, Int(segmentEnd.timeIntervalSince(cursor))) - if seconds > 0 { - let key = dayString(for: cursor) - byDay[key, default: 0] += seconds - } - cursor = segmentEnd - } - - Defaults[\.screenTimeByDay] = byDay - } - - private func screenTimeSeconds(from start: Date, to end: Date, onDay dayKey: String) -> Int { - guard end > start else { return 0 } - let calendar = Calendar.current - guard - let dayStart = date(fromDayKey: dayKey), - let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) - else { return 0 } - - let segmentStart = max(start, dayStart) - let segmentEnd = min(end, nextDay) - guard segmentEnd > segmentStart else { return 0 } - return max(0, Int(segmentEnd.timeIntervalSince(segmentStart))) - } - - private func date(fromDayKey key: String) -> Date? { - let formatter = DateFormatter() - formatter.calendar = Calendar.current - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd" - return formatter.date(from: key) - } - - /// 上一进程留下的 session 只结算到最后一次确认亮屏,死掉/锁屏的空档不再计入 - private func closeOrphanScreenSessionIfNeeded() { - guard screenSessionStart == nil else { return } - guard let start = Defaults[\.screenSessionStartAt] else { return } - let end = Defaults[\.screenLastOnAt] ?? Defaults[\.unlockLastHeartbeatAt] - if let end, end > start { - addScreenTime(from: start, to: end) - } - Defaults[\.screenSessionStartAt] = nil - debugLog("closeOrphanScreenSession start=\(start) end=\(String(describing: end))") - notifyChange() - } - - private var shouldUseLiveScreenClock: Bool { - UIApplication.shared.applicationState == .active - && UIApplication.shared.isProtectedDataAvailable - && !isSessionLocked - } - - private func resolvedScreenSessionEnd() -> Date { - if shouldUseLiveScreenClock { - let now = Date() - noteScreenOn(at: now) - return now - } - return lastScreenOnAt ?? screenSessionStart ?? Date() - } - - private func noteScreenOn(at date: Date = Date()) { - guard UIApplication.shared.isProtectedDataAvailable else { return } - lastScreenOnAt = date - Defaults[\.screenLastOnAt] = date - } - - private func startScreenSession() { - guard UIApplication.shared.isProtectedDataAvailable else { return } - guard hasUserActivatedApp || UIApplication.shared.applicationState == .active else { return } - rollDayIfNeeded() - let now = Date() - if screenSessionStart != nil { - noteScreenOn(at: now) - return - } - screenSessionStart = now - Defaults[\.screenSessionStartAt] = now - noteScreenOn(at: now) - Defaults[\.unlockLastHeartbeatAt] = now - debugLog("startScreenSession at=\(now)") - } - - private func flushScreenSession(end: Date? = nil) { - guard let start = screenSessionStart else { return } - let resolvedEnd = end ?? resolvedScreenSessionEnd() - addScreenTime(from: start, to: max(start, resolvedEnd)) - screenSessionStart = nil - Defaults[\.screenSessionStartAt] = nil - debugLog("flushScreenSession start=\(start) end=\(resolvedEnd)") - notifyChange() - } - - /// 观察中断后按最后亮屏结算,避免锁屏空档计入 - private func repairOpenScreenSessionIfStale() { - guard let start = screenSessionStart else { return } - guard UIApplication.shared.isProtectedDataAvailable else { - markLocked() - return - } - let lastOn = lastScreenOnAt ?? start - let gap = Date().timeIntervalSince(lastOn) - if gap > screenOnStaleInterval { - debugLog("repairStaleScreenSession gap=\(Int(gap)) lastOn=\(lastOn)") - flushScreenSession(end: lastOn) - startScreenSession() - } else { - noteScreenOn() - } - } - - private func recordAppUsage() { - rollDayIfNeeded() - Defaults[\.appUsageToday] = Defaults[\.appUsageToday] + 1 - notifyChange() - debugLog("appUsage today=\(Defaults[\.appUsageToday])") - } - - // MARK: - Counting - - private func recordUnlock(reason: String) { - let now = Date() - rollDayIfNeeded() - Defaults[\.unlockCountToday] = Defaults[\.unlockCountToday] + 1 - Defaults[\.unlockCountTotal] = Defaults[\.unlockCountTotal] + 1 - var byDay = Defaults[\.unlockCountByDay] - byDay[todayString()] = Defaults[\.unlockCountToday] - Defaults[\.unlockCountByDay] = byDay - Defaults[\.unlockLastCountedAt] = now - Defaults[\.unlockWasLocked] = false - isSessionLocked = false - Defaults[\.unlockLastHeartbeatAt] = now - stopProtectedDataPolling() - startScreenSession() - if UIApplication.shared.applicationState != .active { - startProtectedDataPolling() - } - notifyChange() - print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])") - debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])") - } - - private func markLocked() { - if isSessionLocked, screenSessionStart == nil { - Defaults[\.unlockWasLocked] = true - hasSeenLockInSession = true - startProtectedDataPolling() - return - } - let end = lastScreenOnAt ?? screenSessionStart - flushScreenSession(end: end) - isSessionLocked = true - hasSeenLockInSession = true - Defaults[\.unlockWasLocked] = true - Defaults[\.unlockLastHeartbeatAt] = Date() - startProtectedDataPolling() - } - - private func persistLifecycleHeartbeat() { - Defaults[\.unlockLastHeartbeatAt] = Date() - if !UIApplication.shared.isProtectedDataAvailable { - markLocked() - return - } - noteScreenOn() - } - - private func notifyChange() { - NotificationCenter.default.post(name: .unlockCountDidChange, object: nil) - } - - private func debugLog(_ message: String) { - let line = "\(Date()) \(logPrefix) \(message)" - NSLog("%@", line) - - var logs = UserDefaults.standard.stringArray(forKey: debugLogDefaultsKey) ?? [] - logs.append(line) - if logs.count > 200 { - logs.removeFirst(logs.count - 200) - } - UserDefaults.standard.set(logs, forKey: debugLogDefaultsKey) - } - - private func dumpStoredDebugLogs() { - let logs = UserDefaults.standard.stringArray(forKey: debugLogDefaultsKey) ?? [] - guard !logs.isEmpty else { return } - NSLog("%@ storedLogCount=%d", logPrefix, logs.count) - logs.suffix(50).forEach { NSLog("%@ stored %@", logPrefix, $0) } - } - - private var shouldPollProtectedData: Bool { - let needsUnlockDetect = Defaults[\.unlockWasLocked] || isSessionLocked - let needsLockDetect = screenSessionStart != nil && UIApplication.shared.applicationState != .active - return needsUnlockDetect || needsLockDetect - } - - private func startProtectedDataPolling() { - guard shouldPollProtectedData else { return } - guard protectedDataPollTimer == nil else { return } - debugLog("startProtectedDataPolling") - - let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in - guard let self else { return } - self.pollProtectedDataAvailability() - } - protectedDataPollTimer = timer - RunLoop.main.add(timer, forMode: .common) - pollProtectedDataAvailability() - } - - private func stopProtectedDataPolling() { - guard protectedDataPollTimer != nil else { return } - protectedDataPollTimer?.invalidate() - protectedDataPollTimer = nil - debugLog("stopProtectedDataPolling") - } - - private func pollProtectedDataAvailability() { - let available = UIApplication.shared.isProtectedDataAvailable - if !available { - debugLog("protectedDataPoll protected=0 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") - markLocked() - return - } - if Defaults[\.unlockWasLocked] || isSessionLocked { - debugLog("protectedDataPoll protected=1 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") - recordUnlock(reason: "protectedDataPoll") - return - } - noteScreenOn() - if !shouldPollProtectedData { - stopProtectedDataPolling() - } - } - - /// 对照 protectedData 纠正锁态(定位回调补强,避免挂起丢通知) - private func syncLockStateFromProtectedData(reason: String) { - if !UIApplication.shared.isProtectedDataAvailable { - markLocked() - return - } - if Defaults[\.unlockWasLocked] || isSessionLocked { - recordUnlock(reason: reason) - return - } - repairOpenScreenSessionIfStale() - } - - /// 定位事件短窗口:锁态同步 + 心跳 + FinishTask 断言 - private func performLocationTick(reason: String) { - let app = UIApplication.shared - var taskId = UIBackgroundTaskIdentifier.invalid - taskId = app.beginBackgroundTask(withName: "unlock.locationTick") { - if taskId != .invalid { - app.endBackgroundTask(taskId) - taskId = .invalid - } - } - - syncLockStateFromProtectedData(reason: reason) - Defaults[\.unlockLastHeartbeatAt] = Date() - - if taskId != .invalid { - app.endBackgroundTask(taskId) - taskId = .invalid - } - } - - /// DAS / 定位冷启动短窗口:等 locationd 接管前尽量不被秒挂 - private func beginBootBackgroundTask() { - endBootBackgroundTask() - let app = UIApplication.shared - bootBackgroundTask = app.beginBackgroundTask(withName: "unlock.boot") { [weak self] in - self?.debugLog("bootTask expired") - self?.endBootBackgroundTask() - } - debugLog("bootTask begin id=\(bootBackgroundTask.rawValue)") - DispatchQueue.main.asyncAfter(deadline: .now() + 25) { [weak self] in - self?.endBootBackgroundTask() - } - } - - private func endBootBackgroundTask() { - guard bootBackgroundTask != .invalid else { return } - let id = bootBackgroundTask - bootBackgroundTask = .invalid - UIApplication.shared.endBackgroundTask(id) - debugLog("bootTask end id=\(id.rawValue)") - } - - /// 进后台后再给 poll 一段系统宽限,覆盖「回桌面马上锁屏」 - private func beginBackgroundPollTask() { - endBackgroundPollTask() - let app = UIApplication.shared - backgroundPollTask = app.beginBackgroundTask(withName: "unlock.screenOnPoll") { [weak self] in - self?.debugLog("screenOnPollTask expired") - self?.endBackgroundPollTask() - } - debugLog("screenOnPollTask begin id=\(backgroundPollTask.rawValue)") - } - - private func endBackgroundPollTask() { - guard backgroundPollTask != .invalid else { return } - let id = backgroundPollTask - backgroundPollTask = .invalid - UIApplication.shared.endBackgroundTask(id) - debugLog("screenOnPollTask end id=\(id.rawValue)") - } - - // MARK: - protectedData / lifecycle - - @objc private func protectedDataWillBecomeUnavailable() { - debugLog("protectedDataWillBecomeUnavailable appState=\(UIApplication.shared.applicationState.rawValue)") - markLocked() - } - - @objc private func protectedDataDidBecomeAvailable() { - debugLog("protectedDataDidBecomeAvailable appState=\(UIApplication.shared.applicationState.rawValue) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") - guard hasSeenLockInSession || Defaults[\.unlockWasLocked] else { return } - guard isSessionLocked || Defaults[\.unlockWasLocked] else { return } - recordUnlock(reason: "protectedData") - } - - @objc private func appWillResignActive() { - if UIApplication.shared.isProtectedDataAvailable { - noteScreenOn() - Defaults[\.unlockLastHeartbeatAt] = Date() - } else { - markLocked() - } - debugLog("willResignActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])") - } - - @objc private func appDidEnterBackground() { - persistLifecycleHeartbeat() - startProtectedDataPolling() - beginBackgroundPollTask() - restartLocationMonitoringIfPossible(reassertContinuous: true) - forceRefreshGeofenceFromLastKnown(reason: "didEnterBackground") - debugLog("didEnterBackground auth=\(authorizationStatus().rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) regions=\(locationManager.monitoredRegions.count)") - } - - @objc private func appWillTerminate() { - flushScreenSession() - persistLifecycleHeartbeat() - forceRefreshGeofenceFromLastKnown(reason: "willTerminate") - debugLog("willTerminate protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])") - } - - @objc private func appDidBecomeActive() { - debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") - hasUserActivatedApp = true - endBootBackgroundTask() - endBackgroundPollTask() - if UIApplication.shared.isProtectedDataAvailable { - if isSessionLocked || Defaults[\.unlockWasLocked] { - reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock") - } else { - repairOpenScreenSessionIfStale() - startScreenSession() - } - if !isSessionLocked { - stopProtectedDataPolling() - } - } else { - markLocked() - } - recordAppUsage() - restartLocationMonitoringIfPossible(reassertContinuous: true) - Defaults[\.unlockLastHeartbeatAt] = Date() - } - - /// 杀进程前已记锁屏,解锁后冷启动/回前台时补计 - private func reconcileUnlockIfNeeded(reason: String) { - guard UIApplication.shared.isProtectedDataAvailable else { return } - guard Defaults[\.unlockWasLocked] || isSessionLocked else { return } - recordUnlock(reason: reason) - } - - // MARK: - Location wake - - private func authorizationStatus() -> CLAuthorizationStatus { - if #available(iOS 14.0, *) { - return locationManager.authorizationStatus - } - return CLLocationManager.authorizationStatus() - } - - /// Info.plist 的 UIBackgroundModes 必须是含 location 的数组,才可安全打开后台持续定位 - private var isLocationBackgroundModeEnabled: Bool { - guard let modes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] else { - return false - } - return modes.contains("location") - } - - private func restartLocationMonitoringIfPossible(reassertContinuous: Bool = false) { - guard authorizationStatus() == .authorizedAlways else { - stopAllLocationMonitoring() - stopProtectedDataPolling() - debugLog("stopLocation auth=\(authorizationStatus().rawValue) stopVisits=true stopCoarse=true stopGeofence=true") - return - } - - // 重要位置变化 / Visit / 围栏:杀进程后由系统拉起 - locationManager.startMonitoringSignificantLocationChanges() - locationManager.startMonitoringVisits() - debugLog("startVisits auth=\(authorizationStatus().rawValue)") - - if isLocationBackgroundModeEnabled { - reassertContinuousLocationUpdates(forceStopFirst: reassertContinuous) - } else { - if #available(iOS 9.0, *) { - locationManager.allowsBackgroundLocationUpdates = false - coarseLocationManager.allowsBackgroundLocationUpdates = false - } - locationManager.stopUpdatingLocation() - coarseLocationManager.stopUpdatingLocation() - print("UnlockCountManager: UIBackgroundModes 缺少 location,仅使用重要位置变化/围栏") - debugLog("startSignificantOnly auth=\(authorizationStatus().rawValue)") - } - } - - private func stopAllLocationMonitoring() { - locationManager.stopUpdatingLocation() - locationManager.stopMonitoringSignificantLocationChanges() - locationManager.stopMonitoringVisits() - coarseLocationManager.stopUpdatingLocation() - stopGeofenceMonitoring() - if #available(iOS 9.0, *) { - locationManager.allowsBackgroundLocationUpdates = false - coarseLocationManager.allowsBackgroundLocationUpdates = false - } - } - - /// 停再开持续定位,迫使 locationd 重新挂上 Location subscription(对齐 Lookus 拉起后行为) - private func reassertContinuousLocationUpdates(forceStopFirst: Bool) { - guard authorizationStatus() == .authorizedAlways, isLocationBackgroundModeEnabled else { return } - if forceStopFirst { - locationManager.stopUpdatingLocation() - coarseLocationManager.stopUpdatingLocation() - debugLog("reassertContinuous stopThenStart") - } - if #available(iOS 9.0, *) { - locationManager.allowsBackgroundLocationUpdates = true - coarseLocationManager.allowsBackgroundLocationUpdates = true - } - locationManager.startUpdatingLocation() - coarseLocationManager.startUpdatingLocation() - debugLog("startPreciseLocation accuracy=10 distanceFilter=none activity=otherNavigation auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)") - debugLog("startCoarseLocation accuracy=800 distanceFilter=100 activity=otherNavigation auth=\(authorizationStatus().rawValue) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable)") - } - - private func stopGeofenceMonitoring() { - for region in locationManager.monitoredRegions where region.identifier.hasPrefix(geofenceIdPrefix) { - locationManager.stopMonitoring(for: region) - } - lastGeofenceCenter = nil - } - - /// 中心 + 东南西北共 5 个围栏中心点(偏移约 40m) - private func geofenceGridCenters(around coordinate: CLLocationCoordinate2D) -> [(id: String, coordinate: CLLocationCoordinate2D)] { - let earthRadius: CLLocationDistance = 6_378_137 - let latRad = coordinate.latitude * .pi / 180 - let dLat = (geofenceSatelliteOffset / earthRadius) * 180 / .pi - let dLon = (geofenceSatelliteOffset / (earthRadius * cos(latRad))) * 180 / .pi - return [ - (geofenceCenterId, coordinate), - ("\(geofenceIdPrefix)n", CLLocationCoordinate2D(latitude: coordinate.latitude + dLat, longitude: coordinate.longitude)), - ("\(geofenceIdPrefix)s", CLLocationCoordinate2D(latitude: coordinate.latitude - dLat, longitude: coordinate.longitude)), - ("\(geofenceIdPrefix)e", CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude + dLon)), - ("\(geofenceIdPrefix)w", CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude - dLon)) - ] - } - - private func rememberLocation(_ location: CLLocation) { - lastKnownLocation = location - Defaults[\.unlockLastLatitude] = location.coordinate.latitude - Defaults[\.unlockLastLongitude] = location.coordinate.longitude - } - - private func resolvedLastKnownLocation() -> CLLocation? { - if let location = locationManager.location ?? lastKnownLocation { - return location - } - if let lat = Defaults[\.unlockLastLatitude], let lon = Defaults[\.unlockLastLongitude] { - return CLLocation(latitude: lat, longitude: lon) - } - if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] { - return CLLocation(latitude: lat, longitude: lon) - } - return nil - } - - private func forceRefreshGeofenceFromLastKnown(reason: String) { - guard let location = resolvedLastKnownLocation() else { - debugLog("geofenceSkip reason=\(reason) noLastLocation") - return - } - debugLog("geofenceForce reason=\(reason)") - refreshGeofence(around: location, force: true) - } - - private func refreshGeofence(around location: CLLocation, force: Bool = false) { - guard authorizationStatus() == .authorizedAlways else { return } - guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { - debugLog("geofence unavailable") - return - } - - let coordinate = location.coordinate - if !force, let last = lastGeofenceCenter { - let lastLocation = CLLocation(latitude: last.latitude, longitude: last.longitude) - if lastLocation.distance(from: location) < geofenceRefreshDistance { - return - } - } - - stopGeofenceMonitoring() - - let centers = geofenceGridCenters(around: coordinate) - for item in centers { - let region = CLCircularRegion( - center: item.coordinate, - radius: geofenceRadius, - identifier: item.id - ) - region.notifyOnEntry = true - region.notifyOnExit = true - locationManager.startMonitoring(for: region) - } - lastGeofenceCenter = coordinate - let ours = locationManager.monitoredRegions.filter { $0.identifier.hasPrefix(geofenceIdPrefix) }.count - debugLog("geofenceRefresh lat=\(coordinate.latitude) lon=\(coordinate.longitude) radius=\(geofenceRadius) grid=\(centers.count) force=\(force) monitoredOurs=\(ours) monitoredTotal=\(locationManager.monitoredRegions.count)") - } - - /// DAS 预热 / 定位拉起 / 任意非前台冷启动:立刻重申持续定位并尝试恢复围栏 - private func handleNonActiveLaunchIfNeeded(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { - let launchedByLocation = launchOptions?[.location] != nil - let nonActive = UIApplication.shared.applicationState != .active - guard launchedByLocation || nonActive else { return } - - let reason: String - if launchedByLocation { - reason = "locationLaunch" - } else if UIApplication.shared.applicationState == .background { - reason = "backgroundLaunch" - } else { - reason = "inactiveLaunch" - } - - debugLog("handleLaunch reason=\(reason) locationKey=\(launchedByLocation) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) bgMode=\(isLocationBackgroundModeEnabled)") - reassertContinuousLocationUpdates(forceStopFirst: true) - forceRefreshGeofenceFromLastKnown(reason: reason) - performLocationTick(reason: reason) - startProtectedDataPolling() - } -} - -// MARK: - CLLocationManagerDelegate - -extension UnlockCountManager: CLLocationManagerDelegate { - - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - restartLocationMonitoringIfPossible(reassertContinuous: true) - debugLog("authChangedNewAPI auth=\(authorizationStatus().rawValue)") - notifyChange() - } - - func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - restartLocationMonitoringIfPossible(reassertContinuous: true) - debugLog("authChangedOldAPI status=\(status.rawValue)") - notifyChange() - } - - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - let source = manager === coarseLocationManager ? "coarse" : "precise" - debugLog("didUpdateLocations source=\(source) count=\(locations.count) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") - performLocationTick(reason: "locationUpdate") - if let latest = locations.last { - rememberLocation(latest) - refreshGeofence(around: latest) - } - // locationd 已开始送点,可结束 DAS 预热 boot task - endBootBackgroundTask() - } - - func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) { - debugLog("didVisit arrival=\(visit.arrivalDate) departure=\(visit.departureDate) appState=\(UIApplication.shared.applicationState.rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") - beginBootBackgroundTask() - reassertContinuousLocationUpdates(forceStopFirst: true) - performLocationTick(reason: "visit") - if visit.coordinate.latitude != 0 || visit.coordinate.longitude != 0 { - let location = CLLocation(latitude: visit.coordinate.latitude, longitude: visit.coordinate.longitude) - rememberLocation(location) - refreshGeofence(around: location, force: true) - } - } - - func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { - guard region.identifier.hasPrefix(geofenceIdPrefix) else { return } - debugLog("didEnterRegion id=\(region.identifier) appState=\(UIApplication.shared.applicationState.rawValue)") - beginBootBackgroundTask() - reassertContinuousLocationUpdates(forceStopFirst: true) - performLocationTick(reason: "geofenceEnter") - } - - func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { - guard region.identifier.hasPrefix(geofenceIdPrefix) else { return } - debugLog("didExitRegion id=\(region.identifier) appState=\(UIApplication.shared.applicationState.rawValue)") - beginBootBackgroundTask() - reassertContinuousLocationUpdates(forceStopFirst: true) - performLocationTick(reason: "geofenceExit") - if let location = manager.location { - rememberLocation(location) - refreshGeofence(around: location, force: true) - } else { - forceRefreshGeofenceFromLastKnown(reason: "geofenceExit") - } - } - - func locationManager(_ manager: CLLocationManager, didStartMonitoringFor region: CLRegion) { - debugLog("geofenceStarted id=\(region.identifier)") - } - - func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) { - debugLog("geofenceFail id=\(region?.identifier ?? "nil") error=\(error.localizedDescription)") - } - - func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - print("UnlockCountManager location error: \(error.localizedDescription)") - debugLog("locationError=\(error.localizedDescription)") - } -} diff --git a/QuickLocation/Plugin/ImagePlugin.swift b/QuickLocation/Plugin/ImagePlugin.swift index 46e19a06..8065d03d 100644 --- a/QuickLocation/Plugin/ImagePlugin.swift +++ b/QuickLocation/Plugin/ImagePlugin.swift @@ -255,20 +255,43 @@ enum HeadPic { } } +private enum HeadPicAssociatedKeys { + static var lastValue: UInt8 = 0 +} + extension UIImageView { + private var lastHeadPicValue: String { + get { objc_getAssociatedObject(self, &HeadPicAssociatedKeys.lastValue) as? String ?? "" } + set { objc_setAssociatedObject(self, &HeadPicAssociatedKeys.lastValue, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } + } + func setHeadPic(_ value: String?) { let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { + lastHeadPicValue = "" kf.cancelDownloadTask() image = HeadPic.placeholder return } + let cached = HeadPic.image(for: trimmed) + let hasCachedImage = cached.size.width > 0 + if lastHeadPicValue == trimmed, hasCachedImage, image === cached { + return + } + lastHeadPicValue = trimmed if let url = HeadPic.remoteURL(for: trimmed) { - kf.cancelDownloadTask() - kf.setImage(with: url, placeholder: nil, options: kNetworkImageFailureOptions) + if !hasCachedImage { + kf.cancelDownloadTask() + image = nil + } + kf.setImage( + with: url, + placeholder: hasCachedImage ? cached : nil, + options: kNetworkImageFailureOptions + ) } else { kf.cancelDownloadTask() - image = HeadPic.localImage(trimmed) + image = cached } } } diff --git a/QuickLocation/PrivacyInfo.xcprivacy b/QuickLocation/PrivacyInfo.xcprivacy index 215a9bbb..16b66b4d 100644 --- a/QuickLocation/PrivacyInfo.xcprivacy +++ b/QuickLocation/PrivacyInfo.xcprivacy @@ -7,7 +7,140 @@ NSPrivacyTrackingDomains NSPrivacyCollectedDataTypes - + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePreciseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhysicalAddress + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhoneNumber + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeName + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeEmailAddress + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeAudioData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeContacts + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUserContent + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyAccessedAPITypes diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift index a474a446..621f1830 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift @@ -6,6 +6,7 @@ // import UIKit +import Kingfisher final class GroupMemberListView: UIView { @@ -257,6 +258,7 @@ final class GroupMemberListCell: UICollectionViewCell { super.prepareForReuse() updateSOSBlink(false) bubbleImageView.isHidden = true + avatarImageView.kf.cancelDownloadTask() } func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) { diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index 45381372..db2c1bb7 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -20,10 +20,8 @@ class GroupMemberView2: UIView { private var navBarHeightConstraint: NSLayoutConstraint? private var todayTrackTopToInteraction: NSLayoutConstraint? private var todayTrackTopToMember: NSLayoutConstraint? - private var appUsageTopToPhoneReportConstraint: NSLayoutConstraint? private var appUsageTopToTodayTrackConstraint: NSLayoutConstraint? private var todayTrackBottomConstraint: NSLayoutConstraint? - private var phoneReportBottomConstraint: NSLayoutConstraint? private var appUsageBottomConstraint: NSLayoutConstraint? private var isAppUsageVisible = false private var currentMemberModel: GroupMemberModel? @@ -210,42 +208,32 @@ class GroupMemberView2: UIView { } } if isShowingOfflineOther { - phoneReportView.configure(with: nil) appUsageView.configure(with: nil) applyFeatureAccessStates(appUsagePreview: nil) applyOfflineStayPoints() return } - phoneReportView.configure(with: Self.phoneReportPreview(from: usage)) let appUsagePreview = MemberAppUsagePreview(topApps: usage?.topApps ?? []) appUsageView.configure(with: appUsagePreview) applyFeatureAccessStates(appUsagePreview: appUsagePreview) applyStayPoints(usage?.stayPoints ?? []) } - var isPhoneReportDenied: Bool { phoneReportView.isDenied } var isAppUsageDenied: Bool { appUsageView.isDenied } - var onPhoneReportRemind: (() -> Void)? var onAppUsageRemind: (() -> Void)? private func applyFeatureAccessStates(appUsagePreview: MemberAppUsagePreview?) { guard currentMemberModel != nil else { - phoneReportView.setAccessState(.available) appUsageView.setAccessState(.available) updateAppUsageVisibility(appUsagePreview != nil) return } let isSelf = isShowingCurrentUser let memberPrivilege = currentMemberModel?.extra.privilege ?? GroupPrivilege() - let phoneState = Self.accessState( - privilegeAllowed: isSelf || memberPrivilege.phone_report - ) let appUsageState = Self.accessState( privilegeAllowed: isSelf || memberPrivilege.app_usage_record ) - phoneReportView.setAccessState(phoneState) - phoneReportView.onDeniedAction = { [weak self] in self?.onPhoneReportRemind?() } appUsageView.setAccessState(appUsageState) appUsageView.onDeniedAction = { [weak self] in self?.onAppUsageRemind?() } let showUsage = appUsagePreview != nil || appUsageState != .available @@ -399,16 +387,10 @@ class GroupMemberView2: UIView { } private func updateFeatureVisibilityLayout() { - let hidesPhoneReport = false - let showsAppUsage = !hidesPhoneReport && isAppUsageVisible - phoneReportView.isHidden = hidesPhoneReport - appUsageView.isHidden = !showsAppUsage - - appUsageTopToPhoneReportConstraint?.isActive = !hidesPhoneReport - appUsageTopToTodayTrackConstraint?.isActive = hidesPhoneReport - phoneReportBottomConstraint?.isActive = !hidesPhoneReport && !isAppUsageVisible - appUsageBottomConstraint?.isActive = showsAppUsage - todayTrackBottomConstraint?.isActive = hidesPhoneReport + appUsageView.isHidden = !isAppUsageVisible + appUsageTopToTodayTrackConstraint?.isActive = true + appUsageBottomConstraint?.isActive = isAppUsageVisible + todayTrackBottomConstraint?.isActive = !isAppUsageVisible setNeedsLayout() } @@ -472,23 +454,6 @@ class GroupMemberView2: UIView { return min(max(percent, 0), 100) } - private static func phoneReportPreview(from usage: PhoneUsageTodayModel?) -> MemberPhoneReportPreview? { - guard let usage else { return nil } - let info = usage.phoneInfo - return MemberPhoneReportPreview( - screenTimeSeconds: screenTimeSeconds(from: info?.useTime), - usageCount: usage.appCount, - unlockCount: Int(info?.unlockCount.trimmed ?? "") - ) - } - - private static func screenTimeSeconds(from value: String?) -> Int? { - guard let value else { return nil } - let text = value.trimmed - guard !text.isEmpty, let seconds = Int(text) else { return nil } - return max(0, seconds) - } - private func applyStayPoints(_ stayPoints: [PhoneUsageStayPointModel]) { stayNumLab.text = "\(stayPoints.count)" guard let latest = stayPoints.max(by: { @@ -618,29 +583,15 @@ class GroupMemberView2: UIView { todayTrackView.layoutChain .edgesHorzontal() todayTrackTopToMember = todayTrackView.jh_pinEdge(.top, toEdge: .bottom, of: memberView, offset: 12) - - scrollContentView.addSubview(phoneReportView) - phoneReportView.layoutChain - .topToBottomOfView(todayTrackView, offset: 15) - .edgesHorzontal() scrollContentView.addSubview(appUsageView) appUsageView.layoutChain .edgesHorzontal() - appUsageTopToPhoneReportConstraint = appUsageView.topAnchor.constraint( - equalTo: phoneReportView.bottomAnchor, - constant: 15 - ) - appUsageTopToPhoneReportConstraint?.isActive = true appUsageTopToTodayTrackConstraint = appUsageView.topAnchor.constraint( equalTo: todayTrackView.bottomAnchor, constant: 15 ) - - phoneReportBottomConstraint = phoneReportView.bottomAnchor.constraint( - equalTo: scrollContentView.bottomAnchor, - constant: -(49 + kSafeBottomMargin) - ) + appUsageTopToTodayTrackConstraint?.isActive = true appUsageBottomConstraint = appUsageView.bottomAnchor.constraint( equalTo: scrollContentView.bottomAnchor, constant: -(49 + kSafeBottomMargin) @@ -649,7 +600,7 @@ class GroupMemberView2: UIView { equalTo: scrollContentView.bottomAnchor, constant: -(49 + kSafeBottomMargin) ) - phoneReportBottomConstraint?.isActive = true + todayTrackBottomConstraint?.isActive = true return view }() @@ -1191,8 +1142,7 @@ class GroupMemberView2: UIView { return label }() - // MARK: - 手机报告 / APP使用记录 - lazy var phoneReportView = MemberPhoneReportView() + // MARK: - APP使用记录 lazy var appUsageView: MemberAppUsageView = { let view = MemberAppUsageView() view.isHidden = true diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 6a5aa1c1..8d1f9cbc 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -78,6 +78,7 @@ class HomeViewController: BaseViewController { private var groupInfoRequestID = UUID() private var pendingGroupDataChanges: [GroupDataChange] = [] private var hasAppeared = false + private var didRequestWhenInUseLocation = false private struct GroupDataChange { let groupKey: String @@ -123,12 +124,9 @@ class HomeViewController: BaseViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - UnlockCountManager.shared.refreshAuthorizationAndMonitoring() - MQTTService.shared.applicationDidBecomeActive() rootView.groupMemberView.refreshLocalDeviceInfoIfNeeded() - - let manager = AuthorizeManager.manager(type: .locationAlways) - rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized + requestWhenInUseLocationIfNeeded() + refreshLocationWarning() refreshPigeonInbox() queryUnlockRequests() if hasAppeared { @@ -143,6 +141,21 @@ class HomeViewController: BaseViewController { // sosPlayer?.stop() } + private func requestWhenInUseLocationIfNeeded() { + guard !didRequestWhenInUseLocation else { return } + let manager = AuthorizeManager.manager(type: .locationWhenInUse) + guard manager?.authorizeStatus() == .notDetermined else { return } + didRequestWhenInUseLocation = true + manager?.authorize { [weak self] _ in + self?.refreshLocationWarning() + } + } + + private func refreshLocationWarning() { + let manager = AuthorizeManager.manager(type: .locationAlways) + rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized + } + // MARK: - MQTT 位置上报 private let geocoder = CLGeocoder() private var lastGeocodedCoord: CLLocationCoordinate2D? @@ -341,13 +354,6 @@ class HomeViewController: BaseViewController { self?.openMessagePopView() }).disposed(by: disposeBag) - // 手机报告二级页 - rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in - guard let self else { return } - guard !self.rootView.groupMemberView.isPhoneReportDenied else { return } - self.openPhoneReportDetail() - }).disposed(by: disposeBag) - // APP 报告二级页 rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in guard let self else { return } @@ -355,9 +361,6 @@ class HomeViewController: BaseViewController { self.openAppUsageReport() }).disposed(by: disposeBag) - rootView.groupMemberView.onPhoneReportRemind = { [weak self] in - self?.remindSelectedMember(content: "请开启手机报告隐私设置") - } rootView.groupMemberView.onAppUsageRemind = { [weak self] in self?.remindSelectedMember(content: "请开启App使用记录隐私设置") } @@ -677,11 +680,6 @@ class HomeViewController: BaseViewController { rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty ? "我的圈子" : viewModel.groupName - MQTTService.shared.updatePhoneReportContext( - groupKey: hasRequestedGroup ? requestedGroupKey : "", - location: phoneReportLocation - ) - let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList GroupListPopView.updateIfVisible( groupModel: model, @@ -859,18 +857,6 @@ class HomeViewController: BaseViewController { .disposed(by: disposeBag) } - private var phoneReportLocation: CLLocation? { - if let lastLocation, - CLLocationCoordinate2DIsValid(lastLocation.coordinate) { - return lastLocation - } - guard let latitude = Defaults[\.currentLatitude], - let longitude = Defaults[\.currentLongitude] else { return nil } - let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - guard CLLocationCoordinate2DIsValid(coordinate) else { return nil } - return CLLocation(latitude: latitude, longitude: longitude) - } - private func phoneUsageCacheKey(groupKey: String, userId: String) -> String { "\(groupKey)|\(userId)" } @@ -1245,18 +1231,6 @@ class HomeViewController: BaseViewController { } } - private func openPhoneReportDetail() { - let members = viewModel.memberList.map { $0.toJSON() } - var userInfo: [String: Any] = [ - "members": members, - "userId": selectedMemberId - ] - if let group = viewModel.groupModel { - userInfo["groupJson"] = group.toJSON() - } - AppRouter.push(Route.phoneReportDetail, userInfo: userInfo) - } - private func openAppUsageReport() { let members = viewModel.memberList.map { $0.toJSON() } var userInfo: [String: Any] = [ @@ -2003,10 +1977,6 @@ extension HomeViewController: MAMapViewDelegate { // 地图标注 let isFirstFix = lastLocation == nil lastLocation = location - MQTTService.shared.updatePhoneReportContext( - groupKey: viewModel.groupModel?.default_group_key, - location: location - ) if isFirstFix { reportCurrentLocationIfNeeded() } diff --git a/QuickLocation/Section/Home/HomeViewModel.swift b/QuickLocation/Section/Home/HomeViewModel.swift index dc366630..5d88142e 100644 --- a/QuickLocation/Section/Home/HomeViewModel.swift +++ b/QuickLocation/Section/Home/HomeViewModel.swift @@ -135,20 +135,23 @@ class HomeViewModel { /// 标记成员离线(保留 last_active_time 用于显示"X分钟前") func setMemberOffline(userId: String) { guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return } - var updated = memberList - updated[idx].is_online = false - updateMembers(updated) + guard memberList[idx].is_online else { return } + memberList[idx].is_online = false + updateMembers(memberList) } - /// MQTT 位置更新后刷新成员的地址、电量、在线状态 + /// MQTT 位置更新后刷新成员的地址、电量、在线状态。 + /// 列表 cell 不展示这些字段,在线状态没变时不要整表 reload。 func updateMemberLocation(userId: String, lat: Double, lon: Double, address: String, battery: String, isOnline: Bool, lastUpdateTime: Int64) { guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return } - var updated = memberList - updated[idx].last_position = "\(lat):\(lon):\(address)" - updated[idx].battery = battery - updated[idx].is_online = isOnline - updated[idx].last_active_time = lastUpdateTime - updateMembers(updated) + let onlineChanged = memberList[idx].is_online != isOnline + memberList[idx].last_position = "\(lat):\(lon):\(address)" + memberList[idx].battery = battery + memberList[idx].is_online = isOnline + memberList[idx].last_active_time = lastUpdateTime + if onlineChanged { + updateMembers(memberList) + } } /// 成员是否存在 diff --git a/QuickLocation/Section/Home/MemberPhoneReportView.swift b/QuickLocation/Section/Home/MemberPhoneReportView.swift deleted file mode 100644 index e6f2863a..00000000 --- a/QuickLocation/Section/Home/MemberPhoneReportView.swift +++ /dev/null @@ -1,338 +0,0 @@ -// -// MemberPhoneReportView.swift -// QuickLocation -// - -import UIKit - -struct MemberPhoneReportPreview { - let screenTimeSeconds: Int? - let usageCount: Int? - let unlockCount: Int? - - static func currentUser(from manager: UnlockCountManager = .shared) -> MemberPhoneReportPreview { - let seconds = manager.todayScreenTimeSeconds - return MemberPhoneReportPreview( - screenTimeSeconds: seconds > 0 ? seconds : nil, - usageCount: manager.todayAppUsageCount, - unlockCount: manager.todayCount - ) - } -} - -/// 成员面板「手机报告」:屏幕使用时长 + 使用次数 + 今日解锁 -final class MemberPhoneReportView: UIView { - - private enum Layout { - static let designCardWidth: CGFloat = 345 - static let leftPanelWidthRatio: CGFloat = 164 / designCardWidth - static let leftPanelAspect: CGFloat = 147 / 164 - static let panelGap: CGFloat = 13 - static let cardInset: CGFloat = 12 - static let usageUnlockGap: CGFloat = 9 - } - - private let titleColor = UIColor(hexStr: "#293445") - private let usageTint = UIColor(hexStr: "#E8FBE8") - private let unlockTint = UIColor(hexStr: "#E8F4FF") - - private let sectionIcon = UIImageView() - private let sectionTitleLab = UILabel() - private let cardView = UIView() - private let leftPanel = UIView() - private let rightPanel = UIView() - private let screenTimeIcon = UIImageView() - private let screenTimeTitleLab = UILabel() - private let screenTimeBadge = ScreenTimeBadgeView() - private let usageCard = UIView() - private let usageIcon = UIImageView() - private let usageTitleLab = UILabel() - private let usageValueLab = UILabel() - private let unlockCard = UIView() - private let unlockIcon = UIImageView() - private let unlockTitleLab = UILabel() - private let unlockValueLab = UILabel() - private let deniedOverlay = PrivilegeDeniedOverlay() - private(set) var isDenied = false - var onDeniedAction: (() -> Void)? - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - configure(with: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setAccessState(_ state: MemberFeatureAccessState) { - isDenied = state == .privilegeDenied - deniedOverlay.configure( - title: "TA未开启设置", - message: "无法查看手机报告", - actionTitle: "提醒TA", - imageName: "PrivilegeDenied/phone_report" - ) - deniedOverlay.onAction = { [weak self] in self?.onDeniedAction?() } - deniedOverlay.setVisible(isDenied) - deniedOverlay.layer.cornerRadius = cardView.layer.cornerRadius - leftPanel.isHidden = false - rightPanel.isHidden = false - gestureRecognizers?.forEach { $0.isEnabled = !isDenied } - } - - func configure(with preview: MemberPhoneReportPreview?) { - if let preview { - screenTimeBadge.configure(seconds: preview.screenTimeSeconds) - usageValueLab.text = preview.usageCount.map(String.init) ?? "**" - unlockValueLab.text = preview.unlockCount.map(String.init) ?? "**" - } else { - screenTimeBadge.configure(seconds: nil) - usageValueLab.text = "**" - unlockValueLab.text = "**" - } - } - - private func setupUI() { - backgroundColor = .clear - - sectionIcon.image = UIImage(named: "Home/member_phone_report") - sectionIcon.contentMode = .scaleAspectFit - addSubview(sectionIcon) - sectionIcon.layoutChain - .top() - .left(15) - .width(18) - .heightToWidth(1) - - sectionTitleLab.text = "手机报告" - sectionTitleLab.font = .systemFont(ofSize: 16, weight: .bold) - sectionTitleLab.textColor = titleColor - addSubview(sectionTitleLab) - sectionTitleLab.layoutChain - .leftToRightOfView(sectionIcon, offset: 6) - .centerY(sectionIcon) - - cardView.backgroundColor = .white - cardView.cornerRadius = 16 - addSubview(cardView) - cardView.layoutChain - .topToBottomOfView(sectionIcon, offset: 12) - .edgesHorzontal(15) - .bottom() - - cardView.addSubview(deniedOverlay) - deniedOverlay.layoutChain.edges() - - cardView.addSubview(leftPanel) - cardView.addSubview(rightPanel) - leftPanel.backgroundColor = UIColor(hexStr: "#FFFCEA") - leftPanel.cornerRadius = 14 - rightPanel.backgroundColor = .clear - leftPanel.layoutChain - .top(Layout.cardInset) - .left(Layout.cardInset) - .widthToView(cardView, multiplier: Layout.leftPanelWidthRatio) - .heightToWidth(Layout.leftPanelAspect) - - rightPanel.layoutChain - .top(Layout.cardInset) - .right(Layout.cardInset) - .leftToRightOfView(leftPanel, offset: Layout.panelGap) - .heightToView(leftPanel) - .bottom(Layout.cardInset) - - screenTimeIcon.image = UIImage(named: "Home/member_report_clock") - screenTimeIcon.contentMode = .scaleAspectFit - leftPanel.addSubview(screenTimeIcon) - screenTimeIcon.layoutChain - .top(7) - .left(7) - .width(20) - .height(20) - - screenTimeTitleLab.text = "屏幕使用时长" - screenTimeTitleLab.font = .systemFont(ofSize: 12, weight: .bold) - screenTimeTitleLab.textColor = UIColor(hexStr: "#293445") - leftPanel.addSubview(screenTimeTitleLab) - screenTimeTitleLab.layoutChain - .leftToRightOfView(screenTimeIcon, offset: 6) - .centerY(screenTimeIcon) - - leftPanel.addSubview(screenTimeBadge) - screenTimeBadge.layoutChain - .topToBottomOfView(screenTimeTitleLab, offset: 13) - .centerX(leftPanel, offset: 7) - - let screenTimeBadgeArrow = UIImageView(image: UIImage(named: "Home/member_screenTime_arrow")) - leftPanel.addSubview(screenTimeBadgeArrow) - screenTimeBadgeArrow.layoutChain - .topToBottomOfView(screenTimeBadge) - .centerX(screenTimeBadge) - - let barchartIcon = UIImageView(image: UIImage(named: "Home/member_screenTime")) - leftPanel.addSubview(barchartIcon) - barchartIcon.layoutChain - .centerX() - .topToBottomOfView(screenTimeBadge, offset: 12) - - usageCard.backgroundColor = UIColor(hexStr: "#F8FFEE") - usageCard.cornerRadius = 14 - rightPanel.addSubview(usageCard) - usageCard.layoutChain - .top() - .edgesHorzontal() - - usageIcon.image = UIImage(named: "Home/member_usage_count") - usageIcon.contentMode = .scaleAspectFit - usageCard.addSubview(usageIcon) - usageIcon.layoutChain - .top(8) - .left(10) - .width(20) - .height(20) - - usageTitleLab.text = "使用次数" - usageTitleLab.font = .systemFont(ofSize: 12, weight: .bold) - usageTitleLab.textColor = UIColor(hexStr: "#293445") - usageCard.addSubview(usageTitleLab) - usageTitleLab.layoutChain - .leftToRightOfView(usageIcon, offset: 6) - .centerY(usageIcon) - .right(relation: .lessThanOrEqual) - - usageValueLab.font = .systemFont(ofSize: 24, weight: .bold) - usageValueLab.textColor = UIColor(hexStr: "#83DE00") - usageCard.addSubview(usageValueLab) - usageValueLab.layoutChain - .topToBottomOfView(usageTitleLab, offset: 4) - .centerX() - - let usageValueUnitLab = UILabel() - usageValueUnitLab.text = "次" - usageValueUnitLab.textColor = UIColor(hexStr: "#293445") - usageValueUnitLab.font = .systemFont(ofSize: 12, weight: .bold) - usageCard.addSubview(usageValueUnitLab) - usageValueUnitLab.layoutChain - .leftToRightOfView(usageValueLab, offset: 3) - .bottomToView(usageValueLab, offset: -4) - - unlockCard.backgroundColor = UIColor(hexStr: "#F2FEFF") - unlockCard.cornerRadius = 14 - rightPanel.addSubview(unlockCard) - unlockCard.layoutChain - .topToBottomOfView(usageCard, offset: Layout.usageUnlockGap) - .edgesHorzontal() - .heightToView(usageCard) - .bottom() - - unlockIcon.image = UIImage(named: "Home/member_daily_unlock") - unlockIcon.contentMode = .scaleAspectFit - unlockCard.addSubview(unlockIcon) - unlockIcon.layoutChain - .top(8) - .left(10) - .width(20) - .height(20) - - unlockTitleLab.text = "今日解锁" - unlockTitleLab.font = .systemFont(ofSize: 12, weight: .bold) - unlockTitleLab.textColor = UIColor(hexStr: "#293445") - unlockCard.addSubview(unlockTitleLab) - unlockTitleLab.layoutChain - .leftToRightOfView(unlockIcon, offset: 6) - .centerY(unlockIcon) - .right(relation: .lessThanOrEqual) - - unlockValueLab.font = .systemFont(ofSize: 24, weight: .bold) - unlockValueLab.textColor = UIColor(hexStr: "#08B6C4") - unlockCard.addSubview(unlockValueLab) - unlockValueLab.layoutChain - .topToBottomOfView(unlockTitleLab, offset: 4) - .centerX() - - let unlockValueUnitLab = UILabel() - unlockValueUnitLab.text = "次" - unlockValueUnitLab.textColor = UIColor(hexStr: "#293445") - unlockValueUnitLab.font = .systemFont(ofSize: 12, weight: .bold) - unlockCard.addSubview(unlockValueUnitLab) - unlockValueUnitLab.layoutChain - .leftToRightOfView(unlockValueLab, offset: 3) - .bottomToView(unlockValueLab, offset: -4) - - cardView.bringSubviewToFront(deniedOverlay) - } -} - -// MARK: - 屏幕时长 Badge - -private final class ScreenTimeBadgeView: UIView { - - private let contentStack = UIStackView() - private let numberColor = UIColor(hexStr: "#FF6B4A") - private let unitColor = UIColor(hexStr: "#293445") - private let numberFont = UIFont.systemFont(ofSize: 24, weight: .heavy) - private let unitFont = UIFont.systemFont(ofSize: 14, weight: .heavy) - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .white - cornerRadius = 11 - contentStack.axis = .horizontal - contentStack.alignment = .lastBaseline - contentStack.spacing = 2 - addSubview(contentStack) - contentStack.layoutChain - .edges(UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(seconds: Int?) { - contentStack.arrangedSubviews.forEach { view in - contentStack.removeArrangedSubview(view) - view.removeFromSuperview() - } - - guard let seconds, seconds >= 0 else { - contentStack.addArrangedSubview(makeLabel(text: "**", font: numberFont, color: numberColor)) - return - } - - let hours = seconds / 3600 - let minutes = (seconds % 3600) / 60 - - if hours > 0 { - appendPart(number: "\(hours)", unit: "h") - if minutes > 0 { - contentStack.addArrangedSubview(makeSpacer(width: 4)) - appendPart(number: "\(minutes)", unit: "m") - } - } else { - appendPart(number: "\(minutes)", unit: "m") - } - } - - private func appendPart(number: String, unit: String) { - contentStack.addArrangedSubview(makeLabel(text: number, font: numberFont, color: numberColor)) - contentStack.addArrangedSubview(makeLabel(text: unit, font: unitFont, color: unitColor)) - } - - private func makeLabel(text: String, font: UIFont, color: UIColor) -> UILabel { - let label = UILabel() - label.text = text - label.font = font - label.textColor = color - return label - } - - private func makeSpacer(width: CGFloat) -> UIView { - let spacer = UIView() - spacer.translatesAutoresizingMaskIntoConstraints = false - spacer.widthAnchor.constraint(equalToConstant: width).isActive = true - return spacer - } -} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift deleted file mode 100644 index f2234491..00000000 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// PhoneReportDetailVC.swift -// QuickLocation -// - -import UIKit -import RxSwift -import RxCocoa -import ObjectMapper - -final class PhoneReportDetailVC: BaseViewController { - - private var rootView: PhoneReportDetailView! - private let viewModel: PhoneReportDetailViewModel - private var groupModel: GroupModel? - - init(members: [GroupMemberModel], selectedUserId: String, groupModel: GroupModel?) { - self.viewModel = PhoneReportDetailViewModel( - members: members, - selectedUserId: selectedUserId, - groupKey: groupModel?.default_group_key ?? "" - ) - self.groupModel = groupModel - super.init(nibName: nil, bundle: nil) - } - - convenience init(userInfo: [String: Any]) { - let rawMembers = userInfo["members"] - let membersJson: [[String: Any]] - if let arr = rawMembers as? [[String: Any]] { - membersJson = arr - } else if let arr = rawMembers as? [Any] { - membersJson = arr.compactMap { $0 as? [String: Any] } - } else { - membersJson = [] - } - let members = membersJson.compactMap { GroupMemberModel(JSON: $0) } - let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? "" - let group = (userInfo["groupJson"] as? [String: Any]).flatMap { GroupModel(JSON: $0) } - self.init(members: members, selectedUserId: selected, groupModel: group) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func loadView() { - rootView = PhoneReportDetailView(frame: UIScreen.main.bounds) - view = rootView - } - - override func viewDidLoad() { - super.viewDidLoad() - rootView.memberCV.dataSource = self - rootView.memberCV.delegate = self - bind() - rootView.memberCV.reloadData() - DispatchQueue.main.async { [weak self] in - self?.rootView.updateMemberFadeVisibility() - } - } - - private func bind() { - rootView.navView.onBackTapped = { [weak self] in - self?.navigationController?.popViewController(animated: true) - } - - rootView.switchGroupBtn.rx.tap - .subscribe(onNext: { [weak self] in - self?.switchGroup() - }) - .disposed(by: disposeBag) - - viewModel.selectedMemberId - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] _ in - self?.rootView.memberCV.reloadData() - }) - .disposed(by: disposeBag) - - viewModel.snapshot - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] snap in - self?.rootView.apply(snap) - }) - .disposed(by: disposeBag) - - viewModel.selectedMemberId - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] _ in - self?.updatePrivilegeOverlay() - }) - .disposed(by: disposeBag) - - rootView.onRemind = { [weak self] in - guard let self else { return } - GroupService.sendPrivilegeRemind( - content: "请开启手机报告隐私设置", - groupKey: self.groupModel?.default_group_key ?? "", - userId: self.viewModel.selectedMemberId.value - ) - } - updatePrivilegeOverlay() - } - - private func updatePrivilegeOverlay() { - let userId = viewModel.selectedMemberId.value - let isSelf = userId == AppContextManager.shared.userId - let member = viewModel.members.first { $0.user_id == userId } - let denied = !isSelf && member?.extra.privilege.phone_report == false - rootView.setPrivilegeDenied(denied) - } - - private func switchGroup() { - guard let groupModel else { return } - GroupListPopView.show(groupModel: groupModel, - currentMembers: viewModel.members) { [weak self] groupKey in - guard let self, let key = groupKey else { return } - GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) - .subscribe(onNext: { [weak self] _ in - self?.navigationController?.popViewController(animated: true) - }) - .disposed(by: self.disposeBag) - } - } -} - -extension PhoneReportDetailVC: UICollectionViewDataSource, UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - viewModel.members.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) - let model = viewModel.members[indexPath.item] - let selected = model.user_id == viewModel.selectedMemberId.value - let isMe = model.user_id == AppContextManager.shared.userId - cell.configure(model: model, isCurrentUser: isMe, isSelected: selected) - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let model = viewModel.members[indexPath.item] - viewModel.selectedMemberId.accept(model.user_id) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard scrollView === rootView.memberCV else { return } - rootView.updateMemberFadeVisibility() - } -} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift deleted file mode 100644 index e6cf5991..00000000 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift +++ /dev/null @@ -1,747 +0,0 @@ -// -// PhoneReportDetailView.swift -// QuickLocation -// - -import UIKit -import Kingfisher - -final class PhoneReportDetailView: UIView { - - lazy var navView: BaseNavigationView = { - let nav = BaseNavigationView(title: "手机报告") - return nav - }() - - lazy var navBgView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "Common/navBar_bg_2") - iv.contentMode = .scaleAspectFill - return iv - }() - - let switchGroupBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.backgroundColor = .white.withAlphaComponent(0.8) - btn.setTitle(" 切换圈子 ", for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) - btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal) - btn.cornerRadius = 8 - btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 20, bottom: 15, right: 18) - return btn - }() - - lazy var memberCV: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.itemSize = CGSize(width: 61, height: 90) - layout.minimumLineSpacing = 12 - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.showsHorizontalScrollIndicator = false - cv.contentInset = UIEdgeInsets(top: 5, left: 5, bottom: 0, right: 5) - cv.register(GroupMemberListCell.self) -// cv.applyPhoneReportCardStyle(cornerRadius: 20) - return cv - }() - - let scrollView = UIScrollView() - let contentView = UIView() - private let memberTrailingFadeView = PhoneReportMemberTrailingFadeView() - - // MARK: Status - let statusTitleIcon = UIImageView(image: UIImage(named: "Home/report_phone")) - let statusTitleLab = UILabel() - let statusCard = UIView() - let batteryRing = PhoneBatteryRingView() - let estimateLab = UILabel() - let brightnessCard = PhoneStatusMiniCard(iconName: "Home/phone_brightness", title: "亮度") - let volumeCard = PhoneStatusMiniCard(iconName: "Home/phone_volume", title: "音量") - let wifiCard = PhoneStatusMiniCard(iconName: "Home/phone_wifi", title: "WIFI") - let deviceCard = UIView() - let deviceNameLab = UILabel() - let storageLab = UILabel() - let storageBarTrack = UIView() - let storageBar = UIProgressView(progressViewStyle: .default) - let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device")) - let brandLogoImage = UIImageView() - private var brandLogoURL = "" - private let statusDeniedOverlay = PrivilegeDeniedOverlay() - var onRemind: (() -> Void)? - - // MARK: Charts - let screenSection = PhoneReportChartSectionView( - title: "屏幕使用时间", - iconName: "Home/report_screen", - summaryPrefix: "今日使用时长", - summaryColor: UIColor(hexStr: "#00ADFE"), - barGradient: (UIColor(hexStr: "#C0EAFF"), UIColor(hexStr: "#ECF9FF")), - highlightGradient: (UIColor(hexStr: "#16B3FF"), UIColor(hexStr: "#9EDFFF")), - tipColor: UIColor(hexStr: "#20B6FF"), - tipArrowImageName: "Home/report_screen_tip_arrow" - ) - let unlockSection = PhoneReportChartSectionView( - title: "APP使用次数", - iconName: "Home/report_usage", - summaryPrefix: "今日使用次数", - summaryColor: UIColor(hexStr: "#68C025"), - barGradient: (UIColor(hexStr: "#DDFFA1"), UIColor(hexStr: "#F2FFDC")), - highlightGradient: (UIColor(hexStr: "#AAF52A"), UIColor(hexStr: "#E8FFC1")), - tipColor: UIColor(hexStr: "#86E73D"), - tipArrowImageName: "Home/report_usage_tip_arrow", - valueSuffix: "次" - ) - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = UIColor(hexStr: "#FAFAFA") - setupUI() - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - private func setupUI() { - addSubview(navBgView) - addSubview(navView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - - navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) -// navView.addRightButton(switchGroupBtn) - - addSubview(scrollView) - scrollView.showsVerticalScrollIndicator = false - scrollView.clipsToBounds = true - scrollView.delaysContentTouches = false - scrollView.canCancelContentTouches = true - scrollView.layoutChain - .topToBottomOfView(navView) - .edgesHorzontal() - .bottom() - scrollView.addSubview(contentView) - contentView.layoutChain.edges().widthToView(scrollView) - - let memberView = UIView() - memberView.backgroundColor = .white - memberView.applyPhoneReportCardStyle(cornerRadius: 20) - - contentView.addSubview(memberView) - memberView.addSubview(memberCV) - memberView.addSubview(memberTrailingFadeView) - - memberView.layoutChain - .top(8) - .edgesHorzontal(15) - .height(100) - - memberCV.layoutChain - .edgesVertical() - .edgesHorzontal() - - memberTrailingFadeView.layoutChain - .top() - .right() - .bottom() - .width(52) - - statusTitleIcon.contentMode = .scaleAspectFit - contentView.addSubview(statusTitleIcon) - statusTitleIcon.layoutChain - .topToBottomOfView(memberCV, offset: 12) - .left(15) - .width(18) - .height(18) - - statusTitleLab.text = "手机状态" - statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold) - statusTitleLab.textColor = UIColor(hexStr: "#293445") - contentView.addSubview(statusTitleLab) - statusTitleLab.layoutChain - .centerY(statusTitleIcon) - .leftToRightOfView(statusTitleIcon, offset: 6) - - statusCard.backgroundColor = .white - statusCard.applyPhoneReportCardStyle(cornerRadius: 20) - contentView.addSubview(statusCard) - statusCard.layoutChain - .topToBottomOfView(statusTitleIcon, offset: 10) - .edgesHorzontal(15) - .height(175) - statusCard.addSubview(statusDeniedOverlay) - statusDeniedOverlay.layoutChain.edges() - statusDeniedOverlay.layer.cornerRadius = 20 - - let leftPanel = UIView() - leftPanel.backgroundColor = .clear - leftPanel.cornerRadius = 14 - statusCard.addSubview(leftPanel) - leftPanel.layoutChain - .edges(excludingEdge: .right) - .width(136) - - leftPanel.addSubview(batteryRing) - batteryRing.layoutChain.top(23).centerX().width(100).height(100) - - estimateLab.font = .systemFont(ofSize: 12, weight: .medium) - estimateLab.textColor = UIColor(hexStr: "#293445") - estimateLab.textAlignment = .center - estimateLab.backgroundColor = UIColor(hexStr: "#FAFAFA") - estimateLab.cornerRadius = 5 - leftPanel.addSubview(estimateLab) - estimateLab.layoutChain - .topToBottomOfView(batteryRing, offset: 12) - .centerX() - .height(22) - .width(120) - - let rightStack = UIStackView(arrangedSubviews: [brightnessCard, volumeCard, wifiCard]) - rightStack.axis = .horizontal - rightStack.distribution = .fillEqually - rightStack.spacing = 8 - statusCard.addSubview(rightStack) - rightStack.layoutChain - .top(12) - .leftToRightOfView(leftPanel, offset: 10) - .right(12) - .height(64) - - deviceCard.backgroundColor = UIColor(hexStr: "#FAFAFA") - deviceCard.cornerRadius = 12 - statusCard.addSubview(deviceCard) - deviceCard.layoutChain - .topToBottomOfView(rightStack, offset: 8) - .leftToView(rightStack) - .right(12) - .bottom(12) - - deviceNameLab.font = .systemFont(ofSize: 13, weight: .bold) - deviceNameLab.textColor = UIColor(hexStr: "#293445") - deviceCard.addSubview(deviceNameLab) - deviceNameLab.layoutChain.top(10).left(10).right(60) - - storageLab.font = .systemFont(ofSize: 11, weight: .medium) - storageLab.textColor = UIColor(hexStr: "#6B7280") - deviceCard.addSubview(storageLab) - storageLab.layoutChain.topToBottomOfView(deviceNameLab, offset: 4).left(10) - - storageBarTrack.backgroundColor = UIColor(hexStr: "#E0E0E0") - storageBarTrack.layer.cornerRadius = 5 - storageBarTrack.clipsToBounds = true - deviceCard.addSubview(storageBarTrack) - storageBarTrack.layoutChain - .topToBottomOfView(storageLab, offset: 6) - .left(10) - .right(60) - .height(10) - .bottom(10) - - storageBar.progressTintColor = UIColor(hexStr: "#16B3FF") - storageBar.trackTintColor = UIColor(hexStr: "#16B3FF", alpha: 0.3) - storageBarTrack.addSubview(storageBar) - storageBar.layoutChain - .edgesHorzontal(3) - .centerY() - - deviceImage.contentMode = .scaleAspectFit - deviceCard.addSubview(deviceImage) - deviceImage.layoutChain.right(8).centerY().width(36).height(54) - - brandLogoImage.contentMode = .scaleAspectFit - brandLogoImage.isHidden = true - deviceImage.addSubview(brandLogoImage) - brandLogoImage.layoutChain.center().width(18).height(18) - - contentView.addSubview(screenSection) - screenSection.layoutChain - .topToBottomOfView(statusCard, offset: 16) - .edgesHorzontal(15) - - contentView.addSubview(unlockSection) - unlockSection.layoutChain - .topToBottomOfView(screenSection, offset: 16) - .edgesHorzontal(15) - .bottom(24) - } - - override func layoutSubviews() { - super.layoutSubviews() - updateMemberFadeVisibility() - } - - func updateMemberFadeVisibility() { - let rightInset = memberCV.adjustedContentInset.right - let maxOffsetX = max( - -memberCV.adjustedContentInset.left, - memberCV.contentSize.width - memberCV.bounds.width + rightInset - ) - memberTrailingFadeView.isHidden = memberCV.contentOffset.x >= maxOffsetX - 1 - || memberCV.contentSize.width <= memberCV.bounds.width - } - - func apply(_ snap: PhoneReportDetailViewModel.StatusSnapshot) { - batteryRing.configure(percent: snap.batteryPercent, text: snap.batteryText, isCharging: snap.isCharging) - estimateLab.text = "预计使用\(snap.estimateText)" - brightnessCard.setValue(snap.brightnessText) - volumeCard.setValue(snap.volumeText) - wifiCard.setValue(snap.wifiText) - deviceNameLab.text = snap.deviceText - storageLab.text = snap.storageText - storageBar.progress = Float(snap.storageRatio) - updateBrandLogo(urlString: snap.brandIconURL) - screenSection.setSummary(snap.screenTodayText) - unlockSection.setSummary(snap.appUsageTodayText) - screenSection.setBars(snap.screenBars) - unlockSection.setBars(snap.appUsageBars) - } - - func setPrivilegeDenied(_ denied: Bool) { - let title = "TA未开启设置" - let message = "无法查看手机状态" - let action = "提醒TA" - statusDeniedOverlay.configure( - title: title, - message: message, - actionTitle: action, - imageName: "PrivilegeDenied/report_status" - ) - statusDeniedOverlay.onAction = { [weak self] in self?.onRemind?() } - statusDeniedOverlay.setVisible(denied) - if denied { statusCard.bringSubviewToFront(statusDeniedOverlay) } - screenSection.setDenied(denied, imageName: "PrivilegeDenied/report_screen", onRemind: { [weak self] in - self?.onRemind?() - }) - unlockSection.setDenied(denied, imageName: "PrivilegeDenied/report_usage", onRemind: { [weak self] in - self?.onRemind?() - }) - } - - private func updateBrandLogo(urlString: String) { - brandLogoURL = urlString - brandLogoImage.kf.cancelDownloadTask() - brandLogoImage.image = nil - brandLogoImage.isHidden = true - - guard let url = URL(string: urlString), !urlString.isEmpty else { - return - } - - brandLogoImage.kf.setImage(with: url) { [weak self] result in - guard let self, self.brandLogoURL == urlString else { return } - switch result { - case .success: - self.brandLogoImage.isHidden = false - case .failure: - self.brandLogoImage.image = nil - self.brandLogoImage.isHidden = true - } - } - } -} - -private extension UIView { - func applyPhoneReportCardStyle(cornerRadius: CGFloat) { - layer.cornerRadius = cornerRadius - clipsToBounds = false - layer.masksToBounds = false - layer.shadowColor = UIColor(hexStr: "#E5E5E5", alpha: 0.3).cgColor - layer.shadowOffset = CGSize(width: 0, height: 0) - layer.shadowOpacity = 1 - layer.shadowRadius = 5 - } -} - -// MARK: - Subviews - -final class PhoneBatteryRingView: UIView { - private let track = CAShapeLayer() - private let progress = CAShapeLayer() - private let valueLab = UILabel() - private let bolt = UIImageView(image: UIImage(named: "Home/report_bolt")) - - override init(frame: CGRect) { - super.init(frame: frame) - track.fillColor = UIColor.clear.cgColor - track.strokeColor = UIColor(hexStr: "#DEFFD8").cgColor - track.lineWidth = 11 - layer.addSublayer(track) - progress.fillColor = UIColor.clear.cgColor - progress.strokeColor = UIColor(hexStr: "#27DB4B").cgColor - progress.lineWidth = 11 - progress.lineCap = .round - progress.strokeEnd = 0 - layer.addSublayer(progress) - - bolt.contentMode = .scaleAspectFit - addSubview(bolt) - valueLab.font = .systemFont(ofSize: 16, weight: .heavy) - valueLab.textColor = UIColor(hexStr: "#293445") - valueLab.textAlignment = .center - addSubview(valueLab) - valueLab.layoutChain.centerX().centerY() - bolt.layoutChain - .centerY(valueLab) - .rightToLeftOfView(valueLab, offset: -2) - .width(12) - .height(14) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layoutSubviews() { - super.layoutSubviews() - let center = CGPoint(x: bounds.midX, y: bounds.midY) - let radius = min(bounds.width, bounds.height) / 2 - 6 - let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: true) - track.path = path.cgPath - progress.path = path.cgPath - } - - func configure(percent: Int?, text: String, isCharging: Bool = false) { - valueLab.text = text - bolt.isHidden = !isCharging - progress.strokeEnd = CGFloat(max(0, min(100, percent ?? 0))) / 100 - } -} - -final class PhoneStatusMiniCard: UIView { - private let icon = UIImageView() - private let titleLab = UILabel() - private let valueLab = UILabel() - - init(iconName: String, title: String) { - super.init(frame: .zero) - backgroundColor = UIColor(hexStr: "#FAFAFA") - cornerRadius = 12 - icon.image = UIImage(named: iconName) - icon.contentMode = .scaleAspectFit - addSubview(icon) - icon.layoutChain.top(7).left(5).width(16).height(16) - titleLab.text = title - titleLab.font = .systemFont(ofSize: 10, weight: .medium) - titleLab.textColor = UIColor(hexStr: "#AAAAAA") - titleLab.textAlignment = .center - addSubview(titleLab) - titleLab.layoutChain.leftToRightOfView(icon, offset: 4).right(5, relation: .greaterThanOrEqual).centerY(icon) - valueLab.font = .systemFont(ofSize: 16, weight: .medium) - valueLab.textColor = UIColor(hexStr: "#293445") - valueLab.textAlignment = .center - addSubview(valueLab) - valueLab.layoutChain.topToBottomOfView(icon, offset: 10).edgesHorzontal(2) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func setValue(_ text: String) { - valueLab.text = text - } -} - -final class PhoneReportChartSectionView: UIView { - let summaryPrefix: String - private let titleLab = UILabel() - private let summaryPrefixLab = UILabel() - private let summaryLab = UILabel() - private let chart = PhoneReportBarChartView() - private let summaryColor: UIColor - private let barGradient: (UIColor, UIColor) - private let highlightGradient: (UIColor, UIColor) - private let tipColor: UIColor - private let tipArrowImageName: String - private let valueSuffix: String - private let card = UIView() - private let deniedOverlay = PrivilegeDeniedOverlay() - - init( - title: String, - iconName: String, - summaryPrefix: String, - summaryColor: UIColor, - barGradient: (UIColor, UIColor), - highlightGradient: (UIColor, UIColor), - tipColor: UIColor, - tipArrowImageName: String, - valueSuffix: String = "" - ) { - self.summaryPrefix = summaryPrefix - self.summaryColor = summaryColor - self.barGradient = barGradient - self.highlightGradient = highlightGradient - self.tipColor = tipColor - self.tipArrowImageName = tipArrowImageName - self.valueSuffix = valueSuffix - super.init(frame: .zero) - let titleIcon = UIImageView(image: UIImage(named: iconName)) - titleIcon.contentMode = .scaleAspectFit - addSubview(titleIcon) - titleIcon.layoutChain.top().left().width(18).height(18) - titleLab.text = title - titleLab.font = .systemFont(ofSize: 16, weight: .bold) - titleLab.textColor = UIColor(hexStr: "#293445") - addSubview(titleLab) - titleLab.layoutChain.centerY(titleIcon).leftToRightOfView(titleIcon, offset: 6) - - card.backgroundColor = .white - card.applyPhoneReportCardStyle(cornerRadius: 20) - addSubview(card) - card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180) - card.addSubview(deniedOverlay) - deniedOverlay.layoutChain.edges() - deniedOverlay.layer.cornerRadius = 20 - - summaryPrefixLab.text = summaryPrefix - summaryPrefixLab.font = .systemFont(ofSize: 12, weight: .medium) - summaryPrefixLab.textColor = UIColor(hexStr: "#293445") - summaryPrefixLab.setContentCompressionResistancePriority(.required, for: .horizontal) - card.addSubview(summaryPrefixLab) - summaryPrefixLab.layoutChain.top(14).left(14) - - summaryLab.font = .systemFont(ofSize: 12, weight: .bold) - summaryLab.textColor = summaryColor - summaryLab.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - card.addSubview(summaryLab) - summaryLab.layoutChain - .centerY(summaryPrefixLab) - .leftToRightOfView(summaryPrefixLab, offset: 4) - .right(14, relation: .greaterThanOrEqual) - - card.addSubview(chart) - chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).bottom(14) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func setDenied(_ denied: Bool, imageName: String, onRemind: (() -> Void)?) { - deniedOverlay.configure( - title: "TA未开启设置", - message: "无法查看手机状态", - actionTitle: "提醒TA", - imageName: imageName - ) - deniedOverlay.onAction = onRemind - deniedOverlay.setVisible(denied) - if denied { card.bringSubviewToFront(deniedOverlay) } - } - - func setSummary(_ text: String) { - summaryLab.text = text - } - - func setBars(_ bars: [PhoneReportDetailViewModel.ChartBar]) { - chart.configure( - bars: bars, - barGradient: barGradient, - highlightGradient: highlightGradient, - tipColor: tipColor, - tipArrowImageName: tipArrowImageName, - suffix: valueSuffix - ) - } -} - -final class PhoneReportBarChartView: UIView { - private var bars: [PhoneReportDetailViewModel.ChartBar] = [] - private var barGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue) - private var highlightGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue) - private var tipColor: UIColor = .systemBlue - private var suffix = "" - private var barLayers: [CALayer] = [] - private var labelViews: [UILabel] = [] - private var barFrames: [CGRect] = [] - private var selectedIndex: Int? - private var barSpacing: CGFloat = 0 - private var baselineY: CGFloat = 0 - private let tipLab = UILabel() - private let tipArrowView = UIImageView() - private let baseline = UIView() - - override init(frame: CGRect) { - super.init(frame: frame) - clipsToBounds = false - isUserInteractionEnabled = true - baseline.backgroundColor = UIColor(hexStr: "#F2F2F2") - addSubview(baseline) - tipLab.font = .systemFont(ofSize: 11, weight: .bold) - tipLab.textColor = .white - tipLab.textAlignment = .center - tipLab.cornerRadius = 6 - tipLab.clipsToBounds = true - tipLab.isHidden = true - tipLab.isUserInteractionEnabled = false - addSubview(tipLab) - tipArrowView.contentMode = .scaleAspectFit - tipArrowView.isHidden = true - tipArrowView.isUserInteractionEnabled = false - addSubview(tipArrowView) - - let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) - addGestureRecognizer(tap) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func configure( - bars: [PhoneReportDetailViewModel.ChartBar], - barGradient: (UIColor, UIColor), - highlightGradient: (UIColor, UIColor), - tipColor: UIColor, - tipArrowImageName: String, - suffix: String - ) { - let previous = self.bars - self.bars = bars - self.barGradient = barGradient - self.highlightGradient = highlightGradient - self.tipColor = tipColor - tipArrowView.image = UIImage(named: tipArrowImageName) - self.suffix = suffix - let dataChanged = previous.count != bars.count - || zip(previous, bars).contains { $0.label != $1.label || $0.value != $1.value } - if dataChanged || selectedIndex == nil { - selectedIndex = bars.firstIndex(where: \.isToday) ?? (bars.isEmpty ? nil : bars.count - 1) - } - setNeedsLayout() - } - - @objc private func handleTap(_ gesture: UITapGestureRecognizer) { - let point = gesture.location(in: self) - guard !barFrames.isEmpty else { return } - for (i, frame) in barFrames.enumerated() { - let hitX = frame.minX - barSpacing / 2 - let hitW = frame.width + barSpacing - let hit = CGRect(x: hitX, y: 0, width: hitW, height: max(baselineY, bounds.height)) - if hit.contains(point) { - guard selectedIndex != i else { return } - selectedIndex = i - setNeedsLayout() - return - } - } - } - - override func layoutSubviews() { - super.layoutSubviews() - barLayers.forEach { $0.removeFromSuperlayer() } - barLayers.removeAll() - labelViews.forEach { $0.removeFromSuperview() } - labelViews.removeAll() - barFrames.removeAll() - tipLab.isHidden = true - tipArrowView.isHidden = true - guard !bars.isEmpty else { - baseline.isHidden = true - return - } - baseline.isHidden = false - - let maxVal = max(bars.map(\.value).max() ?? 1, 1) - let count = bars.count - let labelH: CGFloat = 16 - let tipVerticalPadding: CGFloat = 8 - let tipArrowHeight: CGFloat = 5 - let tipArrowBarSpacing: CGFloat = 6 - let tipHeight = ceil(tipLab.font.lineHeight) + tipVerticalPadding - let chartH = max(4, bounds.height - labelH - tipHeight - tipArrowHeight - tipArrowBarSpacing) - let barW: CGFloat = 16 - barSpacing = count > 1 - ? max(0, (bounds.width - barW * CGFloat(count)) / CGFloat(count - 1)) - : 0 - baselineY = bounds.height - labelH - baseline.frame = CGRect(x: 0, y: baselineY - 0.5, width: bounds.width, height: 1) - - if selectedIndex == nil || selectedIndex! >= count { - selectedIndex = bars.firstIndex(where: \.isToday) ?? count - 1 - } - - for (i, bar) in bars.enumerated() { - let ratio = CGFloat(bar.value) / CGFloat(maxVal) - let h = max(4, chartH * max(0.05, ratio)) - let x = CGFloat(i) * (barW + barSpacing) - let y = baselineY - h - let barFrame = CGRect(x: x, y: y, width: barW, height: h) - barFrames.append(barFrame) - - let isSelected = i == selectedIndex - let gradient = CAGradientLayer() - gradient.frame = barFrame - let colors = isSelected ? highlightGradient : barGradient - gradient.colors = [colors.0.cgColor, colors.1.cgColor] - gradient.startPoint = CGPoint(x: 0.5, y: 0) - gradient.endPoint = CGPoint(x: 0.5, y: 1) - gradient.cornerRadius = 4 - gradient.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - layer.insertSublayer(gradient, below: tipLab.layer) - barLayers.append(gradient) - - let lab = UILabel() - lab.text = bar.label - lab.font = .systemFont(ofSize: 10, weight: .medium) - lab.textColor = UIColor(hexStr: "#9CA3AF") - lab.textAlignment = .center - lab.frame = CGRect(x: x - 10, y: bounds.height - labelH, width: barW + 20, height: labelH) - insertSubview(lab, belowSubview: tipLab) - labelViews.append(lab) - - if isSelected { - tipLab.text = tipText(for: bar) - tipLab.backgroundColor = tipColor - tipLab.sizeToFit() - let tipW = tipLab.bounds.width + 5 + 5 - // 与柱子水平居中,允许超出 chart 左右边界 - tipLab.frame = CGRect( - x: x + barW / 2 - tipW / 2, - y: max(0, y - tipHeight - tipArrowHeight - tipArrowBarSpacing), - width: tipW, - height: tipHeight - ) - tipLab.isHidden = false - tipArrowView.frame = CGRect( - x: x + barW / 2 - 5, - y: tipLab.frame.maxY, - width: 10, - height: tipArrowHeight - ) - tipArrowView.isHidden = false - } - } - bringSubviewToFront(tipLab) - bringSubviewToFront(tipArrowView) - } - - private func tipText(for bar: PhoneReportDetailViewModel.ChartBar) -> String { - if suffix == "次" { - return "\(bar.value)次" - } - let hours = bar.value / 3600 - let mins = (bar.value % 3600) / 60 - if hours > 0 { - return mins > 0 ? "\(hours)小时\(mins)分" : "\(hours)小时" - } - return "\(mins)分钟" - } -} - -private final class PhoneReportMemberTrailingFadeView: UIView { - override class var layerClass: AnyClass { CAGradientLayer.self } - - override init(frame: CGRect) { - super.init(frame: frame) - isUserInteractionEnabled = false - let gradientLayer = layer as! CAGradientLayer - gradientLayer.colors = [ - UIColor.white.withAlphaComponent(0).cgColor, - UIColor.white.withAlphaComponent(0.88).cgColor, - UIColor.white.cgColor - ] - gradientLayer.locations = [0, 0.7, 1] - gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) - gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) - gradientLayer.cornerRadius = 20 - gradientLayer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift deleted file mode 100644 index f916b312..00000000 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift +++ /dev/null @@ -1,341 +0,0 @@ -// -// PhoneReportDetailViewModel.swift -// QuickLocation -// - -import Foundation -import RxSwift -import RxCocoa -import UIKit - -final class PhoneReportDetailViewModel { - - struct ChartBar { - let label: String - let value: Int - let isToday: Bool - } - - struct StatusSnapshot { - let batteryText: String - let batteryPercent: Int? - let isCharging: Bool - let estimateText: String - let brightnessText: String - let volumeText: String - let wifiText: String - let deviceText: String - let storageText: String - let storageRatio: CGFloat - let brandIconURL: String - let screenTodayText: String - let appUsageTodayText: String - let screenBars: [ChartBar] - let appUsageBars: [ChartBar] - } - - let members: [GroupMemberModel] - let selectedMemberId: BehaviorRelay - let snapshot: BehaviorRelay - - private let groupKey: String - private let disposeBag = DisposeBag() - private var statusObserveBag = DisposeBag() - private let placeholder = "**" - private var reportCache: [String: PhoneUsageReportModel] = [:] - private var requestIDs: [String: UUID] = [:] - - init(members: [GroupMemberModel], selectedUserId: String, groupKey: String) { - self.members = members - self.groupKey = groupKey - self.selectedMemberId = BehaviorRelay(value: selectedUserId) - self.snapshot = BehaviorRelay(value: Self.emptySnapshot()) - - selectedMemberId - .asObservable() - .distinctUntilChanged() - .subscribe(onNext: { [weak self] userId in - self?.refresh(userId: userId) - }) - .disposed(by: disposeBag) - } - - private func isCurrentUser(_ userId: String) -> Bool { - !userId.isEmpty && userId == AppContextManager.shared.userId - } - - private func refresh(userId: String) { - let isSelf = isCurrentUser(userId) - updateStatusObserving(isSelf: isSelf) - - guard !userId.isEmpty else { - snapshot.accept(Self.emptySnapshot()) - return - } - - if groupKey.isEmpty { - snapshot.accept(isSelf ? makeLocalStatusSnapshot(report: nil) : Self.emptySnapshot()) - return - } - - let cacheKey = "\(groupKey)|\(userId)" - let cached = reportCache[cacheKey] - if isSelf { - snapshot.accept(makeLocalStatusSnapshot(report: cached)) - } else if let cached { - snapshot.accept(makeRemoteSnapshot(report: cached)) - } else { - snapshot.accept(Self.emptySnapshot()) - } - - guard requestIDs[cacheKey] == nil else { return } - let requestID = UUID() - requestIDs[cacheKey] = requestID - - UserService.phoneUsageReport(userId: userId, groupKey: groupKey) - .subscribe(onNext: { [weak self] response in - guard let self, self.requestIDs[cacheKey] == requestID else { return } - self.requestIDs.removeValue(forKey: cacheKey) - guard response.code == "0", let report = response.model else { - guard self.selectedMemberId.value == userId, self.isCurrentUser(userId) else { return } - self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey])) - return - } - - self.reportCache[cacheKey] = report - guard self.selectedMemberId.value == userId else { return } - self.snapshot.accept(self.makeSnapshot(report: report, userId: userId)) - }, onError: { [weak self] _ in - guard let self, self.requestIDs[cacheKey] == requestID else { return } - self.requestIDs.removeValue(forKey: cacheKey) - guard self.selectedMemberId.value == userId, self.isCurrentUser(userId) else { return } - self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey])) - }) - .disposed(by: disposeBag) - } - - private func updateStatusObserving(isSelf: Bool) { - statusObserveBag = DisposeBag() - guard isSelf else { return } - PhoneStatusCollector.shared.start() - NotificationCenter.default.rx.notification(.phoneStatusDidChange) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] _ in - guard let self else { return } - let userId = self.selectedMemberId.value - guard self.isCurrentUser(userId) else { return } - let cacheKey = "\(self.groupKey)|\(userId)" - self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey])) - }) - .disposed(by: statusObserveBag) - } - - private func makeSnapshot(report: PhoneUsageReportModel, userId: String) -> StatusSnapshot { - if isCurrentUser(userId) { - return makeLocalStatusSnapshot(report: report) - } - return makeRemoteSnapshot(report: report) - } - - private func makeLocalStatusSnapshot(report: PhoneUsageReportModel?) -> StatusSnapshot { - let local = PhoneStatusCollector.shared.snapshot() - let charts = chartFields(from: report) - return StatusSnapshot( - batteryText: local.batteryPercent == nil ? placeholder : local.batteryText, - batteryPercent: local.batteryPercent, - isCharging: local.isCharging, - estimateText: Self.estimatedUsageText( - batteryPercent: local.batteryPercent, - placeholder: placeholder - ), - brightnessText: local.brightnessText, - volumeText: local.volumeText, - wifiText: local.network, - deviceText: local.model, - storageText: local.memoryUsed == nil ? placeholder : local.storageText, - storageRatio: local.storageRatio, - brandIconURL: Self.validHTTPURLString(report?.brandIcon ?? "") ?? "", - screenTodayText: charts.screenTodayText, - appUsageTodayText: charts.appUsageTodayText, - screenBars: charts.screenBars, - appUsageBars: charts.appUsageBars - ) - } - - private func makeRemoteSnapshot(report: PhoneUsageReportModel) -> StatusSnapshot { - let info = report.phoneInfo - let charts = chartFields(from: report) - let batteryPercent = Self.percent(from: info?.battery) - return StatusSnapshot( - batteryText: Self.displayText(info?.battery, placeholder: placeholder), - batteryPercent: batteryPercent, - isCharging: false, - estimateText: Self.estimatedUsageText( - batteryPercent: batteryPercent, - placeholder: placeholder - ), - brightnessText: Self.displayText(info?.brightness, placeholder: placeholder), - volumeText: Self.displayText(info?.volume, placeholder: placeholder), - wifiText: Self.displayText(info?.network, placeholder: placeholder), - deviceText: Self.displayText(info?.model, placeholder: placeholder), - storageText: Self.storageText(used: info?.memoryUsed, total: info?.memoryTotal, placeholder: placeholder), - storageRatio: Self.storageRatio(used: info?.memoryUsed, total: info?.memoryTotal), - brandIconURL: Self.validHTTPURLString(report.brandIcon) ?? "", - screenTodayText: charts.screenTodayText, - appUsageTodayText: charts.appUsageTodayText, - screenBars: charts.screenBars, - appUsageBars: charts.appUsageBars - ) - } - - private func chartFields(from report: PhoneUsageReportModel?) -> ( - screenTodayText: String, - appUsageTodayText: String, - screenBars: [ChartBar], - appUsageBars: [ChartBar] - ) { - guard let report else { - return (placeholder, placeholder, [], []) - } - let info = report.phoneInfo - let screenBars = Self.makeBars(from: report.screenUseTimes, value: { $0.useTime }) - let appUsageBars = Self.makeBars(from: report.appUseCounts, value: { $0.count }) - let todayScreenSeconds = Self.todayValue(in: report.screenUseTimes, value: { $0.useTime }) - ?? Self.nonnegativeInt(from: info?.useTime) - let todayAppUsageCount = Self.todayValue(in: report.appUseCounts, value: { $0.count }) - let screenTodayText = todayScreenSeconds.map { Self.formatDuration($0) } ?? placeholder - let appUsageTodayText = todayAppUsageCount.map { "\($0)次" } ?? placeholder - return (screenTodayText, appUsageTodayText, screenBars, appUsageBars) - } - - private static func emptySnapshot() -> StatusSnapshot { - StatusSnapshot( - batteryText: "**", - batteryPercent: nil, - isCharging: false, - estimateText: "**", - brightnessText: "**", - volumeText: "**", - wifiText: "**", - deviceText: "**", - storageText: "**", - storageRatio: 0, - brandIconURL: "", - screenTodayText: "**", - appUsageTodayText: "**", - screenBars: [], - appUsageBars: [] - ) - } - - private static func makeBars( - from entries: [PhoneUsageReportDayModel], - value: (PhoneUsageReportDayModel) -> Int? - ) -> [ChartBar] { - entries.compactMap { entry -> (Date, Int)? in - guard let date = reportDateFormatter.date(from: entry.day), - let rawValue = value(entry) else { return nil } - return (date, max(0, rawValue)) - } - .sorted { $0.0 < $1.0 } - .map { date, value in - ChartBar( - label: chartDateFormatter.string(from: date), - value: value, - isToday: Calendar.current.isDateInToday(date) - ) - } - } - - private static func todayValue( - in entries: [PhoneUsageReportDayModel], - value: (PhoneUsageReportDayModel) -> Int? - ) -> Int? { - for entry in entries { - guard let date = reportDateFormatter.date(from: entry.day), - Calendar.current.isDateInToday(date), - let rawValue = value(entry) else { continue } - return max(0, rawValue) - } - return nil - } - - private static let reportDateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = Calendar(identifier: .gregorian) - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() - - private static let chartDateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = Calendar(identifier: .gregorian) - formatter.timeZone = TimeZone.current - formatter.dateFormat = "M/d" - return formatter - }() - - private static func displayText(_ value: String?, placeholder: String) -> String { - let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return text.isEmpty ? placeholder : text - } - - private static func nonnegativeInt(from value: String?) -> Int? { - guard let value, - let number = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } - return max(0, number) - } - - private static func percent(from value: String?) -> Int? { - guard let number = firstNumber(in: value) else { return nil } - return max(0, min(100, Int(number.rounded()))) - } - - private static func estimatedUsageText(batteryPercent: Int?, placeholder: String) -> String { - guard let batteryPercent else { return placeholder } - let estimatedMinutes = batteryPercent * 9 - let hours = max(1, Int((Double(estimatedMinutes) / 60).rounded())) - return "\(hours)小时" - } - - private static func storageRatio(used: String?, total: String?) -> CGFloat { - guard let usedValue = firstNumber(in: used), - let totalValue = firstNumber(in: total), - totalValue > 0 else { return 0 } - return CGFloat(max(0, min(1, usedValue / totalValue))) - } - - private static func storageText(used: String?, total: String?, placeholder: String) -> String { - let usedText = used?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let totalText = total?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !usedText.isEmpty, !totalText.isEmpty else { return placeholder } - return "\(usedText)/\(totalText)" - } - - private static func firstNumber(in value: String?) -> Double? { - guard let value, - let range = value.range(of: #"\d+(?:\.\d+)?"#, options: .regularExpression) else { return nil } - return Double(value[range]) - } - - private static func validHTTPURLString(_ value: String) -> String? { - let text = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard let components = URLComponents(string: text), - let scheme = components.scheme?.lowercased(), - scheme == "http" || scheme == "https", - components.host != nil else { return nil } - return text - } - - private static func formatDuration(_ seconds: Int) -> String { - let hours = seconds / 3600 - let minutes = (seconds % 3600) / 60 - if hours > 0 { - return minutes > 0 ? "\(hours)小时\(minutes)分" : "\(hours)小时" - } - return "\(minutes)分钟" - } -} diff --git a/QuickLocation/Section/Launch/LaunchPrivacyConsentView.swift b/QuickLocation/Section/Launch/LaunchPrivacyConsentView.swift new file mode 100644 index 00000000..82517ed4 --- /dev/null +++ b/QuickLocation/Section/Launch/LaunchPrivacyConsentView.swift @@ -0,0 +1,222 @@ +// +// LaunchPrivacyConsentView.swift +// QuickLocation +// + +import UIKit + +final class LaunchPrivacyConsentView: UIView, UITextViewDelegate { + + var onAgree: (() -> Void)? + var onDisagree: (() -> Void)? + var onOpenURL: ((String) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + configureBody() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show(in host: UIView) { + frame = host.bounds + autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.addSubview(self) + layoutIfNeeded() + dimView.alpha = 0 + card.transform = CGAffineTransform(translationX: 0, y: bounds.height * 0.4) + UIView.animate(withDuration: 0.32, delay: 0, options: [.curveEaseOut]) { + self.dimView.alpha = 1 + self.card.transform = .identity + } + } + + private func setupUI() { + addSubview(dimView) + addSubview(card) + card.addSubview(gradientView) + card.addSubview(titleLab) + card.addSubview(bodyTextView) + card.addSubview(agreeBtn) + card.addSubview(disagreeBtn) + + dimView.layoutChain.edges() + + card.layoutChain + .edgesHorzontal() + .bottom() + + gradientView.layoutChain + .top() + .edgesHorzontal() + .height(120) + + titleLab.layoutChain + .top(28) + .edgesHorzontal(24) + .height(28) + + bodyTextView.layoutChain + .topToBottomOfView(titleLab, offset: 16) + .edgesHorzontal(24) + + agreeBtn.layoutChain + .topToBottomOfView(bodyTextView, offset: 22) + .edgesHorzontal(30) + .height(56) + + disagreeBtn.layoutChain + .topToBottomOfView(agreeBtn, offset: 12) + .centerX() + .bottom(kSafeBottomMargin + 8) + .height(24) + + agreeBtn.addTarget(self, action: #selector(handleAgree), for: .touchUpInside) + disagreeBtn.addTarget(self, action: #selector(handleDisagree), for: .touchUpInside) + } + + private func configureBody() { + let full = """ +请你知悉,为保障为您正常提供相应服务,当您在未登录/未注册状态下进行浏览和搜索时,需要开启以下权限: +1.个人信息类型:设备信息(包括设备型号、设备标识符)。 +2.位置权限(始终允许),您同意开启设备定位服务后,我们会通过GPS或WiFi等方式持续收集您的地理位置信息,将用于为您记录位置信息和为您的好友发送位置信息。您可以通过关闭手机定位服务,停止我们对您的地理位置信息进行收集,同时我们将对您的好友停止更新您的位置。 +想了解更多服务协议、隐私内容和使用第三方SDK详情,请阅读我们的《用户服务协议》和《隐私政策》。 +""" + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 5 + paragraph.paragraphSpacing = 8 + + let attributed = NSMutableAttributedString( + string: full, + attributes: [ + .font: UIFont.systemFont(ofSize: 14, weight: .regular), + .foregroundColor: UIColor(hexStr: "#333333"), + .paragraphStyle: paragraph + ] + ) + let agreementRange = (full as NSString).range(of: "《用户服务协议》") + let privacyRange = (full as NSString).range(of: "《隐私政策》") + if agreementRange.location != NSNotFound { + attributed.addAttributes([ + .foregroundColor: UIColor(hexStr: "#16B3FF"), + .link: "UserAgreement" + ], range: agreementRange) + } + if privacyRange.location != NSNotFound { + attributed.addAttributes([ + .foregroundColor: UIColor(hexStr: "#16B3FF"), + .link: "PrivacyPolicy" + ], range: privacyRange) + } + bodyTextView.attributedText = attributed + bodyTextView.linkTextAttributes = [ + .foregroundColor: UIColor(hexStr: "#16B3FF"), + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + bodyTextView.delegate = self + } + + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + switch URL.absoluteString { + case "UserAgreement": + onOpenURL?(URLManager.shared.userAgreementUrl) + case "PrivacyPolicy": + onOpenURL?(URLManager.shared.privacyPolicyUrl) + default: + break + } + return false + } + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { false } + + @objc private func handleAgree() { + onAgree?() + } + + @objc private func handleDisagree() { + onDisagree?() + } + + private lazy var dimView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.45) + return view + }() + + private lazy var card: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 28 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.clipsToBounds = true + return view + }() + + private lazy var gradientView: LaunchPrivacyGradientView = { + let view = LaunchPrivacyGradientView() + view.isUserInteractionEnabled = false + return view + }() + + private lazy var titleLab: UILabel = { + let label = UILabel() + label.text = "服务协议及隐私政策" + label.font = .systemFont(ofSize: 20, weight: .semibold) + label.textColor = UIColor(hexStr: "#1A1A1A") + label.textAlignment = .center + return label + }() + + private lazy var bodyTextView: UITextView = { + let textView = UITextView() + textView.backgroundColor = .clear + textView.isEditable = false + textView.isScrollEnabled = false + textView.isSelectable = true + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + return textView + }() + + private lazy var agreeBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.cornerRadius = 20 + btn.setTitle("同意并进入", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = FontManager.boboBold(18) + btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + return btn + }() + + private lazy var disagreeBtn: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("不同意", for: .normal) + btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .regular) + return btn + }() +} + +private final class LaunchPrivacyGradientView: UIView { + override class var layerClass: AnyClass { CAGradientLayer.self } + + override func layoutSubviews() { + super.layoutSubviews() + guard let layer = layer as? CAGradientLayer else { return } + layer.colors = [ + UIColor(hexStr: "#D9F8F0").cgColor, + UIColor.white.cgColor + ] + layer.startPoint = CGPoint(x: 0.5, y: 0) + layer.endPoint = CGPoint(x: 0.5, y: 1) + } +} diff --git a/QuickLocation/Section/Launch/LaunchViewController.swift b/QuickLocation/Section/Launch/LaunchViewController.swift index 9f7eed87..f0a46a41 100644 --- a/QuickLocation/Section/Launch/LaunchViewController.swift +++ b/QuickLocation/Section/Launch/LaunchViewController.swift @@ -13,15 +13,62 @@ import RxCocoa class LaunchViewController: BaseViewController { private let showVersion = 1 + private var privacyConsentView: LaunchPrivacyConsentView? override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view. view.backgroundColor = .white setupLayout() + if Defaults[\.didAgreeLaunchPrivacy] { + proceedAfterPrivacyAgreed() + } else { + showPrivacyConsentIfNeeded() + } + } + + private func proceedAfterPrivacyAgreed() { + AppDelegate.shared.setupPrivacyGrantedSDKs() getUserConfig() } + + private func showPrivacyConsentIfNeeded() { + let consent = LaunchPrivacyConsentView() + privacyConsentView = consent + consent.onAgree = { [weak self] in + Defaults[\.didAgreeLaunchPrivacy] = true + self?.privacyConsentView?.removeFromSuperview() + self?.privacyConsentView = nil + self?.proceedAfterPrivacyAgreed() + } + consent.onDisagree = { [weak self] in + self?.handlePrivacyDisagree() + } + consent.onOpenURL = { [weak self] url in + self?.openPrivacyWeb(url) + } + consent.show(in: view) + } + + private func handlePrivacyDisagree() { + let alert = UIAlertController( + title: "需同意协议后才能使用", + message: "若不同意服务协议及隐私政策,将无法进入极速定位。", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "退出应用", style: .destructive) { _ in + exit(0) + }) + alert.addAction(UIAlertAction(title: "返回", style: .cancel)) + present(alert, animated: true) + } + + private func openPrivacyWeb(_ url: String) { + let vc = WebViewController(url: url, isShare: false, fullscreen: false, style: "default") + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + } /// 获取用户配置 func getUserConfig() { @@ -45,52 +92,11 @@ class LaunchViewController: BaseViewController { DLAlert.show(title: error.localizedDescription, defaultTitle: "重试") { [weak self] in guard let this = self else { return } -// #if DEBUG || AdHoc -// this.showServerSwitchAlert() -// #else this.getUserConfig() -// #endif } }).disposed(by: disposeBag) } - private func showServerSwitchAlert() { - let current = URLManager.shared.apiServerURL - let alert = UIAlertController( - title: "切换接口", - message: "当前:\(current)", - preferredStyle: .alert - ) - alert.addTextField { field in - field.placeholder = "自定义接口地址" - field.text = current - field.keyboardType = .URL - field.autocorrectionType = .no - field.autocapitalizationType = .none - } - for preset in URLManager.presetServers { - alert.addAction(UIAlertAction(title: preset.title, style: .default) { [weak self] _ in - self?.applyServer(url: preset.url, env: preset.env) - }) - } - alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in - let typed = alert.textFields?.first?.text ?? "" - self?.applyServer(url: typed, env: nil) - }) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(alert, animated: true) - } - - private func applyServer(url: String, env: Int?) { - let normalized = URLManager.normalizedServerURL(url) - guard !normalized.isEmpty else { - DLToast.show(text: "请输入接口地址") - return - } - URLManager.shared.switchServer(url: normalized, env: env) - AppDelegate.shared.showMainViewController() - } - private func continueAfterConfig(model: UserConfigModel) { AppDelegate.shared.showMainViewController() } diff --git a/QuickLocation/Section/Login/LoginView.swift b/QuickLocation/Section/Login/LoginView.swift index 8344608e..2e6db792 100644 --- a/QuickLocation/Section/Login/LoginView.swift +++ b/QuickLocation/Section/Login/LoginView.swift @@ -457,7 +457,7 @@ class LoginView: UIView { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "Login/checkbox"), for: .normal) btn.setImage(UIImage(named: "Login/selected"), for: .selected) - btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 150, bottom: 150, right: 50) + btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 150, bottom: 50, right: 50) return btn }() diff --git a/QuickLocation/Section/Login/LoginViewModel.swift b/QuickLocation/Section/Login/LoginViewModel.swift index f93c8f4b..5b310a11 100644 --- a/QuickLocation/Section/Login/LoginViewModel.swift +++ b/QuickLocation/Section/Login/LoginViewModel.swift @@ -123,11 +123,19 @@ final class LoginViewModel: BaseViewModel { #endif func performWechatLogin() { + guard WXApi.isWXAppInstalled() else { + DLToast.showError(text: "请先安装微信") + return + } NotificationCenter.default.post(name: .pausePopupQueue, object: nil) let req = SendAuthReq() req.scope = "snsapi_userinfo" req.state = "login" - WXApi.send(req) + WXApi.send(req) { success in + if !success { + DLToast.showError(text: "无法打开微信") + } + } } func performAppleLogin() { diff --git a/QuickLocation/Section/Mine/About/AboutVC.swift b/QuickLocation/Section/Mine/About/AboutVC.swift index 63fa18f9..0e3cb438 100644 --- a/QuickLocation/Section/Mine/About/AboutVC.swift +++ b/QuickLocation/Section/Mine/About/AboutVC.swift @@ -10,8 +10,6 @@ import RxCocoa final class AboutVC: BaseViewController { private var rootView: AboutView! - private var logoTapCount = 0 - private var logoTapResetWork: DispatchWorkItem? override func loadView() { rootView = AboutView(frame: UIScreen.main.bounds) @@ -28,9 +26,6 @@ final class AboutVC: BaseViewController { self?.navigationController?.popViewController(animated: true) } - let logoTap = UITapGestureRecognizer(target: self, action: #selector(handleLogoTap)) - rootView.logoImage.addGestureRecognizer(logoTap) - rootView.feedbackBtn.rx.controlEvent(.touchUpInside) .subscribe(onNext: { _ in AppRouter.push(Route.feedback) @@ -62,60 +57,6 @@ final class AboutVC: BaseViewController { .disposed(by: disposeBag) } - @objc private func handleLogoTap() { -#if DEBUG || AdHoc - logoTapResetWork?.cancel() - logoTapCount += 1 - if logoTapCount >= 5 { - logoTapCount = 0 - showServerSwitchAlert() - return - } - let work = DispatchWorkItem { [weak self] in - self?.logoTapCount = 0 - } - logoTapResetWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: work) -#endif - } - - private func showServerSwitchAlert() { - let current = URLManager.shared.apiServerURL - let alert = UIAlertController( - title: "切换接口", - message: "当前:\(current)", - preferredStyle: .alert - ) - alert.addTextField { field in - field.placeholder = "自定义接口地址" - field.text = current - field.keyboardType = .URL - field.autocorrectionType = .no - field.autocapitalizationType = .none - } - for preset in URLManager.presetServers { - alert.addAction(UIAlertAction(title: preset.title, style: .default) { [weak self] _ in - self?.applyServer(url: preset.url, env: preset.env) - }) - } - alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in - let typed = alert.textFields?.first?.text ?? "" - self?.applyServer(url: typed, env: nil) - }) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(alert, animated: true) - } - - private func applyServer(url: String, env: Int?) { - let normalized = URLManager.normalizedServerURL(url) - guard !normalized.isEmpty else { - DLToast.show(text: "请输入接口地址") - return - } - URLManager.shared.switchServer(url: normalized, env: env) - AppDelegate.shared.showMainViewController() - } - private static func openAppStoreReview() { // Bundle 未配置 appId 时给提示;有则跳转写评价页 let appId = AppSettings.shared.appId diff --git a/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift b/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift index 935f5849..59bd388d 100644 --- a/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift +++ b/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift @@ -8,14 +8,11 @@ import RxSwift import Kingfisher enum PrivacySettingItem: CaseIterable { - case phoneReport case groupLock case pigeonMessage var title: String { switch self { - case .phoneReport: - return "手机报告" case .groupLock: return "一键锁机" case .pigeonMessage: @@ -25,8 +22,6 @@ enum PrivacySettingItem: CaseIterable { var imageName: String { switch self { - case .phoneReport: - return "PrivacySettings/phone_report" case .groupLock: return "PrivacySettings/group_lock" case .pigeonMessage: @@ -36,8 +31,6 @@ enum PrivacySettingItem: CaseIterable { var apiKey: String { switch self { - case .phoneReport: - return "phone_report" case .groupLock: return "app_lock" case .pigeonMessage: @@ -208,8 +201,6 @@ final class PrivacySettingsViewController: BaseViewController { let previous = groups[index].privilege var updated = previous switch item { - case .phoneReport: - updated.phone_report = isOn case .groupLock: updated.app_lock = isOn case .pigeonMessage: @@ -266,7 +257,6 @@ final class PrivacySettingsView: UIView { private let scrollView = UIScrollView() private let contentView = UIView() private let groupSelectorView = PrivacyGroupSelectorView() - private let phoneReportRow = PrivacySettingRow(item: .phoneReport) private let groupLockRow = PrivacySettingRow(item: .groupLock) private let pigeonMessageRow = PrivacySettingRow(item: .pigeonMessage) private let footerLabel = UILabel() @@ -286,13 +276,12 @@ final class PrivacySettingsView: UIView { } func apply(_ privilege: GroupPrivilege) { - phoneReportRow.setOn(privilege.phone_report) groupLockRow.setOn(privilege.app_lock) pigeonMessageRow.setOn(privilege.pigeon_mail) } func setSettingsEnabled(_ enabled: Bool) { - [phoneReportRow, groupLockRow, pigeonMessageRow].forEach { + [groupLockRow, pigeonMessageRow].forEach { $0.setSwitchEnabled(enabled) } } @@ -318,10 +307,6 @@ final class PrivacySettingsView: UIView { contentView.addSubview(groupSelectorView) - let shareHeader = makeSectionHeader(title: "圈子中可以共享的数据") - contentView.addSubview(shareHeader) - contentView.addSubview(phoneReportRow) - let ownerHeader = makeSectionHeader(title: "圈主可以对你使用的功能") contentView.addSubview(ownerHeader) contentView.addSubview(groupLockRow) @@ -357,20 +342,8 @@ final class PrivacySettingsView: UIView { .right(15) .height(100) - shareHeader.layoutChain - .topToBottomOfView(groupSelectorView, offset: 20) - .left(17) - .right(15) - .height(24) - - phoneReportRow.layoutChain - .topToBottomOfView(shareHeader, offset: 12) - .left(15) - .right(15) - .height(64) - ownerHeader.layoutChain - .topToBottomOfView(phoneReportRow, offset: 18) + .topToBottomOfView(groupSelectorView, offset: 20) .left(17) .right(15) .height(24) @@ -391,9 +364,6 @@ final class PrivacySettingsView: UIView { groupSelectorView.onSelectionChanged = { [weak self] in self?.onGroupChanged?() } - phoneReportRow.onToggle = { [weak self] isOn in - self?.onToggle?(.phoneReport, isOn) - } groupLockRow.onToggle = { [weak self] isOn in self?.onToggle?(.groupLock, isOn) } diff --git a/QuickLocation/Service/UserService.swift b/QuickLocation/Service/UserService.swift index d604f8bb..119f3961 100644 --- a/QuickLocation/Service/UserService.swift +++ b/QuickLocation/Service/UserService.swift @@ -62,14 +62,6 @@ struct UserService { .asObservable() } - /// 指定圈子成员的手机使用报告 - static func phoneUsageReport(userId: String, groupKey: String) -> Observable { - let api = UserAPI.phoneUsageReport(userId: userId, groupKey: groupKey).multiTarget - return APIProvider.request(token: api, handle: false) - .map(PhoneUsageReportResponse.self) - .asObservable() - } - /// 指定成员某日历史行程 static func phoneUsageTrips(userId: String, date: Int64) -> Observable { let api = UserAPI.phoneUsageTrips(userId: userId, date: date).multiTarget