Screen Time no iOS: o app não sabe quais apps o usuário escolheu (tokens e privacidade)
Oi, sou o Ayumu Kitabayashi e desenvolvo sozinho o minulo, um app de iPhone que esconde os Reels e os Shorts. Para bloquear os apps oficiais do Instagram e do YouTube, o minulo usa a API de Tempo de Uso da Apple (FamilyControls / ManagedSettings / DeviceActivity).
Nessa API, o seu app não recebe o nome dos apps que o usuário escolheu. O que chega são valores opacos (tokens). Para quem faz app de bloqueio isso atrapalha um pouco, mas na hora de preencher com honestidade a privacidade na App Store acabou ajudando. Abaixo vai o código de verdade e onde eu tropecei.
O app é feito com React Native (Expo). A parte de Tempo de Uso usa a biblioteca react-native-device-activity, do Robert Herber, com algumas mudanças minhas. Os trechos em Swift vêm desse lado nativo, um pouco encurtados.
O que a documentação da Apple diz
A página do FamilyActivitySelection diz:
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)
O FamilyActivityPicker, a tela de escolha, também é descrito como "without revealing their choices to the app". O que o app consegue fazer é só repassar esses valores para o ManagedSettings e o DeviceActivity.
Salvando a seleção: Codable e App Group
O FamilyActivitySelection é Codable, então dá para salvar como JSON em base64. No minulo, a biblioteca que eu uso grava nesse formato:
func serializeFamilyActivitySelection(selection: FamilyActivitySelection) -> String {
let encoder = JSONEncoder()
do {
let json = try encoder.encode(selection)
return json.base64EncodedString()
} catch {
return ""
}
}
O lugar é o UserDefaults do App Group. Quem tira e recoloca o bloqueio é a extensão DeviceActivityMonitor, então a seleção precisa ficar num lugar que o app e a extensão consigam ler.
let appGroup = Bundle.main.object(forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)
// uma seleção por id (grupo bloqueado de dia, grupo sempre liberado, grupo liberado na hora de dormir etc.)
familyActivitySelectionIds[id] = serializeFamilyActivitySelection(selection: activitySelection)
userDefaults?.set(familyActivitySelectionIds, forKey: "familyActivitySelectionIds")
Se você decodificar esse base64, vai ver arrays como applicationTokens, mas os valores só fazem sentido dentro do aparelho. Guardar isso como string não faz o desenvolvedor saber o nome de app nenhum.
Bloqueando: os tokens vão direto para o ManagedSettingsStore
Na hora de bloquear, o app lê a seleção salva e coloca no 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 // apps sempre liberados
)
} else {
store.shield.applicationCategories = nil
}
O nome do app não aparece em nenhum momento. Basta ter o token; o bloqueio funciona sem saber que app é.
O nome dá para desenhar com Label(token), mas não dá para ler como texto
O problema aparece quando você quer listar na tela os apps escolhidos. Passando o token para um Label do SwiftUI, aparecem o ícone e o nome:
ForEach(Array(shownApps.enumerated()), id: \.offset) { _, token in
Label(token)
}
A Apple chama isso de "read-only, visual representation" (https://developer.apple.com/documentation/familycontrols/displayingactivitylabels). O que aparece é uma view desenhada pelo sistema, e não existe API para tirar uma string dali.
Como o minulo é React Native, o lado JS não consegue receber os nomes e montar a lista. Então cada linha é uma view nativa: passo só o base64 da seleção e o índice da linha, e o Swift desenha o Label(token).
Existe também o localizedDisplayName de Application. Só que, pela documentação, ele só vem preenchido dentro da extensão que devolve a aparência do escudo (ShieldConfiguration); em qualquer outro lugar é nil (https://developer.apple.com/documentation/managedsettings/application/localizeddisplayname).
Onde eu tropecei
1. A ordem do Set muda toda vez
applicationTokens é um Set. Eu queria que tocar na "terceira linha" da lista liberasse só aquele app, mas a cada redesenho a ordem mudava, e abria um app diferente do que foi tocado. Como não dá para ordenar pelo nome, ordeno pela string do token codificado. O importante é usar a mesma ordenação na tela e na ação.
let tokens = Array(sel.applicationTokens).sorted {
(try? JSONEncoder().encode($0))?.base64EncodedString() ?? ""
< (try? JSONEncoder().encode($1))?.base64EncodedString() ?? ""
}
2. O mesmo conteúdo gera outro base64
Às vezes eu abria a tela de escolha, fechava sem mudar nada, e o base64 vinha diferente. Comparando as strings direto, o app achava que tinha mudado. Agora eu decodifico, ordeno os tokens e só então comparo.
3. String quebrada derruba o app
A biblioteca tinha um force unwrap no ponto que abre o base64, e uma string malformada derrubava o app no lado Swift. O try/catch do JS não pega isso. E como o bloqueio é reaplicado a cada abertura do app, depois que uma string ruim é salva o app não abre mais. Passei a conferir no JS pelo menos o tamanho e os caracteres antes de mandar.
4. Guarde a quantidade na hora da escolha
Eu queria mostrar "3 apps bloqueados", mas a função que conta o conteúdo da seleção depois às vezes devolvia 0. Então guardo à parte as quantidades (apps, categorias, sites) que chegam no evento no momento da escolha. O nome eu não sei, mas a quantidade eu consigo guardar.
5. Pergunte para a pessoa e guarde a resposta
Quando alguém toca em "Abrir no minulo" na tela de bloqueio, o ideal seria decidir pelo nome do app qual rede social abrir. Só que às vezes a Apple não passa o nome para a extensão. Nesse caso, dentro do minulo, a pessoa responde "qual app era", e eu guardo o par token + resposta no App Group. Isso também fica só no aparelho e não é enviado para fora.
Se a autorização for revogada
A documentação do FamilyActivitySelection também diz:
If a user, parent, or guardian revokes authorization of your app, any tokens that FamilyActivitySelection provided while your app was authorized are voided.
Os tokens salvos deixam de valer quando a autorização é revogada. No minulo, ao abrir o app ou voltar para o primeiro plano, leio o estado da autorização e só desligo o bloqueio quando ele aparece claramente como "negado", mostrando um aviso uma única vez. Logo depois de abrir, o estado às vezes aparece por um instante como "não determinado", e desligar nesse momento fazia um bloqueio ativo sumir sem aviso. Quando a pessoa autorizar de novo, acho mais seguro não contar com os tokens antigos e deixar ela escolher os apps outra vez.
Privacidade na App Store
Pela explicação da Apple, "coletar" é mandar o dado para fora do aparelho de um jeito que você ou terceiros possam acessar por mais tempo do que o necessário para atender a solicitação em tempo real. A Apple também diz que dados processados só no aparelho não precisam ser declarados:
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/)
No minulo, os tokens dos apps escolhidos ficam só no App Group e nos registros dentro do aparelho; nada disso sai. E como não dá para saber o nome do app a partir do token, "quais apps a pessoa bloqueia" nem chega a ser algo a declarar.
O cuidado está na segunda frase. O minulo manda para a ferramenta de análise (PostHog) o evento "salvou um horário de bloqueio", junto com a quantidade de apps escolhidos. Isso é um valor derivado dos tokens indo para fora, então declaro como dado de uso. Uma regra que ajuda a não esquecer: mesmo sem nome, se você manda quantidades ou contagens, isso entra na declaração.
Resumo
- FamilyActivitySelection é Codable. Salvo como JSON em base64 no App Group, o app e a extensão conseguem ler
- Para bloquear, basta passar os tokens para o ManagedSettingsStore; o nome não é necessário
- O nome aparece com
Label(token), mas não dá para ler como texto.localizedDisplayNamesó existe dentro da extensão de aparência do escudo - A ordem do Set, a variação do base64, strings quebradas e a contagem precisam de tratamento do seu lado
- Se a autorização for revogada, os tokens deixam de valer
- Sem mandar tokens para fora, não há o que declarar; mas se você manda números derivados deles, declare
Os problemas que tive com bloqueio por horário no DeviceActivity estão neste outro artigo.
O que o minulo envia e o que ele não consegue ver está nesta página (em inglês): https://minulo.app/en/is-minulo-safe.html?ref=dio-1023



