Dokumentacja techniczna · Android · wrzesień 2026

Digital Notary od środka

Jak aplikacja zamienia zdjęcie, wideo lub nagranie audio w dowód, którego nie da się podważyć: każda ścieżka od dotknięcia przycisku do potwierdzenia zdarzenia, z nazwami klas, endpointami i regułami, których trzyma się kod.

Pakiet com.arctical.digitalnotary minSdk 29 · target 37 Stos Compose · Navigation 3 · Hilt · Room · WorkManager · CameraX 1.6 · c2pa-android 0.0.12 Stan przepływ zweryfikowany end-to-end na emulatorze (tryb fake API)

01Model dowodu

Aplikacja nie polega na jednym zabezpieczeniu. Dowód składa się z czterech niezależnych warstw; podważenie materiału wymaga złamania wszystkich naraz.

I

Hard binding C2PA

Manifest C2PA (claim v2) osadzony w pliku zawiera hash bajtów materiału i jest podpisany kluczem P-256, który nigdy nie opuszcza Keystore/StrongBox telefonu. Certyfikat wystawia nasze API po weryfikacji key attestation.

II

Czas związany z dwóch stron

Serwer wydaje id zdarzenia przed nagraniem; kod QR z tym id jest wypalany w pikselach. Plik wraca na serwer po. Moment powstania mieści się między dwoma znacznikami czasu serwera.

III

Sygnały otoczenia

Dwa źródła lokalizacji z flagami isMock, BSSID WiFi, komórki, Play Integrity, stan urządzenia — wszystko trafia do asercji manifestu i jest oceniane po stronie API. Nic nie blokuje nagrania, wszystko jest raportowane.

IV

Potwierdzenie out-of-band

Po odebraniu pliku API wysyła push (FCM) z kodem; dotknięcie notyfikacji odsyła kod z powrotem. Zamyka to pętlę: to urządzenie, ten użytkownik, ten moment.

Zasada projektowa: hash liczymy w tym samym procesie, w tej samej sekundzie, w której plik został zamknięty. Wszystko późniejsze (manifest, upload, push) może się opóźnić lub powtórzyć — ale originalSha256 jest już zapisany w Room, a materiał leży w prywatnym katalogu, do którego żadna inna aplikacja nie ma dostępu.

02Architektura

Jeden moduł :app, MVVM z jednokierunkowym przepływem danych (ViewModel → StateFlow<UiState>, zdarzenia jako wywołania funkcji), pakiety według funkcji. Room jest jedynym źródłem prawdy dla UI; sieć zasila Room, a nie ekrany.

FEATURE · COMPOSE + VIEWMODEL auth home history capture settings CORE · DOMENA EventRepository EventPipeline AuthRepository CORE · INFRASTRUKTURA RoomEventDao EventFileStorefilesDir/events/{id} WorkManagerSealAndUploadWorker NotaryApiRetrofit / FakeNotaryApi DeviceKeyManagerKeystore · attestation C2paSealerc2pa-android (c2pa-rs) Location / Radiofused · GNSS · WiFi · cell PlayIntegrity / DeviceStatenonce = eventId SessionStore · certyfikatEncryptedSharedPreferences NotaryFcmService · PushConfirmHandlerdata-push → notyfikacja → deep link observe / wywołania Room = źródło prawdy Backend (API) wydaje id + nonce, CA dla C2PA, weryfikuje hash, manifest, sygnały, wysyła push z kodem HTTPS · Bearer session Sprzęt urządzenia StrongBox / TEE · kamera · mikrofon GNSS · WiFi · modem Play Services · FCM
Warstwy aplikacji. Ekrany rozmawiają tylko z repozytoriami i pipeline'em; cała logika dowodowa (hash, manifest, podpis) siedzi w core/ i da się testować bez UI.

Mapa pakietów

core/network
Retrofit + kotlinx.serialization, DTO, AuthInterceptor (Bearer, globalne wylogowanie na 401), apiCall {} mapujący wyjątki na ApiException, FakeNotaryApi.
core/auth
SessionStore (EncryptedSharedPreferences), AuthRepository, GoogleSignInClient (Credential Manager), przepływ Apple przez Custom Tabs.
core/events
Model NotaryEvent, EventStatus, EventRepository — scalanie stron z API do Room bez degradacji lokalnego statusu.
core/storage
Room (EventEntity, EventDao), EventFileStore — pliki w filesDir/events/{id}/.
core/crypto
DeviceKeyManager (klucz, attestation, CSR, enrollment, podpis ES256), Hashing, EcdsaDer, SelfSignedCertificates (tylko tryb fake).
core/c2pa
ManifestBuilder (JSON manifestu), C2paSealer (osadzenie i podpis przez Signer.withCallback, lokalny odczyt Reader).
core/location · integrity
LocationSnapshotProvider, RadioSnapshotProvider, PlayIntegrityClient, DeviceStateCollector, model EvidenceMetadata.
core/upload
EventPipeline (hash + rejestracja + kolejkowanie), SealAndUploadWorker (pieczętowanie i wysyłka w tle).
core/push
NotaryFcmService, PushConfirmHandler (notyfikacja z deep linkiem), PushTokenRegistrar.
feature/*
auth, home, history (+ szczegóły), capture (foto/wideo/audio), settings — każdy z własnym ViewModelem.
navigation
Navigation 3: klucze @Serializable, dwa niezależne backstacki (przed i po zalogowaniu), parser deep linków.

03Cykl życia zdarzenia

Każde zdarzenie ma wiersz w Room od chwili wydania id przez serwer. Status przesuwa się tylko do przodu; jedyne przejścia wsteczne to jawne ponowienia.

CREATED CAPTURED SEALED UPLOADING UPLOADED CONFIRMED FAILED plik zamknięty+ SHA-256 manifest C2PA+ hash pliku worker 200 z API kod z pusha wstecz przed nagraniem błąd zapisu / certyfikatu błąd 4xx sieć / 5xx → retry (backoff) „Ponów wysyłanie” (jeśli plik istnieje)
Stany EventStatus. Przejścia sieciowe są ponawiane przez WorkManager (do 20 prób, wykładniczy backoff od 30 s); błędy kryptograficzne i 4xx kończą się FAILED z komunikatem widocznym w szczegółach zdarzenia.
StatusKto ustawiaCo jest już nieodwracalnie zapisane
CREATEDEventRepository.createEventid, nonce, verifyUrl, serverTime z API, elapsedRealtimeNanos urządzenia
CAPTUREDEventPipeline.registerCaptureścieżka pliku, originalSha256, pełne EvidenceMetadata (JSON), token Play Integrity
SEALEDSealAndUploadWorker.sealsealed.* z manifestem, SHA-256 pliku wynikowego, SHA-256 manifestu, securityLevel; oryginał usunięty
UPLOADING → UPLOADEDSealAndUploadWorker.uploadserwer ma plik, hash, metadane, token
CONFIRMEDEventRepository.confirm (po deep linku z pusha)kod zgodny z zapisanym w API
FAILEDworker / discard / błąd zapisuerrorMessage; plik zostaje, jeśli istniał

04Ścieżki krok po kroku

4.1 Start aplikacji i sesja

MainActivity jest jedyną aktywnością (launchMode=singleTask). Przy starcie przekazuje intent.data do MainViewModel.onIntentUri, a AppRoot obserwuje isLoggedIn wyprowadzone z SessionStore.session:

  • Brak sesji → backstack AuthNavigation (Login → EmailCode).
  • Sesja jest → backstack AppNavigation (Home → History / EventDetail / Capture / Settings).

W init MainViewModel robi dwie rzeczy w tle: EventPipeline.resumePending() (patrz 4.12) i rejestrację tokenu FCM po każdym pojawieniu się sesji (PushTokenRegistrar). Token sesji jest opaque i długożyjący; AuthInterceptor dokleja go jako Authorization: Bearer i — gdy dowolny endpoint spoza /auth/ odpowie 401 — czyści SessionStore, co natychmiast przełącza UI na ekran logowania.

4.2 Logowanie

E-mail (kod jednorazowy)

sequenceDiagram
  autonumber
  participant U as Użytkownik
  participant L as LoginScreen / AuthViewModel
  participant A as API
  participant S as SessionStore
  U->>L: adres e-mail, „Zarejestruj / zaloguj”
  L->>A: POST /auth/email/request {email}
  A-->>L: 204 (kod ważny 1 h wysłany mailem)
  L->>L: codeSentTo = email → nawigacja do EmailCodeKey
  U->>L: kod z maila
  L->>A: POST /auth/email/verify {email, code}
  A-->>L: {sessionToken, user}
  L->>S: save(Session) → isLoggedIn = true
      
Ekran kodu ma licznik 60 s do ponownej wysyłki. W trybie fake każdy adres działa, a kod to 000000.

Google (Credential Manager)

GoogleSignInClient.signIn buduje GetGoogleIdOption z BuildConfig.GOOGLE_WEB_CLIENT_ID (identyfikator klienta webowego OAuth, nie androidowego), pobiera GoogleIdTokenCredential i przekazuje sam idToken do POST /auth/google. Backend weryfikuje podpis tokenu i aud, po czym wystawia własną sesję. Anulowanie przez użytkownika nie jest błędem; brak konta Google na urządzeniu (NoCredentialException) dostaje czytelny komunikat.

Apple (web flow)

sequenceDiagram
  participant L as LoginScreen
  participant B as Custom Tab (przeglądarka)
  participant A as API
  participant AP as Apple ID
  participant M as MainActivity
  L->>B: otwórz GET /auth/apple/start?state=<uuid>
  B->>A: start
  A->>AP: redirect do Sign in with Apple (response_mode=form_post)
  AP->>A: POST callback {code, id_token, state}
  A-->>B: 302 → digitalnotary://auth/apple?code=…&state=…
  B->>M: intent VIEW (intent-filter scheme=digitalnotary host=auth)
  M->>L: DeepLink.AppleCallback(code) → AuthViewModel.completeApple
  L->>A: POST /auth/apple/exchange {code}
  A-->>L: {sessionToken, user}
      
Android nie ma natywnego SDK Apple, więc cały taniec OAuth robi backend; aplikacja dostaje tylko jednorazowy code do wymiany. W trybie fake link „Zaloguj z Apple” prowadzi od razu do deep linku.

4.3 Rejestracja zdarzenia (przed nagraniem)

Dotknięcie „Zdjęcie / Wideo / Audio” na ekranie głównym otwiera CaptureScreen z kluczem CaptureKey(type). CaptureViewModel (assisted: type) w init wykonuje EventRepository.createEvent(type):

  1. POST /events {type} → {id, verifyUrl, nonce, serverTime}. Wymaga sieci — to celowe: id musi pochodzić z zegara serwera. Offline użytkownik widzi komunikat i „Spróbuj ponownie”.
  2. Wiersz EventEntity w Room ze statusem CREATED i createdAtElapsedNanos = SystemClock.elapsedRealtimeNanos() (zegar monotoniczny, którego nie da się cofnąć w ustawieniach).
  3. QrCodes.encode(verifyUrl, 512) (ZXing, korekcja M — przeżywa kompresję JPEG/H.264) i podpis DN <id> · dd.MM.yyyy HH:mm UTC.
  4. Asynchronicznie: PlayIntegrityClient.requestToken(id, nonce) z nonce = base64url(SHA-256(id‖nonce)).

Uprawnienia w momencie użycia

Ekran prosi o zestaw zależny od typu: kamera (foto/wideo), mikrofon (wideo/audio), lokalizacja fine+coarse, powiadomienia. Wynik trafia do onPermissions(camera, mic, location). Odmowa nie blokuje — z wyjątkiem uprawnienia niezbędnego dla samego nośnika (kamera dla foto/wideo, mikrofon dla audio), gdzie pokazujemy ekran z przyciskiem do ustawień systemowych. Wideo bez mikrofonu nagrywa się bez dźwięku (audioDenied=true w metadanych), brak lokalizacji daje locationDenied=true.

Gdy tylko lokalizacja jest przyznana i istnieje id, startują kolektory sygnałów: LocationSnapshotProvider.start() (do 6 par próbek fused+GNSS co 5 s, dopóki użytkownik kadruje) oraz RadioSnapshotProvider.requestScan() (jeden skan WiFi — na API 29+ skany są dławione do 4 na 2 min).

4.4 Capture

Zdjęcie i wideo (CameraX)

NotaryCamera.bind() binduje do cyklu życia trzy use case'y naraz — Preview, ImageCapture i VideoCapture<Recorder> — w jednej UseCaseGroup z OverlayEffect(PREVIEW | VIDEO_CAPTURE | IMAGE_CAPTURE). To nie jest fanaberia: CameraX odrzuca OverlayEffect z celami IMAGE_CAPTURE|PREVIEW; jedyna dozwolona kombinacja obejmująca zdjęcia to wszystkie trzy. Nieużywany use case po prostu stoi bezczynnie.

QrOverlayRenderer.draw(canvas, cropRect, rotationDegrees) jest wołany dla każdej klatki trafiającej do enkodera. Płótno jest w orientacji sensora, więc renderer mapuje „pionowe” płótno wirtualne: przesunięcie do środka cropRect, obrót o -rotationDegrees, przesunięcie o połowę wymiarów — dzięki temu QR (20 % krótszego boku, prawy dolny róg) i podpis (lewy dolny róg) są pionowe w gotowym pliku niezależnie od orientacji telefonu.

  • Zdjęcie: ImageCapture.takePicture(OutputFileOptions(original.jpg)), tryb MAXIMIZE_QUALITY, JPEG 92. Wynik już zawiera QR w pikselach (efekt działa przed enkoderem JPEG).
  • Wideo: Recorder z QualitySelector(FHD → fallback ≥ SD), FileOutputOptions(original.mp4), withAudioEnabled() tylko gdy mikrofon przyznany. Koniec nagrania to zdarzenie VideoRecordEvent.Finalize; każdy błąd inny niż limit rozmiaru pliku jest traktowany jako porażka.

Cykl życia composable: CameraCaptureContent pozostaje zmontowane także w fazie Finishing (z półprzezroczystą nakładką postępu). Gdyby ekran przełączył się na inny composable, rememberCoroutineScope zostałby anulowany w trakcie takePhoto, a DisposableEffect zwolniłby kamerę — dokładnie ten błąd wyszedł w pierwszym teście na emulatorze.

Audio (MediaRecorder)

AudioRecorderController: źródło MIC, kontener MPEG-4, koder AAC 44,1 kHz / 128 kb/s → original.m4a. Kontener BMFF jest wspierany przez c2pa-rs, więc audio dostaje ten sam manifest i hard binding co wideo. QR nie ma gdzie się wypalić — id i nonce żyją w manifeście; ekran pokazuje QR i podpis tylko informacyjnie. Poziom sygnału to maxAmplitude/32767 odpytywany co 250 ms.

Wyjście z ekranu

„Wstecz” podczas nagrywania pokazuje dialog „Przerwać nagranie?”. Potwierdzenie (albo wyjście przed nagraniem) woła CaptureViewModel.cancel(): jeśli zdarzenie ma nadal status CREATED, EventRepository.discard kasuje katalog i oznacza je FAILED („Przerwane przez użytkownika”). Wydane przez serwer id jest spalone — celowo, żeby nie dało się „przećwiczyć” kadru na jednym id.

4.5 Hash i przekazanie do pipeline'u

W chwili gdy nośnik zwraca zamknięty plik, CaptureViewModel.onFileReady(Result<File>) zatrzymuje próbkowanie lokalizacji, czeka na token Integrity i buduje CaptureSignals. Dalej wszystko dzieje się w EventPipeline.registerCapture na Dispatchers.IO:

sequenceDiagram
  participant VM as CaptureViewModel
  participant P as EventPipeline
  participant H as Hashing
  participant D as DeviceStateCollector
  participant R as Room (EventDao)
  participant W as WorkManager
  VM->>P: registerCapture(id, file, signals)
  P->>H: sha256Hex(file) — streaming, 64 KiB bufor
  H-->>P: originalSha256
  P->>D: collect() (model, patch, dev-options, ADB, auto-time, root/emulator hints)
  P->>P: EvidenceMetadata(id, verifyUrl, nonce, serverTime, captureElapsedMs, location, radio, device, integrityTokenSha256…)
  P->>R: update(status=CAPTURED, filePath, hash, metadataJson, integrityToken)
  P->>W: enqueueUniqueWork("seal-upload-{id}", KEEP, expedited, CONNECTED)
  P-->>VM: success → Phase.Done(id) → nawigacja do EventDetail
      
Od tego momentu plik należy do pipeline'u. Metadane lądują też jako metadata.json obok pliku — przydatne przy analizie poza aplikacją.

captureElapsedMs = różnica elapsedRealtimeNanos między wydaniem id a zamknięciem pliku. Backend zna serverTimeAtCreate i czas odebrania pliku, więc może sprawdzić, czy deklarowany czas nagrania mieści się w tym oknie i czy nie jest podejrzanie długi.

4.6 Klucz urządzenia i certyfikat (enrollment)

Pieczętowanie wymaga certyfikatu dla klucza sprzętowego. DeviceKeyManager.ensureEnrolled() zwraca istniejący, jeśli jest ważny dłużej niż 24 h; w przeciwnym razie przeprowadza enrollment (pod mutexem, żeby dwa workery nie wygenerowały dwóch kluczy):

sequenceDiagram
  participant K as DeviceKeyManager
  participant KS as Android Keystore
  participant CM as CertificateManager (c2pa-android)
  participant A as API (CA)
  K->>A: POST /devices/enroll/challenge
  A-->>K: {challenge}
  K->>KS: generateKeyPair EC P-256, setAttestationChallenge(challenge), StrongBox → fallback TEE
  KS-->>K: klucz + łańcuch attestation (Google root)
  K->>CM: createCSR(alias, CN=Digital Notary Device)
  CM->>KS: podpis CSR kluczem sprzętowym
  K->>A: POST /devices/enroll {csr, attestationChain[], integrityToken, securityLevel}
  A->>A: weryfikuje łańcuch do Google Hardware Attestation Root, challenge, poziom (StrongBox/TEE), pakiet
  A-->>K: {certChain[] PEM, expiresAt}
  K->>K: zapis łańcucha + expiresAt + securityLevel (EncryptedSharedPreferences)
      
Klucz jest generowany z challenge'em serwera, więc rekord attestation dowodzi, że powstał w odpowiedzi na to konkretne żądanie i w tym konkretnym sprzęcie.
StrongBox
setIsStrongBoxBacked(true), gdy urządzenie deklaruje FEATURE_STRONGBOX_KEYSTORE; StrongBoxUnavailableException → ponowna generacja w TEE. Poziom trafia do securityLevel (STRONGBOX / TEE / SOFTWARE) i do manifestu.
Biometria
setUserAuthenticationRequired(false) — pieczętowanie działa w tle, bez ekranu.
Podpis
signEs256Raw(data): SHA256withECDSA przez Keystore, wynik DER konwertowany przez EcdsaDer.toRaw na 64-bajtowe r‖s, którego wymaga COSE/C2PA.
Tryb fake
API zwraca pusty łańcuch → SelfSignedCertificates.pemFor buduje self-signed leaf podpisany tym samym kluczem sprzętowym, żeby ścieżka działała bez CA.

Profil certyfikatu, którego pilnuje c2pa-rs (§14.5.1 specyfikacji) — CA musi go spełnić co do joty: X.509 v3, end-entity (basicConstraints cA=false), keyUsage=digitalSignature (critical), EKU z konkretną wartością (np. emailProtection; anyExtendedKeyUsage zabronione), AuthorityKeyIdentifier obowiązkowy, SubjectKeyIdentifier zalecany, klucz EC P-256/384/521, bez issuer/subject unique id, bez nieobsłużonych rozszerzeń krytycznych. Brak AKI dał na emulatorze błąd „the certificate is invalid”.

4.7 Pieczętowanie C2PA

SealAndUploadWorker (@HiltWorker, expedited, foreground dataSync na API < 31) dla zdarzenia w stanie CAPTURED:

  1. Odczyt EvidenceMetadata z Room, ensureEnrolled(), uzupełnienie securityLevel.
  2. ManifestBuilder.build(type, metadata, captureTime) → JSON manifestu (sekcja 6).
  3. C2paSealer.seal(original, sealed, mimeType, manifest, certChainPem):
    • C2PASettings z created_assertion_labels rozszerzonym o naszą asercję — c2pa-rs oznacza ją jako created, nie gathered;
    • Builder.fromJson(manifest, settings), Signer.withCallback(ES256, certChainPem) { keyManager.signEs256Raw(it) };
    • builder.sign(mimeType, FileStream(original, READ), FileStream(sealed, WRITE), signer) — c2pa-rs liczy hard binding (c2pa.hash.data dla JPEG, BMFF hash dla MP4/M4A), buduje claim, podpisuje COSE i osadza JUMBF w kopii pliku;
    • wynik: SHA-256 pliku sealed.* (to jest hash wysyłany do API) i SHA-256 bajtów manifestu.
  4. Oryginał usunięty; Room: SEALED, filePath=sealed.*, oba hashe, wzbogacone metadane (także nadpisane w metadata.json).

Bez tsaURL — brak zewnętrznego znacznika czasu RFC 3161; rolę kotwicy czasowej pełni serwer (patrz 10).

4.8 Upload

Ten sam worker kontynuuje dla SEALED/UPLOADING: status UPLOADING, potem POST /events/{id}/content jako multipart:

CzęśćTypZawartość
contentimage/jpeg · video/mp4 · audio/mp4plik sealed.* z osadzonym manifestem
hashtext/plainSHA-256 hex pliku content — API liczy własny i porównuje
manifestHashtext/plainSHA-256 bajtów manifestu (szybki indeks / deduplikacja)
metadataapplication/jsonEvidenceMetadata — ta sama treść, co w asercji manifestu, ale czytelna bez parsera C2PA
integrityTokentext/plain (opcjonalne)token Play Integrity do dekodowania po stronie serwera

Polityka błędów (failOrRetry): ApiException.Network lub HTTP ≥ 500 → Result.retry() (status wraca z UPLOADING do SEALED, żeby UI nie pokazywało wiecznego „Wysyłanie…”), maksymalnie 20 prób; wszystko inne → FAILED z komunikatem. Praca jest unikalna per id (ExistingWorkPolicy.KEEP), więc podwójne zakolejkowanie nie robi drugiego uploadu.

4.9 Potwierdzenie push

sequenceDiagram
  participant A as API
  participant F as FCM
  participant S as NotaryFcmService
  participant H as PushConfirmHandler
  participant U as Użytkownik
  participant M as MainActivity / MainViewModel
  participant R as EventRepository
  A->>A: po 200 na /content: losowy confirmCode zapisany przy zdarzeniu
  A->>F: data message {type:"confirm", eventId, code}
  F->>S: onMessageReceived (także w tle — data-only)
  S->>H: onConfirmationPush(eventId, code)
  H->>H: kanał "event_confirmations", notyfikacja z PendingIntent VIEW digitalnotary://event/{id}/confirm?code=…
  U->>M: dotknięcie notyfikacji (singleTask → onNewIntent)
  M->>M: DeepLink.ConfirmEvent → consumeDeepLink()
  M->>R: confirm(id, code) → POST /events/{id}/confirm {confirmCode}
  A-->>R: {status:"confirmed"}
  R->>R: Room: CONFIRMED
  M->>U: snackbar „Zdarzenie … potwierdzone”, ekran szczegółów
      
Kod nigdy nie jest pokazywany użytkownikowi ani wpisywany ręcznie — przenosi go wyłącznie notyfikacja, więc potwierdzić może tylko urządzenie, na które FCM go dostarczył.
  • Dwa mechanizmy zapasowe: rotacja tokenu (onNewToken → PUT /me/push-token) i przycisk „Potwierdź ponownie” na ekranie szczegółów (POST /events/{id}/resend-confirmation), gdy push nie dotarł.
  • Notyfikacja wymaga POST_NOTIFICATIONS — prosimy o nie razem z uprawnieniami capture, a PushConfirmHandler sprawdza je przed notify().
  • W trybie fake FakeNotaryApi.uploadContent woła PushConfirmHandler bezpośrednio po 2 s — ta sama ścieżka od notyfikacji w dół.

4.10 Historia i szczegóły

Ekran główny obserwuje EventRepository.observeRecent(3) (Room, sortowanie po createdAtServer malejąco) i przy wejściu odświeża stronę 1 z API. Historia obserwuje observeAll(), a HistoryViewModel.loadNext() dokłada kolejne strony (GET /events?page=n&perPage=3); „Pokaż starsze” znika, gdy page·perPage ≥ total.

EventRepository.mergeRemote scala stronę z lokalnym stanem według reguł, które chronią zdarzenia będące w toku:

  • nieznane lokalnie → wstawiane ze statusem z serwera (bez pliku, nonce="");
  • lokalnie CONFIRMED albo zdalnie confirmed → CONFIRMED;
  • lokalnie przed UPLOADED (i nie FAILED) → status lokalny zostaje — serwer nie może „cofnąć” zdarzenia do created;
  • pola dowodowe (ścieżka, hash) nigdy nie są nadpisywane danymi z API; trustLevel z verification jest dopisywany.

Szczegóły zdarzenia (EventDetailViewModel, assisted eventId): podgląd (Coil; ikona dla audio), status, ID, czas, SHA-256, verifyUrl, wynik lokalnej walidacji (C2paSealer.readManifest → Reader.json() musi zawierać naszą asercję i nie mieć validation_state: Invalid), lokalizacja z ostrzeżeniem przy anyMock, poziom klucza, oraz akcje: „Potwierdź ponownie” (UPLOADED), „Ponów wysyłanie” (FAILED z plikiem → EventPipeline.enqueue), „Udostępnij plik” (FileProvider {applicationId}.files, grant tylko do odczytu).

4.11 Ustawienia i konto

  • Wyloguj — POST /auth/logout best-effort; lokalna sesja jest czyszczona niezależnie od odpowiedzi.
  • Usuń konto — dialog, potem DELETE /me; dopiero po sukcesie EventRepository.clearLocal() (kasuje filesDir/events i tabelę) i DeviceKeyManager.wipe() (usuwa klucz z Keystore i certyfikat). Backend powinien przy tym unieważnić certyfikat urządzenia.
  • Regulamin, polityka prywatności, strona — Custom Tabs; adresy w BuildConfig.

4.12 Awarie i wznawianie

ScenariuszCo się dzieje
Proces ubity między zamknięciem pliku a pieczętowaniemWiersz ma CAPTURED + hash + metadane. resumePending() przy następnym starcie (i sam WorkManager po reboocie) ponownie kolejkuje workera dla wszystkich CAPTURED / SEALED / UPLOADING.
Brak sieci przy POST /eventsEkran capture nie wystartuje — komunikat i „Spróbuj ponownie”. Bez id serwera nie ma zdarzenia (założenie modelu czasu).
Brak sieci przy enrollment / uploadWorker ma constraint CONNECTED i backoff; zdarzenie widnieje w historii jako „Zapisane” / „Zapieczętowane” do skutku.
Certyfikat wygasłensureEnrolled odnawia go z wyprzedzeniem 24 h; nowa para kluczy, nowe attestation.
Wygaśnięcie sesji (401)Globalne wylogowanie; kolejka zdarzeń i pliki zostają — po zalogowaniu worker kontynuuje.
Odmowa uprawnień po drodzeNagranie idzie dalej z flagami locationDenied / audioDenied; w metadanych brak odpowiednich sekcji, co samo w sobie jest sygnałem dla oceny.

05Sygnały anty-manipulacyjne

Aplikacja nie rozstrzyga, czy materiał jest wiarygodny — zbiera dowody i przekazuje ocenę serwerowi. Każdy sygnał ma swoje miejsce w EvidenceMetadata i w manifeście.

ZagrożenieCo zbiera aplikacjaCo powinien sprawdzić backend
Symulowana lokalizacja (mock provider)Location.isMock (API 31+) / isFromMockProvider dla każdej próbki; osobno fused i czysty GNSS (LocationManager.GPS_PROVIDER); fusedGnssDistanceM; dokładność, wiek fixu, czas fixu z satelitów (Location.time)anyMock → niska wiarygodność; duża rozbieżność fused/GNSS; zgodność z geolokalizacją BSSID/komórek
Podmiana radia / lokalizacja z innego miejscado 20 najsilniejszych BSSID z RSSI i częstotliwością, podłączony BSSID, do 12 komórek (LTE/NR/WCDMA/GSM: id, MCC/MNC, dBm, registered)geolokalizacja BSSID/CID (własna baza lub usługa) vs GPS
Zmodyfikowana aplikacja, root, emulatorPlay Integrity z nonce związanym ze zdarzeniem; rootHints (test-keys, ścieżki su/magisk, pakiety), emulatorHints, developer options, ADBdekodowanie tokenu: MEETS_STRONG_INTEGRITY → pełna, DEVICE → standardowa, brak → niska; appRecognitionVerdict
Podpis spoza urządzeniaklucz w StrongBox/TEE, cert tylko po weryfikacji attestation, securityLevel w manifeściełańcuch attestation przy enrollment, krótki okres ważności certu, revocation przy usunięciu konta
Manipulacja zegaremserverTimeAtCreate, captureElapsedMs (monotoniczny), deviceWallClock, autoTimeEnabled, czas fixu GNSS, strefa czasowaczas nagrania ⊂ [Tcreate, Tupload]; różnica zegara urządzenia vs serwera
Podmiana pliku przed hashemhash w tym samym procesie natychmiast po zamknięciu pliku; plik w prywatnym filesDir; allowBackup=falsehash z żądania == SHA-256 odebranego pliku; walidacja hard bindingu C2PA (c2pa-rs) do naszego CA
Replay / podszycienonce serwera w manifeście; id jednorazowe; kod z pushanonce w asercji == wydany; id nieużyte wcześniej; confirmCode zgodny
Nagranie ekranu podczas captureFLAG_SECURE na ekranie capture (build release)—

06Manifest C2PA

ManifestBuilder produkuje definicję claim v2 z trzema asercjami. c2pa-rs dokłada sam c2pa.hash.data / BMFF hash, miniaturę i podpis COSE.

{
  "claim_version": 2,
  "claim_generator_info": [{ "name": "Digital Notary", "version": "1.0" }],
  "title": "Digital Notary photo 8CE625C1",
  "format": "image/jpeg",
  "assertions": [
    { "label": "c2pa.actions",
      "data": { "actions": [{
        "action": "c2pa.created",
        "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture",
        "when": "2026-09-15T07:33:12.480Z",
        "softwareAgent": { "name": "Digital Notary", "version": "1.0" } }] } },

    { "label": "c2pa.metadata",
      "data": { "@context": { "exif": "http://ns.adobe.com/exif/1.0/" },
        "exif:DateTimeOriginal": "2026-09-15T07:33:12Z",
        "exif:GPSLatitude": "37,25.31990N", "exif:GPSLongitude": "122,5.04000W",
        "exif:GPSAltitude": "5.0/1", "exif:GPSHPositioningError": "5.0/1",
        "exif:GPSTimeStamp": "2026-09-15T07:33:05Z", "exif:GPSProcessingMethod": "gps" } },

    { "label": "com.arctical.digitalnotary.event",
      "data": {
        "eventId": "8CE625C1",
        "verifyUrl": "https://verify.digitalnotary.example/e/8CE625C1",
        "nonce": "7f59e650-…",
        "serverTimeAtCreate": "2026-09-15T07:33:03.412Z",
        "captureElapsedMs": 8976,
        "deviceWallClock": "2026-09-15T07:33:12.480Z",
        "originalSha256": "fb4374a0…",
        "location": { "fused": {…, "isMock": false}, "gnss": {…, "isMock": false},
                      "samples": [...], "anyMock": false, "fusedGnssDistanceM": 0.0037,
                      "gpsProviderEnabled": true },
        "locationDenied": false, "audioDenied": false,
        "radio": { "connectedBssid": null, "wifi": [...], "cells": [...], "wifiScanAllowed": true },
        "device": { "manufacturer": "Google", "model": "sdk_gphone64_arm64", "sdkInt": 35,
                    "securityPatch": "…", "buildFingerprint": "…", "appVersion": "1.0 (1)",
                    "adbEnabled": true, "autoTimeEnabled": true,
                    "rootHints": [], "emulatorHints": ["fingerprint","product"], "timeZone": "…" },
        "integrityTokenSha256": null,
        "securityLevel": "SOFTWARE" } }
  ]
}
  • stds.exif jest w C2PA 2.x deprecjonowane — stąd c2pa.metadata z przestrzenią nazw EXIF; współrzędne w formacie XMP DDD,MM.mmmmk.
  • Preferowana jest próbka GNSS (czas z satelitów), fused tylko gdy GNSS nie dał fixu.
  • Pola null są pomijane (explicitNulls=false) — brak pola znaczy „nie dało się zebrać”.
  • Nasza asercja jest oznaczona jako created (a nie gathered) przez created_assertion_labels w ustawieniach buildera.

Weryfikacja poza aplikacją: c2patool sealed.jpg (CLI c2pa-rs) z zaufanym rootem naszego CA; każda edycja pliku łamie hard binding.

07Kontrakt API

Interfejs NotaryApi (Retrofit). Wszystkie odpowiedzi mogą nieść serverTime (ISO-8601). Poza /auth/* wymagany nagłówek Authorization: Bearer <sessionToken>.

MetodaEndpointŻądanie → odpowiedź
POST/auth/google{idToken} → {sessionToken, user}
GET/auth/apple/start?state=redirect do Apple; callback → digitalnotary://auth/apple?code=…&state=…
POST/auth/apple/exchange{code} → {sessionToken, user}
POST/auth/email/request{email} → 204 (kod ważny 1 h)
POST/auth/email/verify{email, code} → {sessionToken, user}
POST/auth/logout→ 204
DELETE/me→ 204 (+ revocation certyfikatu urządzenia)
PUT/me/push-token{fcmToken} → 204
POST/devices/enroll/challenge→ {challenge}
POST/devices/enroll{csr, attestationChain[], integrityToken?, securityLevel} → {certChain[], expiresAt}
POST/events{type: photo|video|audio} → {id, verifyUrl, nonce, serverTime}
POST/events/{id}/contentmultipart content, hash, manifestHash, metadata, integrityToken? → {status:"received"}; potem push
POST/events/{id}/confirm{confirmCode} → {status:"confirmed"}
POST/events/{id}/resend-confirmation→ 204 (ponowny push)
GET/events?page=&perPage=3→ {items[{id,type,createdAt,status,verifyUrl?,thumbnailUrl?,verification?}], page, perPage, total}
GET/events/{id}→ EventDto z verification{hashMatches, c2paValid, integrityVerdict, locationConsistent, trustLevel}

Statusy serwera mapowane w EventRepository: created, uploaded|received, confirmed, failed|rejected; nieznane traktowane jak uploaded. Błędy: {code, message} → ApiException.Http(code, apiCode, message); 401 → Unauthorized; IOException → Network.

08Bezpieczeństwo danych na urządzeniu

Pliki dowodowe
filesDir/events/{id}/ — prywatne dla UID aplikacji, poza MediaStore i poza zasięgiem innych aplikacji; oryginał kasowany po pieczętowaniu (jedno źródło prawdy).
Backup
allowBackup=false, data_extraction_rules wyklucza pliki, bazę i preferencje z backupu chmurowego i transferu D2D.
Sesja i certyfikat
EncryptedSharedPreferences (AES-256-GCM, klucz główny w Keystore) — osobne pliki session i device_key.
Klucz podpisujący
Android Keystore, StrongBox jeśli jest; nieeksportowalny; usuwany przy usunięciu konta.
Ruch sieciowy
usesCleartextTraffic=false; logowanie OkHttp tylko w debug (poziom BASIC — bez treści). Certificate pinning do dodania po wystawieniu certów produkcyjnych (TODO w NetworkModule).
Ekran
FLAG_SECURE na ekranie capture w buildach release (w debug wyłączone, żeby dało się robić zrzuty do QA).
Udostępnianie
FileProvider z grantUriPermissions, tylko FLAG_GRANT_READ_URI_PERMISSION, ścieżka ograniczona do events/.
R8
Włączone w release; reguły keepRules/rules.keep chronią JNI c2pa-android, JNA, BouncyCastle, serializery kotlinx i interfejsy Retrofit.

09Konfiguracja i tryb fake

Wartości środowiskowe czyta app/build.gradle.kts z local.properties (poza repozytorium) do BuildConfig:

api.baseUrl=https://api.example.com/        # domyślnie placeholder
api.useFake=true                            # debug: FakeNotaryApi zamiast HTTP
api.useFake.release=false
google.webClientId=xxx.apps.googleusercontent.com
  • Firebase: plugin google-services jest aplikowany tylko, gdy istnieje app/google-services.json; bez niego build i tryb fake działają, a PushTokenRegistrar po cichu pomija rejestrację.
  • FakeNotaryApi implementuje cały interfejs Retrofita w pamięci: sesje dla dowolnego adresu, kod e-mail 000000, generowanie id/verifyUrl/nonce, lokalny „push” 2 s po uploadzie, weryfikacja kodu, stronicowanie. Zwraca pusty łańcuch przy enrollment → self-signed cert.
  • Buildy: debug ograniczony do ABI arm64-v8a i x86_64 (natywne biblioteki c2pa-rs ważą ~45 MB na ABI); release zawiera wszystkie — App Bundle dzieli je per urządzenie.
PolecenieCo robi
./gradlew :app:installDebugbuild + instalacja na podłączonym urządzeniu (tryb fake)
./gradlew :app:testDebugUnitTest13 testów: ManifestBuilder, EcdsaDer, Hashing, reguły scalania EventRepository
./gradlew :app:lintDebuglint bez błędów (NewApi, LocalContext, CredentialManager)
./gradlew :app:assembleReleaseR8, wszystkie ABI

10Ograniczenia i następne kroki

ObszarStanUwaga
Backend / CAbrakKontrakt z sekcji 7 do implementacji; profil certyfikatu z sekcji 4.6 jest twardym wymogiem c2pa-rs.
Znacznik czasu RFC 3161nieSigner.withCallback(tsaURL=null). Kotwicą czasu jest serwer (id przed, odbiór po). Dodanie TSA to jedna zmiana w C2paSealer.
Play Integritytylko na PlayPoza dystrybucją przez Google Play token zwykle nie przychodzi; brak tokenu jest raportowany, nie blokuje.
StrongBox / TEEniezweryfikowaneNa emulatorze poziom to SOFTWARE; ścieżka attestation wymaga fizycznego urządzenia.
Certificate pinningTODOCertificatePinner w NetworkModule po wystawieniu certów produkcyjnych, z planem rotacji.
FCMwymaga google-services.jsonKod gotowy (NotaryFcmService), zweryfikowany przez lokalny odpowiednik pusha.
QR w audion/dBrak nośnika na QR; id/nonce w manifeście. Ewentualnie watermark audio w przyszłości.
Skan WiFiograniczonyAndroid dławi skany (4/2 min) i wymaga lokalizacji; odmowa lokalizacji = brak WiFi/cell — raportowane.
Weryfikacja zewnętrznado zrobieniaUruchomić c2patool na plikach z urządzenia z prawdziwym łańcuchem CA; na emulatorze potwierdzono strukturę JUMBF i lokalny odczyt Reader.