image

Unlimited bootcamps and 750+ courses forever

70
%OFF

AK

Ayumu Kitabayashi23/10/2026 07:59
Share

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. localizedDisplayName só 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

    Share
    Recommended for you
    Neo4j - Certification
    DXC - IA do Zero ao Primeiro Agente
    Reclame AQUI - Dados e IA na Prática
    Comments (0)