Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 61 additions & 51 deletions Projects/App/iOS/Sources/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import UserDefaultsClient
import WatchConnectivity
import WidgetKit

private let watchSyncNotification = Notification.Name("TodayWhatWatchSyncDidRequest")

final class AppDelegate: UIResponder, UIApplicationDelegate {
@Dependency(\.userDefaultsClient) var userDefaultsClient
@Dependency(\.localDatabaseClient) var localDatabaseClient
Expand Down Expand Up @@ -44,6 +46,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
session.delegate = self
session.activate()
}
NotificationCenter.default.addObserver(
self,
selector: #selector(handleWatchSyncNotification(_:)),
name: watchSyncNotification,
object: nil
)
TWLog.setUserProperty(property: .activeWatch, value: WCSession.default.isWatchAppInstalled ? true : false)

if let schoolTypeRawString = self.userDefaultsClient.getValue(.schoolType) as? String,
Expand Down Expand Up @@ -104,64 +112,15 @@ extension AppDelegate: WCSessionDelegate {
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
guard
let type = userDefaultsClient.getValue(.schoolType) as? String,
let code = userDefaultsClient.getValue(.schoolCode) as? String,
let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
let grade = userDefaultsClient.getValue(.grade) as? Int,
let `class` = userDefaultsClient.getValue(.class) as? Int
else {
return
}
let isOnModifiedTimeTable = userDefaultsClient.getValue(.isOnModifiedTimeTable) as? Bool ?? false
let timeTables = try? localDatabaseClient.readRecords(as: ModifiedTimeTableLocalEntity.self)
var dict: [String: Any] = [
"type": type,
"code": code,
"orgCode": orgCode,
"grade": grade,
"class": `class`,
"isOnModifiedTimeTable": isOnModifiedTimeTable,
"timeTables": encodeTimeTables(timeTables: timeTables ?? [])
]
if let major = userDefaultsClient.getValue(.major) as? String {
dict["major"] = major
}

session.sendMessage(dict, replyHandler: nil) { error in
TWLog.error(error.localizedDescription)
}
pushCurrentWatchData()
}

func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
guard
let type = userDefaultsClient.getValue(.schoolType) as? String,
let code = userDefaultsClient.getValue(.schoolCode) as? String,
let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
let grade = userDefaultsClient.getValue(.grade) as? Int,
let `class` = userDefaultsClient.getValue(.class) as? Int
else {
return
}
let isOnModifiedTimeTable = userDefaultsClient.getValue(.isOnModifiedTimeTable) as? Bool ?? false
let timeTables = try? localDatabaseClient.readRecords(as: ModifiedTimeTableLocalEntity.self)
var reply: [String: Any] = [
"type": type,
"code": code,
"orgCode": orgCode,
"grade": grade,
"class": `class`,
"isOnModifiedTimeTable": isOnModifiedTimeTable,
"timeTables": encodeTimeTables(timeTables: timeTables ?? [])
]
if let major = userDefaultsClient.getValue(.major) as? String {
reply["major"] = major
}

guard let reply = buildWatchPayload() else { return }
replyHandler(reply)
}

Expand Down Expand Up @@ -191,6 +150,57 @@ extension AppDelegate: WCSessionDelegate {
// swiftlint: enable force_try
return data
}

private func buildWatchPayload() -> [String: Any]? {
guard
let type = userDefaultsClient.getValue(.schoolType) as? String,
let code = userDefaultsClient.getValue(.schoolCode) as? String,
let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
let grade = userDefaultsClient.getValue(.grade) as? Int,
let `class` = userDefaultsClient.getValue(.class) as? Int
else {
return nil
}

let isOnModifiedTimeTable = userDefaultsClient.getValue(.isOnModifiedTimeTable) as? Bool ?? false
let timeTables = try? localDatabaseClient.readRecords(as: ModifiedTimeTableLocalEntity.self)
var payload: [String: Any] = [
"type": type,
"code": code,
"orgCode": orgCode,
"grade": grade,
"class": `class`,
"isOnModifiedTimeTable": isOnModifiedTimeTable,
"timeTables": encodeTimeTables(timeTables: timeTables ?? [])
]
if let major = userDefaultsClient.getValue(.major) as? String {
payload["major"] = major
}
return payload
}

@objc
private func handleWatchSyncNotification(_ notification: Notification) {
pushCurrentWatchData()
}

private func pushCurrentWatchData() {
guard let payload = buildWatchPayload() else { return }

do {
try session.updateApplicationContext(payload)
} catch {
TWLog.error(error)
}

guard session.activationState == .activated, session.isWatchAppInstalled, session.isReachable else {
return
}

session.sendMessage(payload, replyHandler: nil) { error in
TWLog.error(error.localizedDescription)
}
}
Comment on lines +187 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

On devices where WCSession is not supported (such as iPads) or when the session is not yet activated, calling updateApplicationContext will throw an error and flood the logs with WCErrorDomain Code=7001 ("Session is not activated.").

To prevent this, guard against WCSession.isSupported() and check that session.activationState == .activated before attempting to update the application context.

Suggested change
private func pushCurrentWatchData() {
guard let payload = buildWatchPayload() else { return }
do {
try session.updateApplicationContext(payload)
} catch {
TWLog.error(error)
}
guard session.activationState == .activated, session.isWatchAppInstalled, session.isReachable else {
return
}
session.sendMessage(payload, replyHandler: nil) { error in
TWLog.error(error.localizedDescription)
}
}
private func pushCurrentWatchData() {
guard WCSession.isSupported(), session.activationState == .activated else {
return
}
guard let payload = buildWatchPayload() else { return }
do {
try session.updateApplicationContext(payload)
} catch {
TWLog.error(error)
}
guard session.isWatchAppInstalled, session.isReachable else {
return
}
session.sendMessage(payload, replyHandler: nil) { error in
TWLog.error(error.localizedDescription)
}
}

}

private extension AppDelegate {
Expand Down
108 changes: 53 additions & 55 deletions Projects/App/watchOS/Sources/Manager/WatchSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import WatchConnectivity
final class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
@Dependency(\.userDefaultsClient) var userDefaultsClient
@Dependency(\.localDatabaseClient) var localDatabaseClient
@Published private(set) var syncVersion: Int = 0

var isReachable: Bool {
session.isReachable
Expand All @@ -24,34 +25,7 @@ final class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
error: Error?
) {
sendMessage(message: [:]) { [weak self] items in
guard let self else { return }
guard
let code = items["code"] as? String,
let orgCode = items["orgCode"] as? String,
let grade = items["grade"] as? Int,
let `class` = items["class"] as? Int,
let type = items["type"] as? String,
let isOnModifiedTimeTable = items["isOnModifiedTimeTable"] as? Bool,
let timeTablesData = items["timeTables"] as? Data
else {
return
}
let timeTables = self.decodeTimeTables(data: timeTablesData)
let dict: [UserDefaultsKeys: Any] = [
.grade: grade,
.class: `class`,
.schoolType: type,
.orgCode: orgCode,
.schoolCode: code,
.isOnModifiedTimeTable: isOnModifiedTimeTable
]
dict.forEach { key, value in
self.userDefaultsClient.setValue(key, value)
}
if let major = items["major"] as? String {
self.userDefaultsClient.setValue(.major, major)
}
try? self.localDatabaseClient.save(records: timeTables)
self?.applyIncomingItems(items)
}
}

Expand All @@ -78,33 +52,15 @@ final class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
replyHandler: @escaping ([String: Any]) -> Void
) {
print("RECEIVE : \(message)")
guard
let code = message["code"] as? String,
let orgCode = message["orgCode"] as? String,
let grade = message["grade"] as? Int,
let `class` = message["class"] as? Int,
let type = message["type"] as? String,
let isOnModifiedTimeTable = message["isOnModifiedTimeTable"] as? Bool,
let timeTablesData = message["timeTables"] as? Data
else {
return
}
let timeTables = decodeTimeTables(data: timeTablesData)
let dict: [UserDefaultsKeys: Any] = [
.grade: grade,
.class: `class`,
.schoolType: type,
.orgCode: orgCode,
.schoolCode: code,
.isOnModifiedTimeTable: isOnModifiedTimeTable
]
dict.forEach { key, value in
self.userDefaultsClient.setValue(key, value)
}
if let major = message["major"] as? String {
self.userDefaultsClient.setValue(.major, major)
}
try? self.localDatabaseClient.save(records: timeTables)
applyIncomingItems(message)
}

func session(
_ session: WCSession,
didReceiveMessage message: [String: Any]
) {
print("RECEIVE : \(message)")
applyIncomingItems(message)
}

func sendMessage(
Expand All @@ -123,10 +79,52 @@ final class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
session.sendMessage(message, replyHandler: reply, errorHandler: error)
}

func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
applyIncomingItems(applicationContext)
}

// swiftlint: disable force_try
private func decodeTimeTables(data: Data) -> [ModifiedTimeTableLocalEntity] {
let entities = try! JSONDecoder().decode([ModifiedTimeTableLocalEntity].self, from: data)
return entities
}

private func applyIncomingItems(_ items: [String: Any]) {
guard
let code = items["code"] as? String,
let orgCode = items["orgCode"] as? String,
let grade = items["grade"] as? Int,
let `class` = items["class"] as? Int,
let type = items["type"] as? String,
let isOnModifiedTimeTable = items["isOnModifiedTimeTable"] as? Bool,
let timeTablesData = items["timeTables"] as? Data
else {
return
}

let timeTables = decodeTimeTables(data: timeTablesData)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

외부 페이로드 디코딩에 try!를 사용하면 크래시 위험이 큽니다.

watch 통신 데이터가 손상되거나 스키마가 어긋나면 즉시 크래시 납니다. 안전 디코딩으로 바꿔야 합니다.

제안 패치
-    private func decodeTimeTables(data: Data) -> [ModifiedTimeTableLocalEntity] {
-        let entities = try! JSONDecoder().decode([ModifiedTimeTableLocalEntity].self, from: data)
-        return entities
-    }
+    private func decodeTimeTables(data: Data) -> [ModifiedTimeTableLocalEntity]? {
+        try? JSONDecoder().decode([ModifiedTimeTableLocalEntity].self, from: data)
+    }
...
-        let timeTables = decodeTimeTables(data: timeTablesData)
+        guard let timeTables = decodeTimeTables(data: timeTablesData) else { return }

Also applies to: 90-93

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/App/watchOS/Sources/Manager/WatchSessionManager.swift` at line 108,
The call to decodeTimeTables using force-unwrap (try!) is unsafe and can crash
when incoming watch payloads are corrupt; change uses of try! (including the
similar calls around lines 90-93) to a do-catch or try? pattern so decoding
errors are caught, log the decoding error (use the existing logger or os_log)
and handle failure by returning an empty/default value or early-returning
gracefully instead of crashing; specifically update the call site that invokes
decodeTimeTables(...) and any other decoding calls in WatchSessionManager to
call the throwing decoder safely and handle the thrown error in the catch block.

let dict: [UserDefaultsKeys: Any] = [
.grade: grade,
.class: `class`,
.schoolType: type,
.orgCode: orgCode,
.schoolCode: code,
.isOnModifiedTimeTable: isOnModifiedTimeTable
]
dict.forEach { key, value in
self.userDefaultsClient.setValue(key, value)
}
if let major = items["major"] as? String {
self.userDefaultsClient.setValue(.major, major)
}
try? self.localDatabaseClient.deleteAll(record: ModifiedTimeTableLocalEntity.self)
try? self.localDatabaseClient.save(records: timeTables)
DispatchQueue.main.async {
self.syncVersion += 1
}
Comment on lines +123 to +127

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

삭제/저장 실패를 무시하면 데이터 유실 후에도 syncVersion이 증가할 수 있습니다.

try?로 실패를 삼키지 말고, 저장 성공 시점에만 버전을 올리도록 처리해 주세요.

제안 패치
-        try? self.localDatabaseClient.deleteAll(record: ModifiedTimeTableLocalEntity.self)
-        try? self.localDatabaseClient.save(records: timeTables)
-        DispatchQueue.main.async {
-            self.syncVersion += 1
-        }
+        do {
+            try self.localDatabaseClient.deleteAll(record: ModifiedTimeTableLocalEntity.self)
+            try self.localDatabaseClient.save(records: timeTables)
+            DispatchQueue.main.async {
+                self.syncVersion += 1
+            }
+        } catch {
+            // 필요 시 로깅 추가
+            return
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/App/watchOS/Sources/Manager/WatchSessionManager.swift` around lines
123 - 127, The current code in WatchSessionManager silently ignores failures
from localDatabaseClient.deleteAll(record: ModifiedTimeTableLocalEntity.self)
and localDatabaseClient.save(records: timeTables) using try?, which can advance
syncVersion even if the write failed; change this to perform the delete and save
inside a do/catch (or use the clients' completion callbacks) and only on
successful save increment syncVersion on the main queue
(DispatchQueue.main.async { self.syncVersion += 1 }); if save fails,
log/propagate the error and do not change syncVersion (optionally rollback or
handle partial state as appropriate).

}
Comment on lines +95 to +128

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Because watch sync payloads are delivered via both updateApplicationContext and sendMessage simultaneously, applyIncomingItems will be called twice in rapid succession when the watch app is in the foreground.

This causes redundant database deletions/writes and triggers duplicate UI reloads. Adding an equality check to compare the incoming payload with the current local state will prevent this redundancy and improve performance on the Apple Watch.

    private func applyIncomingItems(_ items: [String: Any]) {
        guard
            let code = items["code"] as? String,
            let orgCode = items["orgCode"] as? String,
            let grade = items["grade"] as? Int,
            let classValue = items["class"] as? Int,
            let type = items["type"] as? String,
            let isOnModifiedTimeTable = items["isOnModifiedTimeTable"] as? Bool,
            let timeTablesData = items["timeTables"] as? Data
        else {
            return
        }

        let timeTables = decodeTimeTables(data: timeTablesData)

        let currentGrade = userDefaultsClient.getValue(.grade) as? Int
        let currentClass = userDefaultsClient.getValue(.class) as? Int
        let currentSchoolType = userDefaultsClient.getValue(.schoolType) as? String
        let currentOrgCode = userDefaultsClient.getValue(.orgCode) as? String
        let currentSchoolCode = userDefaultsClient.getValue(.schoolCode) as? String
        let currentIsOnModifiedTimeTable = userDefaultsClient.getValue(.isOnModifiedTimeTable) as? Bool
        let currentMajor = userDefaultsClient.getValue(.major) as? String
        let incomingMajor = items["major"] as? String

        let currentLocalTimeTables = (try? localDatabaseClient.readRecords(as: ModifiedTimeTableLocalEntity.self)) ?? []

        if currentGrade == grade,
           currentClass == classValue,
           currentSchoolType == type,
           currentOrgCode == orgCode,
           currentSchoolCode == code,
           currentIsOnModifiedTimeTable == isOnModifiedTimeTable,
           currentMajor == incomingMajor,
           currentLocalTimeTables == timeTables {
            return
        }

        let dict: [UserDefaultsKeys: Any] = [
            .grade: grade,
            .class: classValue,
            .schoolType: type,
            .orgCode: orgCode,
            .schoolCode: code,
            .isOnModifiedTimeTable: isOnModifiedTimeTable
        ]
        dict.forEach { key, value in
            self.userDefaultsClient.setValue(key, value)
        }
        if let major = items["major"] as? String {
            self.userDefaultsClient.setValue(.major, major)
        }
        try? self.localDatabaseClient.deleteAll(record: ModifiedTimeTableLocalEntity.self)
        try? self.localDatabaseClient.save(records: timeTables)
        DispatchQueue.main.async {
            self.syncVersion += 1
        }
    }

// swiftlint: enable force_try
}
6 changes: 6 additions & 0 deletions Projects/App/watchOS/Sources/Scenes/Main/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI

struct MainView: View {
@StateObject var viewModel = MainViewModel()
@StateObject var watchSessionManager = WatchSessionManager.shared
@State var isPresentedOption = false
private let mainColor: Color = Color("Main")
private let subColor: Color = Color("Sub")
Expand Down Expand Up @@ -104,6 +105,11 @@ struct MainView: View {
.task {
await viewModel.loadData()
}
.onChange(of: watchSessionManager.syncVersion) { _ in
Task {
await viewModel.loadData()
}
}
.refreshable {
Task {
await viewModel.loadData()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ public struct ModifyTimeTableCore: Reducer {
WidgetCenter.shared.reloadTimelines(ofKind: "TodayWhatTimeTableWidget")
state.isShowingSuccessToast = true
userDefaultsClient.setValue(.isOnModifiedTimeTable, true)
NotificationCenter.default.post(
name: Notification.Name("TodayWhatWatchSyncDidRequest"),
object: nil
)

case let .toastDismissed(dismissed):
state.isShowingSuccessToast = dismissed
Expand Down
5 changes: 5 additions & 0 deletions Projects/Feature/SettingsFeature/Sources/SettingsCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AllergySettingFeature
import BaseFeature
import ComposableArchitecture
import DeviceClient
import Foundation
import ITunesClient
import ModifyTimeTableFeature
import SchoolSettingFeature
Expand Down Expand Up @@ -100,6 +101,10 @@ public struct SettingsCore: Reducer {
case let .isOnModifiedTimeTableChagned(isOnModifiedTimeTable):
state.isOnModifiedTimeTable = isOnModifiedTimeTable
userDefaultsClient.setValue(.isOnModifiedTimeTable, isOnModifiedTimeTable)
NotificationCenter.default.post(
name: Notification.Name("TodayWhatWatchSyncDidRequest"),
object: nil
)

let log = IsOnModifiedTimeTableToggledEventLog(isOnModifiedTimeTable: isOnModifiedTimeTable)
TWLog.event(log)
Expand Down
Loading