mirror of
https://github.com/Finb/Bark.git
synced 2025-12-08 21:36:01 +00:00
Compare commits
15 Commits
5766706e6d
...
e29283113f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29283113f | ||
|
|
32b7dc08f3 | ||
|
|
05faa8d192 | ||
|
|
f55a776874 | ||
|
|
9ca2577ad8 | ||
|
|
321a7e9e32 | ||
|
|
be576e0c91 | ||
|
|
5bcfd1ba3c | ||
|
|
d9da712af3 | ||
|
|
521ee65239 | ||
|
|
15f08940b4 | ||
|
|
a57fdaade6 | ||
|
|
9e8162e9b6 | ||
|
|
30eb46d646 | ||
|
|
1b48b499fa |
2
.github/workflows/testflight.yaml
vendored
2
.github/workflows/testflight.yaml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
- name: Select Xcode Version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: 'latest-stable'
|
||||
xcode-version: '16.1'
|
||||
|
||||
- name: Setup ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
|
||||
2
.github/workflows/tests.yaml
vendored
2
.github/workflows/tests.yaml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
- name: Select Xcode Version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: 'latest-stable'
|
||||
xcode-version: '16.1'
|
||||
|
||||
- name: Setup ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
|
||||
@ -100,6 +100,7 @@
|
||||
066890082D1946D500E106F2 /* MessageItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066890072D1946D500E106F2 /* MessageItemView.swift */; };
|
||||
0668900B2D19525400E106F2 /* ShowLessAndClearView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0668900A2D19525400E106F2 /* ShowLessAndClearView.swift */; };
|
||||
0668900D2D19582400E106F2 /* MessageGroupHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0668900C2D19582400E106F2 /* MessageGroupHeaderView.swift */; };
|
||||
066DF4822D2D31A60092B04E /* MessageDeleteTimeRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */; };
|
||||
066E0C8C2BB6AC9A00873838 /* AddSoundCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 066E0C8B2BB6AC9A00873838 /* AddSoundCell.swift */; };
|
||||
0672CB06256903F700570C9D /* MessageListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0672CB05256903F700570C9D /* MessageListViewModel.swift */; };
|
||||
06787C392A710568008ABDD7 /* GesturePassTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06787C382A710568008ABDD7 /* GesturePassTextView.swift */; };
|
||||
@ -112,6 +113,7 @@
|
||||
0687F2AA2CCB7FA500B2A52F /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */; };
|
||||
06885EB6247FB9880004A303 /* MessageSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06885EB5247FB9880004A303 /* MessageSettingsViewController.swift */; };
|
||||
0689CF4C2C7484A7007203A6 /* BarkTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0689CF4B2C7484A7007203A6 /* BarkTabBarController.swift */; };
|
||||
068A4B962D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068A4B952D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift */; };
|
||||
068EC15827ED99C900D5D11E /* ServerListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068EC15727ED99C900D5D11E /* ServerListViewController.swift */; };
|
||||
068EC15A27ED99E700D5D11E /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068EC15927ED99E700D5D11E /* ServerListViewModel.swift */; };
|
||||
068F66B3247BD84C00DAD25A /* MessageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068F66B2247BD84C00DAD25A /* MessageListViewController.swift */; };
|
||||
@ -343,6 +345,7 @@
|
||||
066890072D1946D500E106F2 /* MessageItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageItemView.swift; sourceTree = "<group>"; };
|
||||
0668900A2D19525400E106F2 /* ShowLessAndClearView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowLessAndClearView.swift; sourceTree = "<group>"; };
|
||||
0668900C2D19582400E106F2 /* MessageGroupHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageGroupHeaderView.swift; sourceTree = "<group>"; };
|
||||
066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeleteTimeRange.swift; sourceTree = "<group>"; };
|
||||
066E0C8B2BB6AC9A00873838 /* AddSoundCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSoundCell.swift; sourceTree = "<group>"; };
|
||||
0672CB05256903F700570C9D /* MessageListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageListViewModel.swift; sourceTree = "<group>"; };
|
||||
06787C382A710568008ABDD7 /* GesturePassTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturePassTextView.swift; sourceTree = "<group>"; };
|
||||
@ -355,6 +358,7 @@
|
||||
0687F2A72CCB791A00B2A52F /* UIFont+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Extension.swift"; sourceTree = "<group>"; };
|
||||
06885EB5247FB9880004A303 /* MessageSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSettingsViewController.swift; sourceTree = "<group>"; };
|
||||
0689CF4B2C7484A7007203A6 /* BarkTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkTabBarController.swift; sourceTree = "<group>"; };
|
||||
068A4B952D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeleteTimeRangeTest.swift; sourceTree = "<group>"; };
|
||||
068EC15727ED99C900D5D11E /* ServerListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListViewController.swift; sourceTree = "<group>"; };
|
||||
068EC15927ED99E700D5D11E /* ServerListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListViewModel.swift; sourceTree = "<group>"; };
|
||||
068F66B2247BD84C00DAD25A /* MessageListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageListViewController.swift; sourceTree = "<group>"; };
|
||||
@ -538,6 +542,7 @@
|
||||
0604F7DD20620D3800B32F09 /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
066DF4812D2D31A30092B04E /* MessageDeleteTimeRange.swift */,
|
||||
06B1158E247BB1FB006D91FB /* Message.swift */,
|
||||
067B2EB425693E38008B6BE1 /* MessageSection.swift */,
|
||||
061E35852D1E5028009A2D6F /* MessageItemModel.swift */,
|
||||
@ -728,6 +733,7 @@
|
||||
06EE1FD226843E9300586708 /* BarkTests.swift */,
|
||||
06EE1FD426843E9300586708 /* Info.plist */,
|
||||
063B909A272149BF00431EC2 /* HomeViewModelTests.swift */,
|
||||
068A4B952D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift */,
|
||||
);
|
||||
path = BarkTests;
|
||||
sourceTree = "<group>";
|
||||
@ -1077,7 +1083,6 @@
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Bark/Pods-Bark-resources.sh",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/Alamofire/Alamofire.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/CryptoSwift/CryptoSwift.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/DeviceKit/DeviceKit.bundle",
|
||||
"${PODS_ROOT}/DropDown/DropDown/resources/DropDownCell.xib",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.bundle",
|
||||
@ -1091,7 +1096,6 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/Material/com.cosmicmind.material.fonts.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/MercariQRScanner/QRScannerAssets.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper/Privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/PLCrashReporter/PLCrashReporter.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/Realm/realm_objc_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RealmSwift/realm_swift_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa/RxCocoa_Privacy.bundle",
|
||||
@ -1106,7 +1110,6 @@
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Alamofire.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/CryptoSwift.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DeviceKit.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DropDownCell.nib",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/IQKeyboardCore.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/IQKeyboardNotification.bundle",
|
||||
@ -1120,7 +1123,6 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/com.cosmicmind.material.fonts.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/QRScannerAssets.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PLCrashReporter.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/realm_objc_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/realm_swift_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RxCocoa_Privacy.bundle",
|
||||
@ -1256,6 +1258,7 @@
|
||||
065BE4502563D939002A8CA4 /* SoundCellViewModel.swift in Sources */,
|
||||
06F08EA729B1DDFE006AB9CA /* Error+Extension.swift in Sources */,
|
||||
0687F2A82CCB791A00B2A52F /* UIFont+Extension.swift in Sources */,
|
||||
066DF4822D2D31A60092B04E /* MessageDeleteTimeRange.swift in Sources */,
|
||||
06B1158F247BB1FB006D91FB /* Message.swift in Sources */,
|
||||
06BCAE562CDB19260092867A /* GroupMuteSettingManager.swift in Sources */,
|
||||
06172FDA27F6DAEF002333A4 /* ServerListTableViewCell.swift in Sources */,
|
||||
@ -1353,6 +1356,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
068A4B962D2E11CD00982449 /* MessageDeleteTimeRangeTest.swift in Sources */,
|
||||
06EE1FD326843E9300586708 /* BarkTests.swift in Sources */,
|
||||
063B909B272149BF00431EC2 /* HomeViewModelTests.swift in Sources */,
|
||||
);
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
// Copyright © 2018年 Fin. All rights reserved.
|
||||
//
|
||||
|
||||
import CrashReporter
|
||||
import IQKeyboardManagerSwift
|
||||
import IQKeyboardToolbarManager
|
||||
import SwiftyStoreKit
|
||||
@ -20,60 +19,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
func setupRealm() {
|
||||
// Tell Realm to use this new configuration object for the default Realm
|
||||
Realm.Configuration.defaultConfiguration = kRealmDefaultConfiguration
|
||||
|
||||
// // iCloud 同步
|
||||
// syncEngine = SyncEngine(objects: [
|
||||
// SyncObject(type: Message.self)
|
||||
// ], databaseScope: .private)
|
||||
|
||||
|
||||
#if DEBUG
|
||||
let realm = try? Realm()
|
||||
print("message count: \(realm?.objects(Message.self).count ?? 0)")
|
||||
let realm = try? Realm()
|
||||
print("message count: \(realm?.objects(Message.self).count ?? 0)")
|
||||
#endif
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
self.window = UIWindow(frame: UIScreen.main.bounds)
|
||||
self.window?.backgroundColor = UIColor.black
|
||||
|
||||
#if !DEBUG
|
||||
let config = PLCrashReporterConfig(signalHandlerType: .mach, symbolicationStrategy: [])
|
||||
if let crashReporter = PLCrashReporter(configuration: config) {
|
||||
// Enable the Crash Reporter.
|
||||
do {
|
||||
try crashReporter.enableAndReturnError()
|
||||
} catch {
|
||||
print("Warning: Could not enable crash reporter: \(error)")
|
||||
}
|
||||
|
||||
if crashReporter.hasPendingCrashReport() {
|
||||
let reportController = CrashReportViewController()
|
||||
do {
|
||||
let data = try crashReporter.loadPendingCrashReportDataAndReturnError()
|
||||
|
||||
// Retrieving crash reporter data.
|
||||
let report = try PLCrashReport(data: data)
|
||||
|
||||
if let text = PLCrashReportTextFormatter.stringValue(for: report, with: PLCrashReportTextFormatiOS) {
|
||||
reportController.crashLog = text
|
||||
} else {
|
||||
print("CrashReporter: can't convert report to text")
|
||||
}
|
||||
} catch {
|
||||
print("CrashReporter failed to load and parse with error: \(error)")
|
||||
}
|
||||
|
||||
// Purge the report.
|
||||
crashReporter.purgePendingCrashReport()
|
||||
self.window?.rootViewController = reportController
|
||||
self.window?.makeKeyAndVisible()
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
print("Could not create an instance of PLCrashReporter")
|
||||
}
|
||||
#endif
|
||||
|
||||
// 必须在应用一开始就配置,否则应用可能提前在配置之前试用了 Realm() ,则会创建两个独立数据库。
|
||||
setupRealm()
|
||||
|
||||
@ -144,9 +100,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
if UIApplication.shared.applicationState == .active {
|
||||
stopCallNotificationProcessor()
|
||||
}
|
||||
return .alert
|
||||
}
|
||||
|
||||
@ -155,6 +108,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
func presentController() {
|
||||
let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any]
|
||||
let title = alert?["title"] as? String
|
||||
let subtitle = alert?["subtitle"] as? String
|
||||
let body = alert?["body"] as? String
|
||||
let url: URL? = {
|
||||
if let url = userInfo["url"] as? String {
|
||||
@ -162,6 +116,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if let action = userInfo["action"] as? String, action == "none" {
|
||||
return
|
||||
}
|
||||
|
||||
// URL 直接打开
|
||||
if let url = url {
|
||||
@ -182,11 +140,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
if let title = title {
|
||||
shareContent += "\(title)\n"
|
||||
}
|
||||
if let subtitle = subtitle {
|
||||
shareContent += "\(subtitle)\n"
|
||||
}
|
||||
if let body = body {
|
||||
shareContent += "\(body)\n"
|
||||
}
|
||||
for (key, value) in userInfo {
|
||||
if ["aps", "title", "body", "url"].contains((key as? String) ?? "") {
|
||||
if ["aps", "title", "subtitle", "body", "url"].contains((key as? String) ?? "") {
|
||||
continue
|
||||
}
|
||||
shareContent += "\(key): \(value) \n"
|
||||
@ -235,11 +196,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
UIApplication.shared.applicationIconBadgeNumber = -1
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// 如果有响铃通知,则关闭响铃
|
||||
stopCallNotificationProcessor()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
@ -262,9 +218,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// 停止响铃
|
||||
func stopCallNotificationProcessor() {
|
||||
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName(kStopCallProcessorKey as CFString), nil, nil, true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -369,6 +369,98 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"beforeAMonth" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Before a month"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bir Ay Önce"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "一个月之前"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"beforeAnHour" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Before an hour"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bir Saat Önce"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "一小时之前"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"beforeToday" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Before today"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bugünden Önce"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "今天之前"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"beforeYesterday" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Before yesterday"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Dünden Önce"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "昨天之前"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"buildDesc" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@ -1835,7 +1927,30 @@
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "过去一小时"
|
||||
"value" : "最近一小时"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lastMonth" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The last month"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Son Ay"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最近一个月"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1886,6 +2001,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"more" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "More"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Daha"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "更多"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoreActions" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@ -2467,19 +2605,19 @@
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ringtone for a notification"
|
||||
"value" : "Keep playing the ringtone for 30 seconds. "
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "bildirim için zil sesi"
|
||||
"value" : "Zili 30 saniye boyunca çalmaya devam ettir. "
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "通知铃声将重复播放 30 秒,同时收到多个通知时将按顺序依次响铃。"
|
||||
"value" : "持续播放铃声 30 秒。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3199,4 +3337,4 @@
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
}
|
||||
|
||||
69
BarkTests/MessageDeleteTimeRangeTest.swift
Normal file
69
BarkTests/MessageDeleteTimeRangeTest.swift
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// MessageDeleteTimeRangeTest.swift
|
||||
// BarkTests
|
||||
//
|
||||
// Created by huangfeng on 1/8/25.
|
||||
// Copyright © 2025 Fin. All rights reserved.
|
||||
//
|
||||
@testable import Bark
|
||||
import Testing
|
||||
|
||||
struct MessageDeleteTimeRangeTest {
|
||||
@Test("检查时间范围区间是否正确", arguments: [
|
||||
MessageDeleteTimeRange.lastHour,
|
||||
MessageDeleteTimeRange.today,
|
||||
MessageDeleteTimeRange.todayAndYesterday,
|
||||
MessageDeleteTimeRange.lastMonth,
|
||||
MessageDeleteTimeRange.allTime,
|
||||
MessageDeleteTimeRange.beforeOneHour,
|
||||
MessageDeleteTimeRange.beforeToday,
|
||||
MessageDeleteTimeRange.beforeYesterday,
|
||||
MessageDeleteTimeRange.beforeOneMonth
|
||||
])
|
||||
func testRange(range: MessageDeleteTimeRange) async throws {
|
||||
let now = Date()
|
||||
let lastHour = Calendar.current.date(byAdding: .hour, value: -1, to: now)!
|
||||
let today = now.startOfDay
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!.startOfDay
|
||||
let lastMonth = Calendar.current.date(byAdding: .month, value: -1, to: now)!
|
||||
|
||||
switch range {
|
||||
case .lastHour:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == lastHour.timeInterval && endDate.timeInterval == now.timeInterval)
|
||||
case .today:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == today.timeInterval && endDate.timeInterval == now.timeInterval)
|
||||
case .todayAndYesterday:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == yesterday.timeInterval && endDate.timeInterval == now.timeInterval)
|
||||
case .lastMonth:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == lastMonth.timeInterval && endDate.timeInterval == now.timeInterval)
|
||||
case .allTime:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == 0 && endDate.timeInterval == now.timeInterval)
|
||||
case .beforeOneHour:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == 0 && endDate.timeInterval == lastHour.timeInterval)
|
||||
case .beforeToday:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == 0 && endDate.timeInterval == today.timeInterval)
|
||||
case .beforeYesterday:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == 0 && endDate.timeInterval == yesterday.timeInterval)
|
||||
case .beforeOneMonth:
|
||||
let startDate = range.startDate
|
||||
let endDate = range.endDate
|
||||
#expect(startDate.timeInterval == 0 && endDate.timeInterval == lastMonth.timeInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -45,26 +45,38 @@ extension Date {
|
||||
}
|
||||
|
||||
extension Date {
|
||||
static var yesterday: Date { return Date().dayBefore }
|
||||
static var tomorrow: Date { return Date().dayAfter }
|
||||
static var lastHour: Date { return Calendar.current.date(byAdding: .hour, value: -1, to: Date())! }
|
||||
var dayBefore: Date {
|
||||
return Calendar.current.date(byAdding: .day, value: -1, to: noon)!
|
||||
}
|
||||
|
||||
var dayAfter: Date {
|
||||
return Calendar.current.date(byAdding: .day, value: 1, to: noon)!
|
||||
}
|
||||
|
||||
var noon: Date {
|
||||
return Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: self)!
|
||||
}
|
||||
|
||||
var month: Int {
|
||||
return Calendar.current.component(.month, from: self)
|
||||
}
|
||||
|
||||
var timeInterval: Int {
|
||||
return Int(timeIntervalSince1970)
|
||||
}
|
||||
|
||||
var isLastDayOfMonth: Bool {
|
||||
return dayAfter.month != month
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
static var yesterday: Date { return Date().dayBefore }
|
||||
static var tomorrow: Date { return Date().dayAfter }
|
||||
static var lastHour: Date { return Calendar.current.date(byAdding: .hour, value: -1, to: Date())! }
|
||||
static var lastMonth: Date { return Calendar.current.date(byAdding: .month, value: -1, to: Date())! }
|
||||
|
||||
var dayBefore: Date {
|
||||
return Calendar.current.date(byAdding: .day, value: -1, to: startOfDay)!
|
||||
}
|
||||
|
||||
var dayAfter: Date {
|
||||
return Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||
}
|
||||
|
||||
var startOfDay: Date {
|
||||
return Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: self)!
|
||||
}
|
||||
|
||||
var endOfDay: Date {
|
||||
return Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: self)!
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,3 +10,4 @@ import Foundation
|
||||
|
||||
|
||||
let kStopCallProcessorKey = "stopCallProcessorNotification"
|
||||
let kBarkSoundPrefix = "bark.sounds.30s"
|
||||
|
||||
@ -14,28 +14,38 @@ import RxDataSources
|
||||
import RxSwift
|
||||
import UIKit
|
||||
|
||||
enum MessageDeleteType: Int {
|
||||
case lastHour = 0
|
||||
case today
|
||||
case todayAndYesterday
|
||||
case allTime
|
||||
|
||||
var string: String {
|
||||
return [
|
||||
NSLocalizedString("lastHour"),
|
||||
NSLocalizedString("today"),
|
||||
NSLocalizedString("todayAndYesterday"),
|
||||
NSLocalizedString("allTime")
|
||||
][self.rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
let deleteButton: UIBarButtonItem = {
|
||||
let btn = BKButton()
|
||||
btn.setImage(UIImage(named: "baseline_delete_outline_black_24pt"), for: .normal)
|
||||
btn.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
|
||||
return UIBarButtonItem(customView: btn)
|
||||
lazy var deleteButton: UIBarButtonItem = {
|
||||
if #available(iOS 14.0, *) {
|
||||
var menuElements = [UIMenuElement]()
|
||||
for range in [MessageDeleteTimeRange.lastHour, .today, .todayAndYesterday, .lastMonth, .allTime] {
|
||||
let action = UIAction(title: range.string) { [weak self] _ in
|
||||
self?.clearAlert(range)
|
||||
}
|
||||
menuElements.append(action)
|
||||
}
|
||||
|
||||
var subMenuElements = [UIMenuElement]()
|
||||
for range in [MessageDeleteTimeRange.beforeOneHour, .beforeToday, .beforeYesterday, .beforeOneMonth] {
|
||||
let action = UIAction(title: range.string) { [weak self] _ in
|
||||
self?.clearAlert(range)
|
||||
}
|
||||
subMenuElements.append(action)
|
||||
}
|
||||
menuElements.append(UIMenu(title: NSLocalizedString("more"), children: subMenuElements))
|
||||
|
||||
let addNewMenu = UIMenu(
|
||||
title: NSLocalizedString("clearFrom"),
|
||||
children: menuElements
|
||||
)
|
||||
return UIBarButtonItem(image: UIImage(named: "baseline_delete_outline_black_24pt"), menu: addNewMenu)
|
||||
} else {
|
||||
let btn = BKButton()
|
||||
btn.setImage(UIImage(named: "baseline_delete_outline_black_24pt"), for: .normal)
|
||||
btn.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
|
||||
return UIBarButtonItem(customView: btn)
|
||||
}
|
||||
|
||||
}()
|
||||
|
||||
let groupButton: UIBarButtonItem = {
|
||||
@ -47,8 +57,6 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
return UIBarButtonItem(customView: btn)
|
||||
}()
|
||||
|
||||
private var expandedGroup: Set<String> = []
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.separatorStyle = .none
|
||||
@ -64,11 +72,24 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
|
||||
tableView.rx.setDelegate(self).disposed(by: rx.disposeBag)
|
||||
tableView.mj_footer = MJRefreshAutoFooter()
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
|
||||
return tableView
|
||||
}()
|
||||
|
||||
|
||||
/// 展开的群组
|
||||
private var expandedGroup: Set<String> = []
|
||||
/// 下拉刷新标记字段
|
||||
private var canRefresh = true
|
||||
|
||||
/// 群组中删除消息的事件流
|
||||
private let itemDeleteInGroupRelay = PublishRelay<MessageItemModel>()
|
||||
/// 下拉刷新事件流
|
||||
private let refreshRelay = PublishRelay<Void>()
|
||||
/// 重新刷新已加载的页的数据 (最多10页)
|
||||
private let reloadRelay = PublishRelay<Void>()
|
||||
/// 按时间范围清除消息事件流
|
||||
private let clearRelay = PublishRelay<MessageDeleteTimeRange>()
|
||||
|
||||
override func makeUI() {
|
||||
navigationItem.searchController = UISearchController(searchResultsController: nil)
|
||||
navigationItem.searchController?.obscuresBackgroundDuringPresentation = false
|
||||
@ -81,6 +102,13 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
if #available(iOS 14.0, *) {
|
||||
// iOS 14 以上,使用 UIMenu
|
||||
} else {
|
||||
// 使用 UIAlertController
|
||||
subscribeDeleteTap()
|
||||
}
|
||||
|
||||
// 点击tab按钮,回到顶部
|
||||
Client.shared.currentTabBarController?
|
||||
.tabBarItemDidClick
|
||||
@ -89,20 +117,11 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
self?.scrollToTop()
|
||||
}).disposed(by: self.rx.disposeBag)
|
||||
|
||||
// 打开APP时,历史消息列表距离上次刷新超过5分钟,则自动刷新一下
|
||||
var lastAutoRefreshdate = Date()
|
||||
NotificationCenter.default.rx
|
||||
.notification(UIApplication.willEnterForegroundNotification)
|
||||
.filter { _ in
|
||||
let now = Date()
|
||||
if now.timeIntervalSince1970 - lastAutoRefreshdate.timeIntervalSince1970 > 60 * 5 {
|
||||
lastAutoRefreshdate = now
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
.delay(.milliseconds(500), scheduler: MainScheduler.instance) // 延迟0.5秒,等待数据库 Results 更新到最新数据集
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
self?.tableView.refreshControl?.sendActions(for: .valueChanged)
|
||||
self?.reloadRelay.accept(())
|
||||
}).disposed(by: rx.disposeBag)
|
||||
|
||||
// 点击群组消息,展开群
|
||||
@ -130,7 +149,8 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
reloadAnimation: .none,
|
||||
deleteAnimation: .left
|
||||
),
|
||||
configureCell: { _, tableView, _, item -> UITableViewCell in
|
||||
configureCell: { [weak self] _, tableView, _, item -> UITableViewCell in
|
||||
guard let self else { return UITableViewCell() }
|
||||
|
||||
switch item {
|
||||
case .message(let message):
|
||||
@ -138,8 +158,8 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
return UITableViewCell()
|
||||
}
|
||||
cell.tapAction = { [weak self, weak cell] message, sourceView in
|
||||
guard let self else { return }
|
||||
self.alertMessage(message: message.attributedText?.string ?? "", sourceView: sourceView)
|
||||
guard let self, let cell else { return }
|
||||
self.alertMessage(message: message, sourceView: sourceView, sourceCell: cell)
|
||||
}
|
||||
cell.message = message
|
||||
return cell
|
||||
@ -168,8 +188,8 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
self.tableView.dataSource?.tableView?(self.tableView, commit: .delete, forRowAt: indexPath)
|
||||
}
|
||||
cell.tapAction = { [weak self, weak cell] message, sourceView in
|
||||
guard let self else { return }
|
||||
self.alertMessage(message: message.attributedText?.string ?? "", sourceView: sourceView)
|
||||
guard let self, let cell else { return }
|
||||
self.alertMessage(message: message, sourceView: sourceView, sourceCell: cell)
|
||||
}
|
||||
cell.cellData = (title, totalCount, messages)
|
||||
cell.isExpanded = self.expandedGroup.contains(title)
|
||||
@ -188,12 +208,14 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
|
||||
let output = viewModel.transform(
|
||||
input: MessageListViewModel.Input(
|
||||
refresh: tableView.refreshControl!.rx.controlEvent(.valueChanged).asDriver(),
|
||||
refresh: refreshRelay.asDriver(onErrorDriveWith: .empty()),
|
||||
loadMore: tableView.mj_footer!.rx.refresh.asDriver(),
|
||||
itemDelete: tableView.rx.modelDeleted(MessageListCellItem.self).asDriver(),
|
||||
delete: getBatchDeleteDriver(),
|
||||
itemDeleteInGroup: itemDeleteInGroupRelay.asDriver(onErrorDriveWith: .empty()),
|
||||
delete: clearRelay.asDriver(onErrorDriveWith: .empty()),
|
||||
groupToggleTap: groupBtn.rx.tap.asDriver(),
|
||||
searchText: navigationItem.searchController!.searchBar.rx.text.asObservable()
|
||||
searchText: navigationItem.searchController!.searchBar.rx.text.asObservable(),
|
||||
reload: reloadRelay.asDriver(onErrorDriveWith: .empty())
|
||||
))
|
||||
|
||||
// tableView 刷新状态
|
||||
@ -230,65 +252,72 @@ class MessageListViewController: BaseViewController<MessageListViewModel> {
|
||||
.drive((groupButton.customView as! UIButton).rx.isHidden).disposed(by: rx.disposeBag)
|
||||
}
|
||||
|
||||
private func getBatchDeleteDriver() -> Driver<MessageDeleteType> {
|
||||
private func subscribeDeleteTap() {
|
||||
guard let deleteBtn = deleteButton.customView as? BKButton else {
|
||||
return Driver.never()
|
||||
return
|
||||
}
|
||||
return deleteBtn.rx
|
||||
.tap
|
||||
.flatMapLatest { _ -> PublishRelay<MessageDeleteType> in
|
||||
let relay = PublishRelay<MessageDeleteType>()
|
||||
|
||||
func alert(_ type: MessageDeleteType) {
|
||||
let alertController = UIAlertController(title: nil, message: "\(NSLocalizedString("clearFrom"))\n\(type.string)", preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("clear"), style: .destructive, handler: { _ in
|
||||
relay.accept(type)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel"), style: .cancel, handler: nil))
|
||||
self.navigationController?.present(alertController, animated: true, completion: nil)
|
||||
deleteBtn.rx.tap.subscribe(onNext: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
|
||||
let alertController = UIAlertController(title: nil, message: NSLocalizedString("clearFrom"), preferredStyle: .actionSheet)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("lastHour"), style: .default, handler: { [weak self] _ in
|
||||
self?.clearAlert(.lastHour)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("today"), style: .default, handler: { [weak self] _ in
|
||||
self?.clearAlert(.today)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("todayAndYesterday"), style: .default, handler: { [weak self] _ in
|
||||
self?.clearAlert(.todayAndYesterday)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("allTime"), style: .default, handler: { [weak self] _ in
|
||||
self?.clearAlert(.allTime)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel"), style: .cancel, handler: nil))
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
alertController.modalPresentationStyle = .popover
|
||||
if #available(iOS 16.0, *) {
|
||||
alertController.popoverPresentationController?.sourceItem = self.deleteButton
|
||||
} else {
|
||||
alertController.popoverPresentationController?.barButtonItem = self.deleteButton
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: nil, message: NSLocalizedString("clearFrom"), preferredStyle: .actionSheet)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("lastHour"), style: .default, handler: { _ in
|
||||
alert(.lastHour)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("today"), style: .default, handler: { _ in
|
||||
alert(.today)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("todayAndYesterday"), style: .default, handler: { _ in
|
||||
alert(.todayAndYesterday)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("allTime"), style: .default, handler: { _ in
|
||||
alert(.allTime)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel"), style: .cancel, handler: nil))
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
alertController.modalPresentationStyle = .popover
|
||||
if #available(iOS 16.0, *) {
|
||||
alertController.popoverPresentationController?.sourceItem = self.deleteButton
|
||||
} else {
|
||||
alertController.popoverPresentationController?.barButtonItem = self.deleteButton
|
||||
}
|
||||
}
|
||||
self.navigationController?.present(alertController, animated: true, completion: nil)
|
||||
|
||||
return relay
|
||||
}
|
||||
.asDriver(onErrorDriveWith: .empty())
|
||||
self.navigationController?.present(alertController, animated: true, completion: nil)
|
||||
}).disposed(by: rx.disposeBag)
|
||||
}
|
||||
|
||||
private func alertMessage(message: String, sourceView: UIView) {
|
||||
func clearAlert(_ range: MessageDeleteTimeRange) {
|
||||
let alertController = UIAlertController(title: nil, message: "\(NSLocalizedString("clearFrom"))\n\(range.string)", preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("clear"), style: .destructive, handler: { [weak self] _ in
|
||||
self?.clearRelay.accept(range)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel"), style: .cancel, handler: nil))
|
||||
self.navigationController?.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func alertMessage(message: MessageItemModel, sourceView: UIView, sourceCell: UITableViewCell) {
|
||||
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
let copyAction = UIAlertAction(title: NSLocalizedString("CopyAll"), style: .default, handler: { [weak self]
|
||||
|
||||
// 复制
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Copy2"), style: .default, handler: { [weak self]
|
||||
(_: UIAlertAction) in
|
||||
UIPasteboard.general.string = message
|
||||
UIPasteboard.general.string = message.attributedText?.string
|
||||
self?.showSnackbar(text: NSLocalizedString("Copy"))
|
||||
})
|
||||
}))
|
||||
// 删除
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("removeMessage"), style: .destructive, handler: { [weak self]
|
||||
(_: UIAlertAction) in
|
||||
guard let self, let indexPath = self.tableView.indexPath(for: sourceCell) else { return }
|
||||
if sourceCell is MessageTableViewCell {
|
||||
// 单个消息,把cell删除
|
||||
self.tableView.dataSource?.tableView?(self.tableView, commit: .delete, forRowAt: indexPath)
|
||||
} else if sourceCell is MessageGroupTableViewCell {
|
||||
// 群组消息,只能删除群组中需删除的消息
|
||||
self.itemDeleteInGroupRelay.accept(message)
|
||||
}
|
||||
}))
|
||||
// 取消
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel"), style: .cancel, handler: { _ in }))
|
||||
|
||||
let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel"), style: .cancel, handler: { _ in })
|
||||
|
||||
alertController.addAction(copyAction)
|
||||
alertController.addAction(cancelAction)
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
alertController.popoverPresentationController?.sourceView = sourceView.superview
|
||||
alertController.popoverPresentationController?.sourceRect = sourceView.frame
|
||||
@ -356,4 +385,21 @@ extension MessageListViewController: UISearchControllerDelegate {
|
||||
searchController.searchBar.searchTextField.sendActions(for: .editingDidEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
|
||||
if offset <= -10 && canRefresh {
|
||||
// 触发下拉刷新,并震动
|
||||
canRefresh = false
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
refreshRelay.accept(())
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
|
||||
if offset >= 0 && !canRefresh {
|
||||
canRefresh = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,12 +34,16 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
var loadMore: Driver<Void>
|
||||
/// 删除
|
||||
var itemDelete: Driver<MessageListCellItem>
|
||||
/// 删除群组中某一条消息
|
||||
var itemDeleteInGroup: Driver<MessageItemModel>
|
||||
/// 批量删除
|
||||
var delete: Driver<MessageDeleteType>
|
||||
var delete: Driver<MessageDeleteTimeRange>
|
||||
/// 切换群组和列表显示样式
|
||||
var groupToggleTap: Driver<Void>
|
||||
/// 搜索
|
||||
var searchText: Observable<String?>
|
||||
/// 重新刷新已加载的页的数据 (最多10页,超过则不刷新)
|
||||
var reload: Driver<Void>
|
||||
}
|
||||
|
||||
struct Output {
|
||||
@ -126,7 +130,7 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
}
|
||||
|
||||
/// 获取 message 列表下一页数据
|
||||
private func getListNextPage() -> [MessageListCellItem] {
|
||||
private func getListNextPage(page: Int, pageCount: Int) -> [MessageListCellItem] {
|
||||
guard let result = results else {
|
||||
return []
|
||||
}
|
||||
@ -142,12 +146,11 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
// copy 是因为 message 可能在被删除后,还会被访问导致闪退
|
||||
messages.append(.message(model: MessageItemModel(message: result[i])))
|
||||
}
|
||||
page += 1
|
||||
return messages
|
||||
}
|
||||
|
||||
/// 获取 group 列表下一页数据
|
||||
private func getGroupNextPage() -> [MessageListCellItem] {
|
||||
private func getGroupNextPage(page: Int, pageCount: Int) -> [MessageListCellItem] {
|
||||
guard let groups, let results else {
|
||||
return []
|
||||
}
|
||||
@ -162,12 +165,7 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
|
||||
for i in startIndex..<endIndex {
|
||||
let group = groups[i].group
|
||||
let messageResult: Results<Message>
|
||||
if let group {
|
||||
messageResult = results.filter("group == %@", group)
|
||||
} else {
|
||||
messageResult = results.filter("group == nil")
|
||||
}
|
||||
let messageResult = getMessages(in: results, group: group)
|
||||
|
||||
var messages: [MessageItemModel] = []
|
||||
for i in 0..<min(messageResult.count, 5) {
|
||||
@ -181,20 +179,35 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
items.append(.messageGroup(name: group ?? NSLocalizedString("default"), totalCount: messageResult.count, messages: messages))
|
||||
}
|
||||
}
|
||||
page += 1
|
||||
return items
|
||||
}
|
||||
|
||||
/// 使用 groupName 获取 messages
|
||||
func getMessages(in results: Results<Message>, group: String?) -> Results<Message> {
|
||||
if let group {
|
||||
return results.filter("group == %@", group)
|
||||
} else {
|
||||
return results.filter("group == nil")
|
||||
}
|
||||
}
|
||||
|
||||
private func getNextPage() -> [MessageListCellItem] {
|
||||
private func getPage(page: Int, pageCount: Int) -> [MessageListCellItem] {
|
||||
if case .group = self.sourceType {
|
||||
// 查看指定分组时,只能按列表查看
|
||||
return getListNextPage()
|
||||
return getListNextPage(page: page, pageCount: pageCount)
|
||||
}
|
||||
if type == .list || !searchText.isEmpty {
|
||||
// 搜索时,也必须按列表查看
|
||||
return getListNextPage()
|
||||
return getListNextPage(page: page, pageCount: pageCount)
|
||||
}
|
||||
return getGroupNextPage()
|
||||
return getGroupNextPage(page: page, pageCount: pageCount)
|
||||
}
|
||||
|
||||
private func getNextPage() -> [MessageListCellItem] {
|
||||
defer {
|
||||
page += 1
|
||||
}
|
||||
return getPage(page: self.page, pageCount: self.pageCount)
|
||||
}
|
||||
|
||||
func transform(input: Input) -> Output {
|
||||
@ -247,7 +260,6 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
Observable
|
||||
.merge(
|
||||
input.refresh.asObservable().map { () },
|
||||
filterGroups.map { _ in () },
|
||||
input.searchText.asObservable().map { _ in () },
|
||||
messageTypeChanged.asObservable().map { _ in () }
|
||||
)
|
||||
@ -279,6 +291,16 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
}
|
||||
}).disposed(by: rx.disposeBag)
|
||||
|
||||
// 重新加载已加载的页的数据,最多10页
|
||||
input.reload.drive(onNext: { [weak self] _ in
|
||||
guard let self, self.page > 0, self.page <= 10 else { return }
|
||||
// 刷新已加载的页的数据
|
||||
let messages = self.getPage(page: 0, pageCount: self.page * self.pageCount)
|
||||
messagesRelay.accept(
|
||||
[MessageSection(header: "model", messages: messages)]
|
||||
)
|
||||
}).disposed(by: rx.disposeBag)
|
||||
|
||||
// 删除message
|
||||
input.itemDelete.drive(onNext: { [weak self] item in
|
||||
guard let self else { return }
|
||||
@ -334,24 +356,53 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
|
||||
}).disposed(by: rx.disposeBag)
|
||||
|
||||
// 批量删除
|
||||
input.delete.drive(onNext: { [weak self] type in
|
||||
guard let strongSelf = self else { return }
|
||||
// 删除群组中某一条消息
|
||||
input.itemDeleteInGroup.drive(onNext: { [weak self] model in
|
||||
guard let self, let results else { return }
|
||||
|
||||
var date = Date()
|
||||
switch type {
|
||||
case .allTime:
|
||||
date = Date(timeIntervalSince1970: 0)
|
||||
case .todayAndYesterday:
|
||||
date = Date.yesterday
|
||||
case .today:
|
||||
date = Date().noon
|
||||
case .lastHour:
|
||||
date = Date.lastHour
|
||||
guard var section = messagesRelay.value.first else {
|
||||
return
|
||||
}
|
||||
|
||||
// 删除数据库里的 message
|
||||
if let realm = try? Realm(),
|
||||
let message = realm.objects(Message.self).filter("id == %@", model.id).first
|
||||
{
|
||||
try? realm.write {
|
||||
realm.delete(message)
|
||||
}
|
||||
}
|
||||
|
||||
if let index = section.messages.firstIndex(where: { item in
|
||||
if case .messageGroup(_, _, let messages) = item {
|
||||
return messages.contains { item in
|
||||
return item.id == model.id
|
||||
}
|
||||
}
|
||||
return false
|
||||
}) {
|
||||
// 用最新的数据,重新生成 cellItem
|
||||
if case .messageGroup(let name, _, var messages) = section.messages[index] {
|
||||
let messagesResult = self.getMessages(in: results, group: messages.first?.group)
|
||||
messages = messagesResult.prefix(5).map { MessageItemModel(message: $0) }
|
||||
if messages.count == 0 {
|
||||
section.messages.remove(at: index)
|
||||
} else {
|
||||
section.messages[index] = .messageGroup(name: name, totalCount: messagesResult.count, messages: messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messagesRelay.accept([section])
|
||||
|
||||
}).disposed(by: rx.disposeBag)
|
||||
|
||||
// 批量删除
|
||||
input.delete.drive(onNext: { [weak self] range in
|
||||
guard let self else { return }
|
||||
|
||||
if let realm = try? Realm() {
|
||||
guard let messages = strongSelf.getResults(filterGroups: filterGroups.value, searchText: nil)?.filter("createDate >= %@", date) else {
|
||||
guard let messages = self.getResults(filterGroups: filterGroups.value, searchText: nil)?.filter("createDate >= %@ and createDate <= %@ ", range.startDate, range.endDate) else {
|
||||
return
|
||||
}
|
||||
|
||||
@ -360,8 +411,8 @@ class MessageListViewModel: ViewModel, ViewModelType {
|
||||
}
|
||||
}
|
||||
|
||||
strongSelf.page = 0
|
||||
messagesRelay.accept([MessageSection(header: "model", messages: strongSelf.getNextPage())])
|
||||
self.page = 0
|
||||
messagesRelay.accept([MessageSection(header: "model", messages: self.getNextPage())])
|
||||
|
||||
}).disposed(by: rx.disposeBag)
|
||||
|
||||
|
||||
@ -66,7 +66,8 @@ class SoundsViewModel: ViewModel, ViewModelType {
|
||||
do {
|
||||
let files = try fileManager.contentsOfDirectory(atPath: directory)
|
||||
return files.compactMap { file -> URL? in
|
||||
if file.hasSuffix(suffix) {
|
||||
if file.hasSuffix(suffix), !file.hasPrefix(kBarkSoundPrefix) {
|
||||
// 不要包含 kBarkSoundPrefix 开头的,这些是为了 call=1 合成的 30s 长铃声,不算用户上传的
|
||||
return URL(fileURLWithPath: directory).appendingPathComponent(file)
|
||||
}
|
||||
return nil
|
||||
@ -104,13 +105,15 @@ class SoundsViewModel: ViewModel, ViewModelType {
|
||||
).share(replay: 1)
|
||||
|
||||
// 所有铃声列表,包含自定义铃声和默认铃声
|
||||
let sounds: Observable<([SoundItem], [SoundItem])> = soundsListUpdated.map { _ in
|
||||
let sounds: Observable<([SoundItem], [SoundItem])> = soundsListUpdated.map { [weak self] _ in
|
||||
guard let self else { return ([], []) }
|
||||
|
||||
let defaultSounds = self.getSounds(
|
||||
urls: Bundle.main.urls(forResourcesWithExtension: "caf", subdirectory: nil) ?? []
|
||||
)
|
||||
|
||||
let customSounds: [SoundItem] = {
|
||||
guard let soundsDirectoryUrl = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first?.appending("/Sounds") else {
|
||||
guard let soundsDirectoryUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bark")?.appendingPathComponent("Library/Sounds").path else {
|
||||
return [.addSound]
|
||||
}
|
||||
return self.getSounds(
|
||||
@ -179,52 +182,27 @@ class SoundFileStorage: SoundFileStorageProtocol {
|
||||
/// 将指定文件保存在 Library/Sound,如果存在则覆盖
|
||||
func saveSound(url: URL) {
|
||||
// 保存到Sounds文件夹
|
||||
let soundsDirectoryUrl = getSoundsDirectory()
|
||||
guard let soundsDirectoryUrl = getSoundsDirectory() else {
|
||||
return
|
||||
}
|
||||
let soundUrl = soundsDirectoryUrl.appendingPathComponent(url.lastPathComponent)
|
||||
try? fileManager.copyItem(at: url, to: soundUrl)
|
||||
|
||||
// 另外复制一份到共享目录
|
||||
saveSoundToGroupDirectory(url: url)
|
||||
}
|
||||
|
||||
func deleteSound(url: URL) {
|
||||
// 删除sounds目录铃声文件
|
||||
try? fileManager.removeItem(at: url)
|
||||
|
||||
// 再删除共享目录中对应的铃声文件
|
||||
if let groupSoundUrl = getSoundsGroupDirectory()?.appendingPathComponent(url.lastPathComponent) {
|
||||
try? fileManager.removeItem(at: groupSoundUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Library 目录下的 Sounds 文件夹
|
||||
/// 如果不存在就创建
|
||||
private func getSoundsDirectory() -> URL {
|
||||
let soundsDirectoryUrl = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first!.appending("/Sounds")
|
||||
if !fileManager.fileExists(atPath: soundsDirectoryUrl) {
|
||||
try? fileManager.createDirectory(atPath: soundsDirectoryUrl, withIntermediateDirectories: true, attributes: nil)
|
||||
private func getSoundsDirectory() -> URL? {
|
||||
guard let soundsDirectoryUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bark")?.appendingPathComponent("Library/Sounds") else {
|
||||
return nil
|
||||
}
|
||||
return URL(fileURLWithPath: soundsDirectoryUrl)
|
||||
}
|
||||
|
||||
/// 保存到共享文件夹,供 NotificationServiceExtension 使用(例如持续响铃需要拿到这个文件)
|
||||
private func saveSoundToGroupDirectory(url: URL) {
|
||||
guard let groupUrl = getSoundsGroupDirectory() else {
|
||||
return
|
||||
if !fileManager.fileExists(atPath: soundsDirectoryUrl.path) {
|
||||
try? fileManager.createDirectory(atPath: soundsDirectoryUrl.path, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
let soundUrl = groupUrl.appendingPathComponent(url.lastPathComponent)
|
||||
try? fileManager.copyItem(at: url, to: soundUrl)
|
||||
}
|
||||
|
||||
/// 获取共享目录下的 Sounds 文件夹
|
||||
/// 如果不存在就创建
|
||||
private func getSoundsGroupDirectory() -> URL? {
|
||||
if let directoryUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bark")?.appendingPathComponent("Sounds") {
|
||||
if !fileManager.fileExists(atPath: directoryUrl.path) {
|
||||
try? fileManager.createDirectory(atPath: directoryUrl.path, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
return directoryUrl
|
||||
}
|
||||
return nil
|
||||
return URL(fileURLWithPath: soundsDirectoryUrl.path)
|
||||
}
|
||||
}
|
||||
|
||||
92
Model/MessageDeleteTimeRange.swift
Normal file
92
Model/MessageDeleteTimeRange.swift
Normal file
@ -0,0 +1,92 @@
|
||||
//
|
||||
// MessageDeleteTimeRange.swift
|
||||
// Bark
|
||||
//
|
||||
// Created by huangfeng on 1/7/25.
|
||||
// Copyright © 2025 Fin. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MessageDeleteTimeRange {
|
||||
/// 最近一小时
|
||||
case lastHour
|
||||
/// 今天
|
||||
case today
|
||||
/// 今天和昨天
|
||||
case todayAndYesterday
|
||||
/// 最近一个月
|
||||
case lastMonth
|
||||
/// 全部时间
|
||||
case allTime
|
||||
|
||||
/// 一小时之前
|
||||
case beforeOneHour
|
||||
/// 一天之前
|
||||
case beforeToday
|
||||
/// 昨天之前
|
||||
case beforeYesterday
|
||||
/// 一月之前
|
||||
case beforeOneMonth
|
||||
|
||||
var string: String {
|
||||
switch self {
|
||||
case .lastHour:
|
||||
return NSLocalizedString("lastHour")
|
||||
case .today:
|
||||
return NSLocalizedString("today")
|
||||
case .todayAndYesterday:
|
||||
return NSLocalizedString("todayAndYesterday")
|
||||
case .lastMonth:
|
||||
return NSLocalizedString("lastMonth")
|
||||
case .allTime:
|
||||
return NSLocalizedString("allTime")
|
||||
case .beforeOneHour:
|
||||
return NSLocalizedString("beforeAnHour")
|
||||
case .beforeToday:
|
||||
return NSLocalizedString("beforeToday")
|
||||
case .beforeYesterday:
|
||||
return NSLocalizedString("beforeYesterday")
|
||||
case .beforeOneMonth:
|
||||
return NSLocalizedString("beforeAMonth")
|
||||
}
|
||||
}
|
||||
|
||||
var startDate: Date {
|
||||
switch self {
|
||||
case .lastHour:
|
||||
return Date.lastHour
|
||||
case .today:
|
||||
return Date().startOfDay
|
||||
case .todayAndYesterday:
|
||||
return Date.yesterday
|
||||
case .lastMonth:
|
||||
return Date.lastMonth
|
||||
case .allTime,
|
||||
.beforeOneHour,
|
||||
.beforeToday,
|
||||
.beforeYesterday,
|
||||
.beforeOneMonth:
|
||||
return Date(timeIntervalSince1970: 0)
|
||||
}
|
||||
}
|
||||
|
||||
var endDate: Date {
|
||||
switch self {
|
||||
case .lastHour,
|
||||
.today,
|
||||
.todayAndYesterday,
|
||||
.lastMonth,
|
||||
.allTime:
|
||||
return Date()
|
||||
case .beforeOneHour:
|
||||
return Date.lastHour
|
||||
case .beforeToday:
|
||||
return Date().startOfDay
|
||||
case .beforeYesterday:
|
||||
return Date.yesterday
|
||||
case .beforeOneMonth:
|
||||
return Date.lastMonth
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ enum MessageListCellItem: Equatable {
|
||||
/// 一组消息,可以收缩折叠
|
||||
case messageGroup(name: String, totalCount: Int, messages: [MessageItemModel])
|
||||
|
||||
// 确定 cell 内部是否需要更新
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.message(let l), .message(let r)):
|
||||
@ -46,12 +47,13 @@ enum MessageListCellItem: Equatable {
|
||||
extension MessageListCellItem: IdentifiableType {
|
||||
typealias Identity = String
|
||||
|
||||
// 确定整个 cell 是否删除或替换
|
||||
var identity: String {
|
||||
switch self {
|
||||
case .message(let model):
|
||||
return "list_\(model.id)"
|
||||
case .messageGroup(_, _, let messages):
|
||||
return "group_\(messages.first?.id ?? "")"
|
||||
return "group_\(messages.first?.group ?? NSLocalizedString("Default"))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,82 +7,31 @@
|
||||
//
|
||||
|
||||
import AudioToolbox
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
|
||||
class CallProcessor: NotificationContentProcessor {
|
||||
/// 循环播放的铃声
|
||||
var soundID: SystemSoundID = 0
|
||||
/// 播放完毕后,返回的 content
|
||||
var content: UNMutableNotificationContent? = nil
|
||||
/// 是否需要停止播放,由主APP发出停止通知赋值
|
||||
var needsStop = false
|
||||
/// 铃声文件夹,扩展访问不到主APP中的铃声,需要先共享铃声文件
|
||||
let soundsDirectoryUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bark")?.appendingPathComponent("Library/Sounds")
|
||||
|
||||
func process(identifier: String, content bestAttemptContent: UNMutableNotificationContent) async throws -> UNMutableNotificationContent {
|
||||
guard let call = bestAttemptContent.userInfo["call"] as? String, call == "1" else {
|
||||
return bestAttemptContent
|
||||
}
|
||||
self.content = bestAttemptContent
|
||||
|
||||
self.registerObserver()
|
||||
self.sendLocalNotification(identifier: identifier, content: bestAttemptContent)
|
||||
self.cancelRemoteNotification(content: bestAttemptContent)
|
||||
await startAudioWork()
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
||||
func serviceExtensionTimeWillExpire(contentHandler: (UNNotificationContent) -> Void) {
|
||||
stopAudioWork()
|
||||
if let content {
|
||||
contentHandler(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成一个本地推送
|
||||
private func sendLocalNotification(identifier: String, content: UNMutableNotificationContent) {
|
||||
// 推送id和推送的内容都使用远程APNS的
|
||||
guard let content = content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
return
|
||||
}
|
||||
if !content.isCritical { // 重要警告的声音可以无视静音模式,所以别把这特性给弄没了
|
||||
content.sound = nil
|
||||
}
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
/// 响铃结束时取消显示远程推送,因为已经用本地推送显示了一遍
|
||||
private func cancelRemoteNotification(content: UNMutableNotificationContent) {
|
||||
// 远程推送在响铃结束后静默不显示
|
||||
// 至于iOS15以下的设备,因不支持这个特性会在响铃结束后再展示一次
|
||||
// 如果设置了 level 参数,就还是以 level 参数为准不做修改
|
||||
if #available(iOSApplicationExtension 15.0, *), self.content?.userInfo["level"] == nil {
|
||||
self.content?.interruptionLevel = .passive
|
||||
}
|
||||
}
|
||||
|
||||
// 开始播放铃声,startAudioWork(completion:) 方法的异步包装
|
||||
private func startAudioWork() async {
|
||||
return await withCheckedContinuation { continuation in
|
||||
self.startAudioWork {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
// 延长铃声到30s
|
||||
return self.processNotificationSound(content: bestAttemptContent)
|
||||
}
|
||||
}
|
||||
|
||||
/// 铃声播放结束时的回调
|
||||
var startAudioWorkCompletion: (() -> Void)? = nil
|
||||
/// 播放铃声
|
||||
private func startAudioWork(completion: @escaping () -> Void) {
|
||||
guard let content else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
self.startAudioWorkCompletion = completion
|
||||
|
||||
// MARK: - 铃声
|
||||
|
||||
extension CallProcessor {
|
||||
// 将通知铃声延长到30s,并用30s的长铃声替换掉原铃声
|
||||
func processNotificationSound(content: UNMutableNotificationContent) -> UNMutableNotificationContent {
|
||||
let sound = ((content.userInfo["aps"] as? [String: Any])?["sound"] as? String)?.split(separator: ".")
|
||||
let soundName: String
|
||||
let soundType: String
|
||||
if sound?.count == 2, let first = sound?.first, let last = sound?.last {
|
||||
if sound?.count == 2, let first = sound?.first, let last = sound?.last, last == "caf" {
|
||||
soundName = String(first)
|
||||
soundType = String(last)
|
||||
} else {
|
||||
@ -90,65 +39,108 @@ class CallProcessor: NotificationContentProcessor {
|
||||
soundType = "caf"
|
||||
}
|
||||
|
||||
// 先找自定义上传的铃声,再找内置铃声
|
||||
guard let audioPath = getSoundInCustomSoundsDirectory(soundName: "\(soundName).\(soundType)") ??
|
||||
Bundle.main.path(forResource: soundName, ofType: soundType)
|
||||
else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
let fileUrl = URL(string: audioPath)
|
||||
// 创建响铃任务
|
||||
AudioServicesCreateSystemSoundID(fileUrl! as CFURL, &soundID)
|
||||
// 播放震动、响铃
|
||||
AudioServicesPlayAlertSound(soundID)
|
||||
// 监听响铃完成状态
|
||||
let selfPointer = unsafeBitCast(self, to: UnsafeMutableRawPointer.self)
|
||||
AudioServicesAddSystemSoundCompletion(soundID, nil, nil, { sound, clientData in
|
||||
guard let pointer = clientData else { return }
|
||||
let processor = unsafeBitCast(pointer, to: CallProcessor.self)
|
||||
if processor.needsStop {
|
||||
processor.startAudioWorkCompletion?()
|
||||
return
|
||||
if let longSoundUrl = getLongSound(soundName: soundName, soundType: soundType) {
|
||||
if let level = content.userInfo["level"] as? String, level == "critical" {
|
||||
LevelProcessor.setCriticalSound(content: content, soundName: longSoundUrl.lastPathComponent)
|
||||
} else {
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: longSoundUrl.lastPathComponent))
|
||||
}
|
||||
// 音频文件一次播放完成,再次播放
|
||||
AudioServicesPlayAlertSound(sound)
|
||||
}, selfPointer)
|
||||
}
|
||||
|
||||
/// 停止播放
|
||||
private func stopAudioWork() {
|
||||
AudioServicesRemoveSystemSoundCompletion(soundID)
|
||||
AudioServicesDisposeSystemSoundID(soundID)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/// 注册停止通知
|
||||
func registerObserver() {
|
||||
let notification = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||
CFNotificationCenterAddObserver(notification, observer, { _, pointer, _, _, _ in
|
||||
guard let observer = pointer else { return }
|
||||
let processor = Unmanaged<CallProcessor>.fromOpaque(observer).takeUnretainedValue()
|
||||
processor.needsStop = true
|
||||
}, kStopCallProcessorKey as CFString, nil, .deliverImmediately)
|
||||
}
|
||||
|
||||
func getSoundInCustomSoundsDirectory(soundName: String) -> String? {
|
||||
// 扩展访问不到主APP中的铃声,需要先共享铃声文件,再实现自定义铃声响铃
|
||||
guard let soundsDirectoryUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bark")?.appendingPathComponent("Sounds") else {
|
||||
func getLongSound(soundName: String, soundType: String) -> URL? {
|
||||
guard let soundsDirectoryUrl else {
|
||||
return nil
|
||||
}
|
||||
let path = soundsDirectoryUrl.appendingPathComponent(soundName).path
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return path
|
||||
// 创建铃声文件夹
|
||||
if !FileManager.default.fileExists(atPath: soundsDirectoryUrl.path) {
|
||||
try? FileManager.default.createDirectory(atPath: soundsDirectoryUrl.path, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
return nil
|
||||
|
||||
// 已经存在处理过的长铃声,则直接返回
|
||||
let longSoundName = "\(kBarkSoundPrefix).\(soundName).\(soundType)"
|
||||
let longSoundPath = soundsDirectoryUrl.appendingPathComponent(longSoundName)
|
||||
if FileManager.default.fileExists(atPath: longSoundPath.path) {
|
||||
return longSoundPath
|
||||
}
|
||||
|
||||
// 原始铃声路径
|
||||
var path: String = soundsDirectoryUrl.appendingPathComponent("\(soundName).\(soundType)").path
|
||||
if !FileManager.default.fileExists(atPath: path) {
|
||||
// 不存在自定义的铃声,就用内置的铃声
|
||||
path = Bundle.main.path(forResource: soundName, ofType: soundType) ?? ""
|
||||
}
|
||||
guard !path.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 将原始铃声处理成30s的长铃声,并缓存起来
|
||||
return mergeCAFFilesToDuration(inputFile: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
deinit {
|
||||
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||
let name = CFNotificationName(kStopCallProcessorKey as CFString)
|
||||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), observer, name, nil)
|
||||
/// - Author: @uuneo
|
||||
/// - Description:将输入的音频文件重复为指定时长的音频文件
|
||||
/// - Parameters:
|
||||
/// - inputFile: 原始铃声文件路径
|
||||
/// - targetDuration: 重复的时长
|
||||
/// - Returns: 长铃声文件路径
|
||||
func mergeCAFFilesToDuration(inputFile: URL, targetDuration: TimeInterval = 30) -> URL? {
|
||||
guard let soundsDirectoryUrl else {
|
||||
return nil
|
||||
}
|
||||
let longSoundName = "\(kBarkSoundPrefix).\(inputFile.lastPathComponent)"
|
||||
let longSoundPath = soundsDirectoryUrl.appendingPathComponent(longSoundName)
|
||||
|
||||
do {
|
||||
// 打开输入文件并获取音频格式
|
||||
let audioFile = try AVAudioFile(forReading: inputFile)
|
||||
let audioFormat = audioFile.processingFormat
|
||||
let sampleRate = audioFormat.sampleRate
|
||||
|
||||
// 计算目标帧数
|
||||
let targetFrames = AVAudioFramePosition(targetDuration * sampleRate)
|
||||
var currentFrames: AVAudioFramePosition = 0
|
||||
// 创建输出音频文件
|
||||
let outputAudioFile = try AVAudioFile(forWriting: longSoundPath, settings: audioFormat.settings)
|
||||
|
||||
// 循环读取文件数据,拼接到目标时长
|
||||
while currentFrames < targetFrames {
|
||||
// 每次读取整个文件的音频数据
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: AVAudioFrameCount(audioFile.length)) else {
|
||||
// 出错了就终止,避免死循环
|
||||
return nil
|
||||
}
|
||||
|
||||
try audioFile.read(into: buffer)
|
||||
|
||||
// 计算剩余所需帧数
|
||||
let remainingFrames = targetFrames - currentFrames
|
||||
if AVAudioFramePosition(buffer.frameLength) > remainingFrames {
|
||||
// 如果当前缓冲区帧数超出所需,截取剩余部分
|
||||
let truncatedBuffer = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: AVAudioFrameCount(remainingFrames))!
|
||||
let channelCount = Int(buffer.format.channelCount)
|
||||
for channel in 0..<channelCount {
|
||||
let sourcePointer = buffer.floatChannelData![channel]
|
||||
let destinationPointer = truncatedBuffer.floatChannelData![channel]
|
||||
memcpy(destinationPointer, sourcePointer, Int(remainingFrames) * MemoryLayout<Float>.size)
|
||||
}
|
||||
truncatedBuffer.frameLength = AVAudioFrameCount(remainingFrames)
|
||||
try outputAudioFile.write(from: truncatedBuffer)
|
||||
break
|
||||
} else {
|
||||
// 否则写入整个缓冲区
|
||||
try outputAudioFile.write(from: buffer)
|
||||
currentFrames += AVAudioFramePosition(buffer.frameLength)
|
||||
}
|
||||
|
||||
// 重置输入文件读取位置
|
||||
audioFile.framePosition = 0
|
||||
}
|
||||
return longSoundPath
|
||||
} catch {
|
||||
print("Error processing CAF file: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,20 +15,9 @@ class LevelProcessor: NotificationContentProcessor {
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
||||
// 重要警告
|
||||
if level == "critical" {
|
||||
// 默认音量
|
||||
var audioVolume: Float = 0.5
|
||||
// 指定音量,取值范围是 0 - 10 , 会转换成 0.0 - 1.0
|
||||
if let volume = bestAttemptContent.userInfo["volume"] as? String, let volume = Float(volume) {
|
||||
audioVolume = max(0.0, min(1, volume / 10.0))
|
||||
}
|
||||
// 设置重要警告 sound
|
||||
if let sound = bestAttemptContent.soundName {
|
||||
bestAttemptContent.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: audioVolume)
|
||||
} else {
|
||||
bestAttemptContent.sound = UNNotificationSound.defaultCriticalSound(withAudioVolume: audioVolume)
|
||||
}
|
||||
if let level = bestAttemptContent.userInfo["level"] as? String, level == "critical" {
|
||||
// 设置重要警告音效
|
||||
LevelProcessor.setCriticalSound(content: bestAttemptContent)
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
||||
@ -48,6 +37,27 @@ class LevelProcessor: NotificationContentProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
extension LevelProcessor {
|
||||
class func setCriticalSound(content bestAttemptContent: UNMutableNotificationContent, soundName: String? = nil) {
|
||||
guard let level = bestAttemptContent.userInfo["level"] as? String, level == "critical" else {
|
||||
return
|
||||
}
|
||||
// 默认音量
|
||||
var audioVolume: Float = 0.5
|
||||
// 指定音量,取值范围是 0 - 10 , 会转换成 0.0 - 1.0
|
||||
if let volume = bestAttemptContent.userInfo["volume"] as? String, let volume = Float(volume) {
|
||||
audioVolume = max(0.0, min(1, volume / 10.0))
|
||||
}
|
||||
// 设置重要警告 sound
|
||||
let sound = soundName ?? bestAttemptContent.soundName
|
||||
if let sound {
|
||||
bestAttemptContent.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: audioVolume)
|
||||
} else {
|
||||
bestAttemptContent.sound = UNNotificationSound.defaultCriticalSound(withAudioVolume: audioVolume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UNMutableNotificationContent {
|
||||
/// 是否是重要警告
|
||||
var isCritical: Bool {
|
||||
|
||||
3
Podfile
3
Podfile
@ -8,13 +8,11 @@ use_modular_headers!
|
||||
def pods
|
||||
pod 'SnapKit'
|
||||
pod 'Material'
|
||||
pod 'KVOController'
|
||||
pod 'SVProgressHUD'
|
||||
pod 'FDFullscreenPopGesture'
|
||||
pod 'Moya/RxSwift'
|
||||
pod 'ObjectMapper'
|
||||
pod 'SwiftyJSON'
|
||||
pod 'DeviceKit'
|
||||
pod 'DefaultsKit'
|
||||
pod 'RealmSwift'
|
||||
pod 'CryptoSwift'
|
||||
@ -31,7 +29,6 @@ def pods
|
||||
pod 'MercariQRScanner', :git => 'https://github.com/Finb/QRScanner'
|
||||
pod 'DropDown'
|
||||
|
||||
pod 'PLCrashReporter'
|
||||
pod 'SwiftyStoreKit'
|
||||
end
|
||||
|
||||
|
||||
36
Podfile.lock
36
Podfile.lock
@ -1,8 +1,7 @@
|
||||
PODS:
|
||||
- Alamofire (5.10.2)
|
||||
- CryptoSwift (1.8.3)
|
||||
- CryptoSwift (1.8.4)
|
||||
- DefaultsKit (0.3.2)
|
||||
- DeviceKit (5.5.0)
|
||||
- Differentiator (5.0.0)
|
||||
- DropDown (2.3.13)
|
||||
- FDFullscreenPopGesture (1.1)
|
||||
@ -27,8 +26,7 @@ PODS:
|
||||
- IQTextInputViewNotification
|
||||
- IQTextInputViewNotification (1.0.8):
|
||||
- IQKeyboardCore
|
||||
- Kingfisher (8.1.2)
|
||||
- KVOController (1.2.0)
|
||||
- Kingfisher (8.1.3)
|
||||
- Material (3.1.8):
|
||||
- Material/Core (= 3.1.8)
|
||||
- Material/Core (3.1.8):
|
||||
@ -46,12 +44,11 @@ PODS:
|
||||
- "NSObject+Rx (5.2.2)":
|
||||
- RxSwift (~> 6.2)
|
||||
- ObjectMapper (4.4.2)
|
||||
- PLCrashReporter (1.11.2)
|
||||
- Realm (20.0.0):
|
||||
- Realm/Headers (= 20.0.0)
|
||||
- Realm/Headers (20.0.0)
|
||||
- RealmSwift (20.0.0):
|
||||
- Realm (= 20.0.0)
|
||||
- Realm (20.0.1):
|
||||
- Realm/Headers (= 20.0.1)
|
||||
- Realm/Headers (20.0.1)
|
||||
- RealmSwift (20.0.1):
|
||||
- Realm (= 20.0.1)
|
||||
- RxCocoa (6.8.0):
|
||||
- RxRelay (= 6.8.0)
|
||||
- RxSwift (= 6.8.0)
|
||||
@ -75,19 +72,16 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- CryptoSwift
|
||||
- DefaultsKit
|
||||
- DeviceKit
|
||||
- DropDown
|
||||
- FDFullscreenPopGesture
|
||||
- IQKeyboardManagerSwift/IQKeyboardToolbarManager
|
||||
- Kingfisher
|
||||
- KVOController
|
||||
- Material
|
||||
- MercariQRScanner (from `https://github.com/Finb/QRScanner`)
|
||||
- MJRefresh
|
||||
- Moya/RxSwift
|
||||
- "NSObject+Rx"
|
||||
- ObjectMapper
|
||||
- PLCrashReporter
|
||||
- RealmSwift
|
||||
- RxCocoa
|
||||
- RxDataSources
|
||||
@ -103,7 +97,6 @@ SPEC REPOS:
|
||||
- Alamofire
|
||||
- CryptoSwift
|
||||
- DefaultsKit
|
||||
- DeviceKit
|
||||
- Differentiator
|
||||
- DropDown
|
||||
- FDFullscreenPopGesture
|
||||
@ -114,14 +107,12 @@ SPEC REPOS:
|
||||
- IQKeyboardToolbarManager
|
||||
- IQTextInputViewNotification
|
||||
- Kingfisher
|
||||
- KVOController
|
||||
- Material
|
||||
- MJRefresh
|
||||
- Motion
|
||||
- Moya
|
||||
- "NSObject+Rx"
|
||||
- ObjectMapper
|
||||
- PLCrashReporter
|
||||
- Realm
|
||||
- RealmSwift
|
||||
- RxCocoa
|
||||
@ -145,9 +136,8 @@ CHECKOUT OPTIONS:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
||||
CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90
|
||||
DefaultsKit: ff9b29089d6fc08651b81be2d2387bee6eb98b75
|
||||
DeviceKit: d83e38ca196a4ebf0cc5f37d6f0316a24b794d5b
|
||||
Differentiator: e8497ceab83c1b10ca233716d547b9af21b9344d
|
||||
DropDown: 8a2116376c1981888557f72ec2ffc9a5e0e456ec
|
||||
FDFullscreenPopGesture: a8a620179e3d9c40e8e00256dcee1c1a27c6d0f0
|
||||
@ -157,8 +147,7 @@ SPEC CHECKSUMS:
|
||||
IQKeyboardToolbar: d4bdccfb78324aec2f3920659c77bb89acd33312
|
||||
IQKeyboardToolbarManager: 6f4072ac620c2572d4af8c09f42a801f3e4909f7
|
||||
IQTextInputViewNotification: f5e954d8881fd9808b744e49e024cc0d4bcfe572
|
||||
Kingfisher: dc989619594e24713c83e5a98f89f08850e0c529
|
||||
KVOController: d72ace34afea42468329623b3379ab3cd1d286b6
|
||||
Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef
|
||||
Material: a2a3f400a3b549d53ef89e56c58c4535b29db387
|
||||
MercariQRScanner: cd024685242f78fe40879cca9734bb7bb2fceb93
|
||||
MJRefresh: ff9e531227924c84ce459338414550a05d2aea78
|
||||
@ -166,9 +155,8 @@ SPEC CHECKSUMS:
|
||||
Moya: 138f0573e53411fb3dc17016add0b748dfbd78ee
|
||||
"NSObject+Rx": 61cf1f7306a73dcef8b36649198af0813ec18dfd
|
||||
ObjectMapper: e6e4d91ff7f2861df7aecc536c92d8363f4c9677
|
||||
PLCrashReporter: 499c53b0104f95c302d94fd723ebb03c56d9bac8
|
||||
Realm: a799b5f9145623fb0bfe149fe81422863631dd0f
|
||||
RealmSwift: f8c4354ebd5053343e6ab9f4eda244145308a51e
|
||||
Realm: 9572204903ef28caeb3c03bb30c1147f354ee3e7
|
||||
RealmSwift: d230b44017a55a1ba97de6a944da1fe3bedb92b5
|
||||
RxCocoa: 2d33c1e1e5d66492052ad46b11024ae287572880
|
||||
RxDataSources: aa47cc1ed6c500fa0dfecac5c979b723542d79cf
|
||||
RxGesture: f3efb47ed2d26a8082f7b660d4a59970e275a7f8
|
||||
@ -179,6 +167,6 @@ SPEC CHECKSUMS:
|
||||
SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a
|
||||
SwiftyStoreKit: 6b9c08810269f030586dac1fae8e75871a82e84a
|
||||
|
||||
PODFILE CHECKSUM: e499f90151cf50309319d3c7295e7cb0541725ef
|
||||
PODFILE CHECKSUM: a7da3cf53c17d876cd2e822046dc7499c69fe470
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@ -10,13 +10,12 @@ import Material
|
||||
import UIKit
|
||||
|
||||
class ServerListTableViewCell: BaseTableViewCell<ServerListTableViewCellViewModel> {
|
||||
|
||||
let backgroundPanel: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.cornerRadius = 3
|
||||
view.clipsToBounds = true
|
||||
view.backgroundColor = BKColor.background.secondary
|
||||
view.layer.cornerRadius = 25
|
||||
|
||||
view.clipsToBounds = true
|
||||
view.layer.borderColor = BKColor.grey.lighten3.cgColor
|
||||
view.layer.borderWidth = 1
|
||||
@ -29,6 +28,7 @@ class ServerListTableViewCell: BaseTableViewCell<ServerListTableViewCellViewMode
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.textColor = BKColor.grey.darken4
|
||||
label.numberOfLines = 0
|
||||
label.lineBreakMode = .byCharWrapping
|
||||
return label
|
||||
}()
|
||||
|
||||
@ -53,8 +53,7 @@ class ServerListTableViewCell: BaseTableViewCell<ServerListTableViewCellViewMode
|
||||
didSet {
|
||||
if state {
|
||||
stateImageView.image = UIImage(named: "online")
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
stateImageView.image = UIImage(named: "offline")
|
||||
}
|
||||
}
|
||||
@ -75,7 +74,6 @@ class ServerListTableViewCell: BaseTableViewCell<ServerListTableViewCellViewMode
|
||||
make.right.equalToSuperview().offset(-18)
|
||||
make.top.equalToSuperview().offset(5)
|
||||
make.bottom.equalToSuperview().offset(-5)
|
||||
make.height.equalTo(50)
|
||||
}
|
||||
|
||||
stateImageView.snp.makeConstraints { make in
|
||||
@ -85,12 +83,13 @@ class ServerListTableViewCell: BaseTableViewCell<ServerListTableViewCellViewMode
|
||||
}
|
||||
addressLabel.snp.makeConstraints { make in
|
||||
make.left.equalTo(stateImageView.snp.right).offset(8)
|
||||
make.top.equalTo(backgroundPanel).offset(8)
|
||||
make.right.equalTo(backgroundPanel).offset(-8)
|
||||
make.top.equalTo(backgroundPanel).offset(10)
|
||||
make.right.equalTo(backgroundPanel).offset(-18)
|
||||
}
|
||||
keyLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(addressLabel.snp.bottom).offset(1)
|
||||
make.left.right.equalTo(addressLabel)
|
||||
make.bottom.equalTo(backgroundPanel).offset(-10)
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,4 +115,9 @@ class ServerListTableViewCell: BaseTableViewCell<ServerListTableViewCellViewMode
|
||||
} onError: { _ in }
|
||||
.disposed(by: rx.reuseBag)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
self.backgroundPanel.layer.cornerRadius = self.backgroundPanel.bounds.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ platform :ios do
|
||||
upload_to_testflight
|
||||
|
||||
bark_key = ENV["BARK_KEY"]
|
||||
%x( 'curl' 'https://api.day.app/#{bark_key}/Bark%20has%20completed%20processing?isArchive=0&url=https%3A%2F%2Fgithub.com%2FFinb%2FBark%2Factions%2Fruns%2F#{run_id}' )
|
||||
%x( 'curl' 'https://api.day.app/#{bark_key}/Bark%20#{version}(#{build})%20has%20completed%20processing?group=Github%20Actions&url=https%3A%2F%2Fgithub.com%2FFinb%2FBark%2Factions%2Fruns%2F#{run_id}' )
|
||||
|
||||
end
|
||||
|
||||
|
||||
@ -78,7 +78,21 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
|
||||
if let copy = userInfo["copy"] as? String {
|
||||
UIPasteboard.general.string = copy
|
||||
} else {
|
||||
UIPasteboard.general.string = response.notification.request.content.body
|
||||
var content = ""
|
||||
if !response.notification.request.content.title.isEmpty {
|
||||
content += "\(response.notification.request.content.title)\n"
|
||||
}
|
||||
if !response.notification.request.content.subtitle.isEmpty {
|
||||
content += "\(response.notification.request.content.subtitle)\n"
|
||||
}
|
||||
if !response.notification.request.content.body.isEmpty {
|
||||
content += "\(response.notification.request.content.body)\n"
|
||||
}
|
||||
if let url = userInfo["url"] as? String, !url.isEmpty {
|
||||
content += "\(url)\n"
|
||||
}
|
||||
content = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
UIPasteboard.general.string = content
|
||||
}
|
||||
|
||||
showTips(text: NSLocalizedString("Copy", comment: ""))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user