Screen Time API trên iOS: app không biết người dùng đã chọn app nào (lưu token, chặn và mục quyền riêng tư trên App Store)
Mình là Ayumu Kitabayashi, đang tự làm một app iPhone tên là minulo (ẩn Reels và Shorts). Để chặn app Instagram và YouTube chính thức, minulo dùng Screen Time API của Apple (FamilyControls / ManagedSettings / DeviceActivity).
Với API này, app của mình không nhận được tên các app mà người dùng đã chọn. Thứ nhận được chỉ là những giá trị "mờ" (token), không đọc được nội dung. Khi làm app chặn thì việc này hơi bất tiện, nhưng lúc khai mục quyền riêng tư trên App Store một cách trung thực thì nó lại giúp ích. Bài này ghi lại code thật và những chỗ mình bị vấp.
App viết bằng React Native (Expo); phần Screen Time dựa trên thư viện react-native-device-activity (tác giả Robert Herber) và có thêm code riêng. Các đoạn Swift bên dưới lấy từ phần native đó, có rút gọn một chút.
Tài liệu của Apple nói gì
Trang FamilyActivitySelection viết:
To protect the user's privacy, FamilyActivitySelection holds opaque values that represent categories, applications, and web domains selected by the user.
(https://developer.apple.com/documentation/familycontrols/familyactivityselection)
Màn hình chọn FamilyActivityPicker cũng được mô tả là "without revealing their choices to the app". Việc app làm được chỉ là chuyển các giá trị nhận được sang ManagedSettings và DeviceActivity.
Lưu lựa chọn: Codable rồi cất vào App Group
FamilyActivitySelection là Codable, nên có thể encode thành JSON rồi lưu dưới dạng chuỗi base64. Trong minulo, hàm của thư viện đang dùng ghi ra đúng dạng này:
func serializeFamilyActivitySelection(selection: FamilyActivitySelection) -> String {
let encoder = JSONEncoder()
do {
let json = try encoder.encode(selection)
return json.base64EncodedString()
} catch {
return ""
}
}
Nơi lưu là UserDefaults của App Group. Người gỡ và bật lại lệnh chặn là extension DeviceActivityMonitor, nên dữ liệu phải nằm ở chỗ mà cả app chính lẫn extension đều đọc được.
let appGroup = Bundle.main.object(forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)
// mỗi id giữ một lựa chọn (nhóm chặn ban ngày, nhóm luôn cho phép, nhóm dùng được cả lúc ngủ, v.v.)
familyActivitySelectionIds[id] = serializeFamilyActivitySelection(selection: activitySelection)
userDefaults?.set(familyActivitySelectionIds, forKey: "familyActivitySelectionIds")
Decode chuỗi base64 này ra sẽ thấy các mảng như applicationTokens, nhưng giá trị bên trong chỉ có nghĩa trên chính thiết bị đó. Lưu dưới dạng chuỗi cũng không làm developer biết được tên app nào.
Chặn: đưa thẳng token vào ManagedSettingsStore
Khi chặn, app đọc lại lựa chọn đã lưu rồi gán vào shield:
let store = ManagedSettingsStore()
store.shield.applications = blocklist.applicationTokens
store.shield.webDomains = blocklist.webDomainTokens
if blocklist.categoryTokens.count > 0 {
store.shield.applicationCategories = .specific(
blocklist.categoryTokens,
except: whitelist.applicationTokens // các app luôn được phép
)
} else {
store.shield.applicationCategories = nil
}
Ở đây tên app cũng không xuất hiện lần nào. Chỉ cần có token là chặn được, không cần biết đó là app gì.
Tên thì hiển thị được bằng Label(token), nhưng không lấy được dưới dạng chữ
Khó ở chỗ khi muốn liệt kê các app đã chọn lên màn hình. Truyền token vào Label của SwiftUI thì icon và tên app sẽ hiện ra:
ForEach(Array(shownApps.enumerated()), id: \.offset) { _, token in
Label(token)
}
Apple gọi đây là "read-only, visual representation" (https://developer.apple.com/documentation/familycontrols/displayingactivitylabels). Thứ hiện ra chỉ là view do hệ thống vẽ, không có API nào lấy chuỗi ra từ đó.
minulo là app React Native nên phía JS không thể nhận tên app để tự dựng danh sách. Vì vậy mỗi dòng là một native view: mình chỉ truyền chuỗi base64 của lựa chọn và "dòng thứ mấy", còn phía Swift vẽ Label(token).
Application cũng có localizedDisplayName. Nhưng theo tài liệu, tên chỉ có giá trị bên trong extension trả về giao diện của màn hình chặn (ShieldConfiguration); ở chỗ khác thì là nil (https://developer.apple.com/documentation/managedsettings/application/localizeddisplayname). Chuyện bấm nút trên màn hình chặn để chuyển sang app của mình thì mình đã viết ở bài trước.
Những chỗ mình đã vấp
1. Thứ tự của Set thay đổi mỗi lần
applicationTokens là một Set. Mình muốn bấm vào "dòng thứ 3" trong danh sách để chỉ mở riêng app đó, nhưng mỗi lần vẽ lại thứ tự lại đổi, và app được mở khác với dòng đã bấm. Vì không sắp theo tên được, mình sắp theo chuỗi đã encode của token. Điều quan trọng là phía hiển thị và phía xử lý phải dùng cùng một cách sắp.
let tokens = Array(sel.applicationTokens).sorted {
(try? JSONEncoder().encode($0))?.base64EncodedString() ?? ""
< (try? JSONEncoder().encode($1))?.base64EncodedString() ?? ""
}
2. Cùng nội dung nhưng base64 khác
Có lúc mở màn hình chọn, không đổi gì rồi đóng lại, chuỗi base64 trả về vẫn khác lần trước. So sánh chuỗi trực tiếp thì app sẽ tưởng là "có thay đổi", nên mình decode ra, sắp lại các token rồi mới so.
3. Truyền chuỗi hỏng vào là crash
Trong thư viện có một chỗ force unwrap lúc mở base64, nên truyền một chuỗi sai định dạng vào là crash ở phía Swift. try/catch bên JS không bắt được. Mà việc bật lại lệnh chặn chạy mỗi lần mở app, nên một khi chuỗi hỏng đã được lưu thì app không mở lên được nữa. Mình đã thêm bước kiểm tra độ dài và loại ký tự ở JS trước khi truyền.
4. Ghi lại số lượng ngay lúc chọn
Mình muốn hiện "Đang chặn 3 app", nhưng hàm đếm nội dung lựa chọn về sau đôi khi trả về 0. Vì vậy mình lưu riêng số lượng (app, danh mục, trang web) có trong sự kiện gửi về ngay lúc người dùng chọn. Không biết tên, nhưng số lượng thì mình tự giữ được.
5. Hỏi người dùng rồi nhớ lại
Khi bấm "Mở trong minulo" từ extension của màn hình chặn, lẽ ra nên dựa vào tên app để quyết định mở mạng xã hội nào. Nhưng có lúc Apple không truyền tên cho extension. Khi đó, mình để người dùng trả lời "đó là app nào" ngay trong minulo, rồi lưu cặp token và câu trả lời vào App Group. Dữ liệu này cũng chỉ nằm trên máy, không gửi ra ngoài.
Khi quyền bị thu hồi
Tài liệu FamilyActivitySelection còn có câu này:
If a user, parent, or guardian revokes authorization of your app, any tokens that FamilyActivitySelection provided while your app was authorized are voided.
Token đã lưu sẽ hết hiệu lực khi quyền bị thu hồi. Trong minulo, mỗi lần mở app hoặc quay lại foreground, mình đọc trạng thái quyền, và chỉ tắt lệnh chặn khi đọc được rõ ràng là "bị từ chối", kèm một thông báo hiện đúng một lần. Ngay sau khi mở app, trạng thái đôi khi hiện "chưa xác định" trong chốc lát; tắt vào lúc đó thì lệnh chặn đang chạy bị gỡ mất mà người dùng không hề biết. Khi người dùng cấp quyền lại, mình nghĩ an toàn hơn là đừng giả định token cũ còn dùng được, mà để họ chọn lại.
Mục quyền riêng tư trên App Store
Theo giải thích của Apple, "thu thập" là gửi dữ liệu ra khỏi thiết bị và để developer hoặc bên thứ ba truy cập được lâu hơn thời gian cần cho việc xử lý theo thời gian thực. Apple cũng ghi rằng dữ liệu chỉ xử lý trên thiết bị thì không cần khai:
Data that is processed only on device is not "collected" and does not need to be disclosed in your answers. If you derive anything from that data and send it off device, the resulting data should be considered separately.
(https://developer.apple.com/app-store/app-privacy-details/)
Với minulo, token của các app đã chọn chỉ nằm trong App Group và các bản ghi trên máy, không gửi ra ngoài. Từ token cũng không biết được tên app, nên "đang chặn những app nào" vốn không có gì để khai.
Cần để ý là câu thứ hai. minulo gửi sự kiện "đã lưu lịch chặn" lên công cụ đo lường (PostHog), kèm theo số app đã chọn. Đây là giá trị tạo ra từ token và được gửi ra ngoài, nên mình khai nó trong mục dữ liệu sử dụng. Cứ nghĩ là: dù không biết tên, nếu gửi số lượng hay số lần thì vẫn phải khai, như vậy sẽ ít bị sót.
Tóm tắt
- FamilyActivitySelection là Codable. Encode JSON sang base64 rồi để trong App Group thì cả app chính lẫn extension đều đọc được
- Chặn chỉ cần đưa token vào ManagedSettingsStore, không cần tên
- Tên hiển thị được bằng
Label(token)nhưng không lấy ra dạng chữ được.localizedDisplayNamechỉ có trong extension giao diện màn hình chặn - Thứ tự của Set, base64 thay đổi, chuỗi hỏng và cách đếm số lượng đều phải tự xử lý
- Quyền bị thu hồi thì token hết hiệu lực
- Không gửi token ra ngoài thì không cần khai, nhưng nếu gửi con số tạo ra từ token thì phải khai
minulo gửi gì và không nhìn thấy gì, mình đã tổng hợp ở trang này (tiếng Anh): https://minulo.app/en/is-minulo-safe.html?ref=viblo-1023
All rights reserved