first commit

This commit is contained in:
FabioMich66 2026-10-03 12:35:00 +02:00
commit ba9986027e
2048 changed files with 478613 additions and 0 deletions

File diff suppressed because one or more lines are too long

3
.fvmrc Normal file
View file

@ -0,0 +1,3 @@
{
"flutter": "3.35.7"
}

148
.gitignore vendored Normal file
View file

@ -0,0 +1,148 @@
# ============================================
# Flutter / Dart / FVM
# ============================================
.dart_tool/
.flutter/
.fvm/
.packages
.pub-cache/
.pub/
# Flutter build artifacts
build/
flutterw
flutterw.bat
# ============================================
# Android / Gradle
# ============================================
android/.gradle/
android/local.properties
android/app/key.properties
android/app/google-services.json
android/app/google-services.plist
# ============================================
# iOS
# ============================================
ios/Flutter/
ios/Pods/
ios/.symlinks/
ios/Flutter/ephemeral/
# ============================================
# Web
# ============================================
web/.dart_tool/
web/build/
# ============================================
# File pesanti (APK, ZIP, DB, dSYM, snapshot)
# ============================================
*.apk
*.aab
*.ipa
*.zip
*.tar.gz
*.7z
*.db
*.db-shm
*.db-wal
*.sqlite
*.bak
*.keystore
*.jks
*.dSYM
*.bin
*.dill
*.so
*.jar
*.dex
*.ttf
# ============================================
# Librerie native enormi
# ============================================
**/libflutter.so
**/libVkLayer_khronos_validation.so
**/libmpv.so
**/kernel_blob.bin
**/isolate_snapshot_data
# ============================================
# Test driver assets (pesanti)
# ============================================
test_driver/screenshots/
test_driver/assets/shaders/
# ============================================
# Repo annidati
# ============================================
aves_mio72s13/
# ============================================
# IDE / Editor
# ============================================
.vscode/
.idea/
*.iml
# ============================================
# Logs & temp files
# ============================================
*.log
*.log.*
*.tmp
*.temp
*.swp
*.swo
# ============================================
# OS files
# ============================================
.DS_Store
Thumbs.db
# ============================================
# Fastlane
# ============================================
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/
# ============================================
# Misc
# ============================================
*.orig
*.rej
*.patch
*.diff
# Media/test artifacts
/IMG_1425.MOV
/cast_1000119720.mp4
/stream_test.fmp4
/growing_stream.fmp4
/cast_hls_dump/
/android_ts_dump_1000119720/
/hls_ts_test/
/hls_vod_android_test/
/hls_android_ts_test/
/five_ts_output/
/progressive_ts_output/
# Root MPEG-TS test files
/audio_sample_test.ts
/av_segment_00000.ts
/av_segment_00000_ffmpeg_level60.ts
/av_segment_00000_level41.ts
/av_segment_00000_level41_android.ts
/av_segment_00000_level41_android_remux.ts
/av_segment_00000_level41_aud_android.ts
/segment_00000.ts
/segment_app.ts
/segment_python.ts
/video_sample_test.ts
/video_segment_00000.ts

4
.gitmodules vendored Normal file
View file

@ -0,0 +1,4 @@
[submodule ".flutter"]
path = .flutter
url = https://github.com/flutter/flutter.git
branch = main

30
.metadata Normal file
View file

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: 90c64ed42ba53a52d18f0cb3b17666c8662ed2a0
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 90c64ed42ba53a52d18f0cb3b17666c8662ed2a0
base_revision: 90c64ed42ba53a52d18f0cb3b17666c8662ed2a0
- platform: android
create_revision: 90c64ed42ba53a52d18f0cb3b17666c8662ed2a0
base_revision: 90c64ed42ba53a52d18f0cb3b17666c8662ed2a0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

18
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,18 @@
repos:
- repo: https://github.com/gherynos/pre-commit-java
rev: v0.2.4
hooks:
- id: Checkstyle
- repo: https://github.com/gitleaks/gitleaks
rev: v8.16.3
hooks:
- id: gitleaks
- repo: https://github.com/jumanjihouse/pre-commit-hooks
rev: 3.0.0
hooks:
- id: shellcheck
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace

View file

@ -0,0 +1,624 @@
# Plugin `plugins/google_cast` – Guida pratica / Debug operativo
Questa guida è la versione **pratica** del documento di architettura del plugin `google_cast`.
L’obiettivo è aiutarti a capire **rapidamente**:
- quale parte del flusso è coinvolta
- quale file toccare
- dove guardare quando qualcosa non funziona
- come distinguere i problemi **Google Cast standard** dai problemi **Custom Receiver**
---
# 1. Mappa mentale rapida
## Flusso generale
```text
Flutter UI
↓
cast_controller.dart
↓
┌───────────────────────────────┬───────────────────────────────┐
│ GoogleCastController │ CustomCastController │
│ (Google Cast standard) │ (Custom Receiver CAF/web) │
└───────────────┬───────────────┴───────────────┬───────────────┘
↓ ↓
MethodChannel('google_cast') MethodChannel('google_cast')
↓ ↓
FlutterCastFrameworkPlugin.kt (Android)
↓
┌──────────────┬───────────────┬──────────────┬──────────────┐
│ Context │ Session │ MediaClient │ QueueManager │
│ Manager │ Handler │ │ │
└──────────────┴───────────────┴──────────────┴──────────────┘
↓
Google Cast framework / Receiver
```
---
# 2. File principali: a cosa servono in pratica
## Lato Flutter / App
### `lib/widgets/cast/cast_controller.dart`
**È la facciata unificata**.
Usalo per capire:
- quale backend è attivo (`google`, `custom`, `dlna`)
- dove finiscono i comandi UI
- come vengono sincronizzati i notifier condivisi:
- indice corrente
- stato play/pause
- posizione/durata
- volume/mute
### Se hai un bug tipo:
- il pulsante UI chiama il backend sbagliato
- i notifier non si aggiornano
- la cast bar chiama il backend sbagliato
👉 il primo file da guardare è **questo**.
---
### `lib/widgets/cast/google_cast_controller.dart`
**È il cervello Dart del Google Cast standard**.
Qui trovi:
- connessione del flusso Google Cast
- caricamento queue standard
- slideshow lato Flutter
- polling media/volume
- sync verso fullscreen viewer / controlli
### Se hai un bug tipo:
- slideshow Google si ferma
- foto/video si comportano male
- seek cambia sulla TV ma non sul telefono
- volume non si aggiorna nella UI
- `onCastReady` genera comportamenti strani
👉 guarda **questo file**.
---
### `lib/widgets/cast/custom_cast_controller.dart`
**È il cervello Dart del Custom Receiver**.
Qui trovi:
- avvio sessione/receiver custom
- invio messaggi JSON al receiver web
- polling custom (`MEDIA_STATUS`, `VOLUME_STATUS`)
- slideshow custom
- comandi `LOAD_QUEUE`, `NEXT`, `PLAY`, `PAUSE`, ecc.
### Se hai un bug tipo:
- Custom connect funziona ma la TV mostra male il contenuto
- overlay/progress duplicati
- volume custom non cambia davvero il video
- il receiver risponde ma Flutter non si aggiorna
👉 guarda **questo file** + il receiver web.
---
### `lib/widgets/collection/app_bar/cast/cast_icon_button.dart`
Gestisce il **tap sull’icona Cast** nella app bar.
Qui si decide se il tap deve fare:
- solo **connessione device**
- oppure connessione + media
### Se hai un bug tipo:
- scegli Google ma sembra partire Custom
- il cast parte subito quando scegli il device
- il device si connette ma la UX è sbagliata
👉 guarda **questo file**.
---
### `lib/widgets/collection/app_bar/cast/cast_bar_inline.dart`
È la **seconda barra orizzontale** con:
- cast manuale
- fullscreen cast
- slideshow
- prev/next
- stop slideshow
- stop cast
### Se hai un bug tipo:
- parte con gli item sbagliati
- usa una selezione vecchia
- startIndex invalido
- overflow orizzontale della barra
👉 guarda **questo file**.
---
### `lib/widgets/collection/app_bar/cast/cast_controls_sheet.dart`
È il **bottom sheet dei controlli**.
Qui trovi:
- seek
- prev/play-pause/next
- volume/mute
- stop slideshow
- stop cast
### Se hai un bug tipo:
- il volume non compare
- il volume compare ma non si aggiorna
- i controlli interagiscono col backend sbagliato
- la UI chiama `controller.google` / `controller.custom` direttamente
👉 guarda **questo file**.
---
### `lib/widgets/viewer/fullscreen_cast_viewer.dart`
È il viewer fullscreen locale sincronizzato col Cast.
### Se hai un bug tipo:
- il video sulla TV cambia seek ma il telefono no
- il fullscreen non segue l’indice Cast
- il video locale e il Cast non sono allineati
👉 guarda **questo file**.
---
## Lato plugin Android
### `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/FlutterCastFrameworkPlugin.kt`
**Router centrale del plugin**.
Riceve tutto dal `MethodChannel('google_cast')`.
### Se hai un bug tipo:
- Flutter chiama un metodo ma sembra non arrivare al native
- un comando non viene instradato bene
- un handler non viene più registrato
👉 guarda **questo file**.
---
### `CastContextManager.kt`
Gestisce:
- `CastContext`
- `Activity`
- sessione Cast corrente
- `RemoteMediaClient`
### Se hai un bug tipo:
- sessione non trovata
- device connesso ma client nullo
- callback non agganciata al client corretto
👉 guarda **questo file**.
---
### `CastSessionHandler.kt`
Gestisce gli eventi di sessione:
- `onSessionStarted`
- `onSessionResumed`
- `onSessionEnded`
- `onCastReady`
- `onCastStopped`
### Se hai un bug tipo:
- il device si connette ma Flutter non sa che è pronto
- la seconda barra non compare
- la sessione finisce ma Flutter resta “connected”
👉 guarda **questo file**.
---
### `CastDialogLauncher.kt`
Mostra il dialog nativo di selezione device.
### Se hai un bug tipo:
- l’icona Cast non apre il picker
- il picker si apre ma non seleziona device
👉 guarda **questo file**.
---
### `CastMediaClient.kt`
Gestisce:
- play / pause / seek
- get media status
- volume / mute
- callback `RemoteMediaClient.Callback`
### Se hai un bug tipo:
- `onQueueIndexChanged` non arriva più a Flutter
- `onVideoFinished` non arriva
- `Invalid Request`
- volume nativo Google non si aggiorna
👉 guarda **questo file**.
---
### `CastQueueManager.kt`
Gestisce:
- `loadQueue(items, startIndex)`
- `next()`
- `prev()`
- costruzione `MediaQueueItem`
- metadati (`index`, `isVideo`, mime, title)
### Se hai un bug tipo:
- `Invalid startIndex`
- queue rotta o item sbagliati
- Google Cast non va al prossimo item come dovrebbe
👉 guarda **questo file**.
---
### `CastCustomReceiver.kt`
Gestisce l’avvio del custom receiver web/CAF.
### Se hai un bug tipo:
- il receiver custom non parte
- parte ma non riceve messaggi
- la sessione custom sembra connettersi ma non carica nulla
👉 guarda **questo file**.
---
# 3. Mappa dei comandi principali
## Comandi Flutter → plugin Android
### Connessione
- `showCastDialog`
- `startCustomReceiver`
### Queue / navigazione
- `loadQueue`
- `next`
- `prev`
### Media
- `play`
- `pause`
- `seekTo`
- `getMediaStatus`
### Volume
- `getVolume`
- `setVolume`
- `setMute`
### Stop
- `stopCast`
---
# 4. Mappa degli eventi di ritorno
## Google Cast standard → Flutter
- `onCastReady`
- `onCastStopped`
- `onQueueIndexChanged`
- `onVideoFinished`
- `onCastError`
## Custom Receiver → Flutter
Di solito via messaggi custom, per esempio:
- `CAST_READY`
- `CAST_STOPPED`
- `MEDIA_STATUS`
- `VOLUME_STATUS`
---
# 5. Troubleshooting rapido
## Problema: seleziono il device e subito compaiono
- `Invalid Request`
- `MediaQueue error 2001`
- `IDLE_REASON_ERROR`
### Causa probabile
Il polling media parte **troppo presto**, cioè già su `onCastReady` quando non esiste ancora una queue/media caricata.
### Dove guardare
- `google_cast_controller.dart`
- `CastMediaClient.kt`
### Fix tipico
- non avviare `startMediaPolling()` dentro `onCastReady`
- far partire il polling solo dopo `loadQueue`
- lato native, ignorare gli stati in cui `mediaInfo == null`
---
## Problema: slideshow Google fa prima e seconda foto, poi si ferma
### Causa probabile
Flutter non riceve più il cambio item queue, quindi il timer slideshow non viene riarmato.
### Dove guardare
- `google_cast_controller.dart`
- `CastMediaClient.kt`
- eventualmente `CastQueueManager.kt`
### Fix tipico
- ripristinare la callback `RemoteMediaClient.Callback`
- reinviare a Flutter `onQueueIndexChanged(index, isVideo)`
- in Dart riarmare il timer solo per le foto
---
## Problema: in slideshow un video viene saltato dopo 5 secondi
### Causa probabile
Il timer slideshow delle foto parte anche sui video.
### Dove guardare
- `google_cast_controller.dart`
- `custom_cast_controller.dart`
### Comportamento corretto
- **foto** → timer
- **video** → nessun timer, deve finire da solo e poi passare al successivo
---
## Problema: in presentazione manuale/fullscreen le foto avanzano da sole
### Causa probabile
La logica slideshow è rimasta attiva quando non dovrebbe.
### Dove guardare
- `google_cast_controller.dart`
- `custom_cast_controller.dart`
- `cast_bar_inline.dart`
### Comportamento corretto
- **manuale/fullscreen**: le foto restano ferme fino a un comando manuale
- i video invece vanno al successivo a fine riproduzione
---
## Problema: `Invalid startIndex: X`
### Causa probabile
Stai caricando una queue con un indice iniziale più grande della lunghezza della lista.
### Dove guardare
- `cast_bar_inline.dart`
- `google_cast_controller.dart`
- `custom_cast_controller.dart`
- `CastQueueManager.kt`
### Fix tipico
- usare `startIndex: 0` nei comandi manuali della cast bar
- oppure proteggere con una funzione tipo `_safeIndexOrKeep(...)`
---
## Problema: seek sul video cambia sulla TV ma non sul telefono
### Causa probabile
Il telefono usa un player locale non sincronizzato con `positionNotifier` / `isPlayingNotifier` del Cast.
### Dove guardare
- `google_cast_controller.dart`
- `fullscreen_cast_viewer.dart`
### Fix tipico
- in `seekCast()` aggiornare subito `positionNotifier`
- nel `VideoWidget` locale ascoltare `positionNotifier` e `isPlayingNotifier`
---
## Problema: il volume compare ma la barra non si aggiorna
### Causa probabile
Il comando volume parte, ma il notifier locale non viene riallineato con lo stato reale del device.
### Dove guardare
- `google_cast_controller.dart`
- `cast_controls_sheet.dart`
- `fullscreen_cast_viewer.dart`
- `CastMediaClient.kt`
### Fix tipico
- dopo `setVolume` e `setMute`, chiamare `refreshVolumeStatus()`
- lato UI, separare `onChanged` e `onChangeEnd` dello slider
---
## Problema: scegli Google ma sembra partire Custom
### Causa probabile
Il backend attivo del controller unificato non è allineato al backend scelto nella UI.
### Dove guardare
- `cast_icon_button.dart`
- `cast_controller.dart`
- `custom_cast_controller.dart`
- `google_cast_controller.dart`
### Fix tipico
- rendere l’icona Cast “connect-only”
- impostare esplicitamente il backend attivo (`_backend`) nel controller unificato
- far partire la queue solo dai pulsanti della cast bar
---
## Problema: in selection mode la app bar sembra toccare anche le foto sotto
### Causa probabile
La barra non sta assorbendo correttamente i tocchi nell’area header.
### Dove guardare
- `collection_app_bar.dart`
- `aves_app_bar.dart`
- `collection_page.dart`
### Fix tipico
- ripristinare il wrapper che assorbe i tap nella barra (`AInkResponse`)
- verificare il comportamento `pinned`
- verificare l’uso corretto di `appBarHeightNotifier`
---
# 6. Regole pratiche di debug
## Regola 1
### Se il problema è “UI Flutter non aggiornata”
Guarda prima i controller Dart:
- `cast_controller.dart`
- `google_cast_controller.dart`
- `custom_cast_controller.dart`
- `fullscreen_cast_viewer.dart`
---
## Regola 2
### Se il problema è “la TV/device fa una cosa diversa da Flutter”
Guarda:
- `CastMediaClient.kt`
- `CastQueueManager.kt`
- receiver custom JS/HTML (se custom)
---
## Regola 3
### Se il problema avviene subito dopo la connessione device
Guarda:
- `CastSessionHandler.kt`
- `google_cast_controller.dart`
- `CastMediaClient.kt`
Il sospetto classico è la distinzione:
- connessione sessione
- queue realmente caricata
---
## Regola 4
### Se il problema riguarda slideshow foto/video
Guarda prima il controller Dart, poi il native.
Ordine consigliato:
1. `google_cast_controller.dart`
2. `custom_cast_controller.dart`
3. `CastMediaClient.kt`
4. `CastQueueManager.kt`
---
## Regola 5
### Se il problema riguarda il Custom Receiver
Non fermarti al controller Flutter.
Devi guardare anche:
- `CastCustomReceiver.kt`
- `receiver.html`
- eventuale JS del receiver
Perché molti bug Custom sono **receiver-side**, non plugin-side.
---
# 7. Differenza pratica Google vs Custom (da tenere a mente)
## Google Cast standard
### Più affidabile quando:
- usi queue nativa
- usi volume Cast nativo
- ti affidi a `RemoteMediaClient.Callback`
## Custom Receiver
### Più flessibile quando:
- vuoi UI custom sul TV
- vuoi logica media personalizzata
- vuoi controllare il player HTML5 direttamente
### Ma più fragile quando:
- volume/mute non sono implementati davvero
- il DOM del receiver non viene pulito bene
- i messaggi receiver/Flutter non sono perfettamente allineati
---
# 8. Checklist rapida: dove toccare per ogni categoria
## Connessione device
- `cast_icon_button.dart`
- `FlutterCastFrameworkPlugin.kt`
- `CastDialogLauncher.kt`
- `CastSessionHandler.kt`
## Queue / media non partono
- `cast_bar_inline.dart`
- `google_cast_controller.dart`
- `CastQueueManager.kt`
## Slideshow foto/video
- `google_cast_controller.dart`
- `custom_cast_controller.dart`
- `CastMediaClient.kt`
## Volume Google
- `google_cast_controller.dart`
- `cast_controls_sheet.dart`
- `fullscreen_cast_viewer.dart`
- `CastMediaClient.kt`
## Volume Custom
- `custom_cast_controller.dart`
- `CastCustomReceiver.kt`
- `receiver.html` / JS
## Bug UI selection / app bar
- `collection_app_bar.dart`
- `aves_app_bar.dart`
- `collection_page.dart`
---
# 9. Conclusione pratica
Se devi pensare al plugin in modo operativo, ricordati questa formula:
## **Flutter decide l’UX → il plugin Android parla con Cast → il receiver riproduce davvero**
Quindi ogni bug va classificato subito in una di queste tre famiglie:
### A. Bug UX / Flutter
- notifier
- selection
- fullscreen viewer
- cast bar
- app bar
### B. Bug plugin Android
- sessione
- queue
- callback media
- volume nativo
### C. Bug receiver
- volume custom
- progress bar duplicate
- media status custom
- UI TV-side
Capire **subito** in quale famiglia rientra il bug fa risparmiare tantissimo tempo di debug.

1920
CHANGELOG.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,369 @@
# Cheatsheet Debug – Plugin `google_cast`
Guida ultra-rapida per capire **subito** dove guardare quando qualcosa non funziona.
---
# 1. Flusso mentale in 10 secondi
```text
UI Flutter
↓
cast_controller.dart
↓
google_cast_controller.dart / custom_cast_controller.dart
↓
MethodChannel('google_cast')
↓
FlutterCastFrameworkPlugin.kt
↓
CastMediaClient / CastQueueManager / CastCustomReceiver / SessionHandler
↓
TV / Chromecast / Custom Receiver
```
---
# 2. Se succede X → apri questi file
## Connessione device non parte
- `lib/widgets/collection/app_bar/cast/cast_icon_button.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/FlutterCastFrameworkPlugin.kt`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastDialogLauncher.kt`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastSessionHandler.kt`
---
## Il device si connette ma non parte nessun media
- `lib/widgets/collection/app_bar/cast/cast_bar_inline.dart`
- `lib/widgets/cast/google_cast_controller.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastQueueManager.kt`
---
## Google Cast fa `Invalid Request` appena scelgo il device
- `lib/widgets/cast/google_cast_controller.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastMediaClient.kt`
### Controlla subito:
- polling avviato troppo presto in `onCastReady`
- sessione connessa ma queue ancora vuota
---
## `MediaQueue error 2001`
- `lib/widgets/cast/google_cast_controller.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastQueueManager.kt`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastMediaClient.kt`
### Controlla subito:
- queue non caricata davvero
- startIndex invalido
- callback media attiva senza media reale
---
## Slideshow fa prima e seconda foto poi si ferma
- `lib/widgets/cast/google_cast_controller.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastMediaClient.kt`
### Segnale tipico:
non arriva più `onQueueIndexChanged`
---
## In slideshow un video viene saltato dopo 5 secondi
- `lib/widgets/cast/google_cast_controller.dart`
- `lib/widgets/cast/custom_cast_controller.dart`
### Regola corretta:
- foto → timer
- video → nessun timer, deve finire da solo
---
## In manuale/fullscreen le foto avanzano da sole
- `lib/widgets/cast/google_cast_controller.dart`
- `lib/widgets/cast/custom_cast_controller.dart`
### Regola corretta:
- foto manuali/fullscreen → ferme finché non premi next/prev
- video → a fine video vanno al successivo
---
## `Invalid startIndex: X`
- `lib/widgets/collection/app_bar/cast/cast_bar_inline.dart`
- `lib/widgets/cast/google_cast_controller.dart`
- `lib/widgets/cast/custom_cast_controller.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastQueueManager.kt`
### Controlla subito:
- `startIndex` più grande di `entries.length - 1`
- indice vecchio riusato su nuova selezione
---
## Seek cambia sulla TV ma non sul telefono
- `lib/widgets/cast/google_cast_controller.dart`
- `lib/widgets/viewer/fullscreen_cast_viewer.dart`
### Controlla subito:
- `seekCast()` aggiorna `positionNotifier`
- `VideoWidget` locale ascolta `positionNotifier` / `isPlayingNotifier`
---
## Il volume compare ma la barra non si aggiorna
- `lib/widgets/cast/google_cast_controller.dart`
- `lib/widgets/collection/app_bar/cast/cast_controls_sheet.dart`
- `lib/widgets/viewer/fullscreen_cast_viewer.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastMediaClient.kt`
### Controlla subito:
- dopo `setVolume` e `setMute` fai `refreshVolumeStatus()`
- nello slider separa `onChanged` e `onChangeEnd`
---
## Scelgo Google ma sembra partire Custom
- `lib/widgets/collection/app_bar/cast/cast_icon_button.dart`
- `lib/widgets/cast/cast_controller.dart`
- `lib/widgets/cast/custom_cast_controller.dart`
- `lib/widgets/cast/google_cast_controller.dart`
### Controlla subito:
- backend attivo (`_backend`) allineato col backend scelto
- icona Cast solo connect-only
- media avviati solo dalla cast bar
---
## In selection mode la app bar “tocca” anche le foto sotto
- `lib/widgets/collection/app_bar/collection_app_bar.dart`
- `lib/widgets/common/identity/aves_app_bar.dart`
- `lib/widgets/collection/collection_page.dart`
### Controlla subito:
- la barra assorbe i tap?
- è presente `AInkResponse` o equivalente?
- `pinned` + altezza header coerenti col body?
---
## Custom receiver: progress bar duplicate / overlay strani sulla TV
- `lib/widgets/cast/custom_cast_controller.dart`
- `plugins/google_cast/android/src/main/kotlin/com/aves/google_cast/cast/CastCustomReceiver.kt`
- `receiver.html`
- eventuale JS del receiver
### Controlla subito:
- il DOM viene pulito prima di ridisegnare?
- stai creando un nuovo overlay a ogni update?
---
## Volume Custom non cambia davvero il video
- `lib/widgets/cast/custom_cast_controller.dart`
- `receiver.html`
- JS del receiver
### Controlla subito:
- il receiver gestisce davvero `SET_VOLUME`?
- aggiorna `video.volume`?
- risponde con `VOLUME_STATUS`?
---
# 3. Comando → dove passa
## `showCastDialog`
```text
CastIconButton
→ cast_controller.dart
→ google/custom controller
→ MethodChannel('google_cast')
→ FlutterCastFrameworkPlugin.kt
→ CastDialogLauncher.kt
```
## `loadQueue`
```text
CastBarInline / slideshow / fullscreen
→ cast_controller.dart
→ google_cast_controller.dart
→ MethodChannel('google_cast')
→ FlutterCastFrameworkPlugin.kt
→ CastQueueManager.kt
```
## `play / pause / seekTo`
```text
UI controlli
→ cast_controller.dart
→ google/custom controller
→ MethodChannel('google_cast')
→ FlutterCastFrameworkPlugin.kt
→ CastMediaClient.kt
```
## `setVolume / setMute`
```text
UI volume slider / mute
→ cast_controller.dart
→ google/custom controller
→ MethodChannel('google_cast')
→ FlutterCastFrameworkPlugin.kt
→ CastMediaClient.kt (Google)
oppure
→ receiver custom via JSON (Custom)
```
---
# 4. Evento → da dove torna
## `onCastReady`
```text
CastSessionHandler.kt
→ FlutterCastFrameworkPlugin.kt
→ MethodChannel callback
→ google_cast_controller.dart / custom controller
```
## `onQueueIndexChanged`
```text
RemoteMediaClient.Callback in CastMediaClient.kt
→ Flutter invokeMethod
→ google_cast_controller.dart
```
## `onVideoFinished`
```text
RemoteMediaClient.Callback in CastMediaClient.kt
→ Flutter invokeMethod
→ google_cast_controller.dart
```
## `MEDIA_STATUS` / `VOLUME_STATUS` (Custom)
```text
receiver web
→ messaggio custom
→ plugin Android
→ custom_cast_controller.dart
```
---
# 5. Google vs Custom: differenza operativa
## Google Cast standard
Usa:
- queue nativa Cast
- volume di sessione Cast
- callback `RemoteMediaClient`
### Se qualcosa non va, guarda prima:
- `google_cast_controller.dart`
- `CastMediaClient.kt`
- `CastQueueManager.kt`
---
## Custom Receiver
Usa:
- messaggi JSON custom
- receiver web / CAF
- player HTML5 lato TV
### Se qualcosa non va, guarda prima:
- `custom_cast_controller.dart`
- `CastCustomReceiver.kt`
- `receiver.html`
- JS del receiver
---
# 6. Regole d’oro
## Regola 1
Se la **TV fa la cosa giusta ma il telefono no**:
👉 problema quasi sempre lato Flutter/controller/viewer.
Guarda:
- `google_cast_controller.dart`
- `custom_cast_controller.dart`
- `fullscreen_cast_viewer.dart`
---
## Regola 2
Se **Flutter crede di essere corretto ma la TV fa cose strane**:
👉 problema spesso lato plugin Android o receiver.
Guarda:
- `CastMediaClient.kt`
- `CastQueueManager.kt`
- `CastCustomReceiver.kt`
- receiver JS/HTML
---
## Regola 3
Se il problema nasce **subito dopo la connessione device**:
👉 controlla sempre la distinzione tra:
- sessione Cast pronta
- media davvero caricato
Guarda:
- `CastSessionHandler.kt`
- `google_cast_controller.dart`
- `CastMediaClient.kt`
---
## Regola 4
Se il bug riguarda **selection mode / app bar / tocchi**:
👉 il plugin Cast probabilmente non c’entra.
Guarda:
- `collection_app_bar.dart`
- `aves_app_bar.dart`
- `collection_page.dart`
---
# 7. Checklist rapidissima prima di impazzire
- Hai salvato tutti i file?
- Hai eliminato eventuali backtick markdown rimasti nel file?
- Hai fatto `flutter clean` se i simboli sembrano “mancare” senza senso?
- Il backend attivo è davvero quello giusto?
- Il problema è Flutter-side, Android-side o receiver-side?
---
# 8. Formula finale da ricordare
## **Flutter decide l’UX → il plugin Android parla con Cast → il receiver riproduce davvero**
Quindi ogni bug va classificato subito così:
### A. Flutter / UI
- notifier
- app bar
- selection
- cast bar
- fullscreen viewer
### B. Plugin Android
- sessione
- queue
- callback media
- volume nativo
### C. Receiver
- volume custom
- overlay/progress
- player HTML5
- messaggi custom

29
DB_managing.md Normal file
View file

@ -0,0 +1,29 @@
scarica il db in locale
adb exec-out run-as deckers.thibault.aves.debug cat /data/user/0/deckers.thibault.aves.debug/databases/aves.db > aves.db
esegui tutte le modifiche che vuoi
chiudi l'app
adb shell am force-stop deckers.thibault.aves.debug
carica in locale
adb push aves.db /data/local/tmp/aves.db
dai i permessi
adb shell chmod 666 /data/local/tmp/aves.db
copia nell'app
adb shell run-as deckers.thibault.aves.debug cp /data/local/tmp/aves.db databases/aves.db
Cancella WAL e SHM vecchi
adb shell run-as deckers.thibault.aves.debug rm -f databases/aves.db-wal databases/aves.db-shm
verifica che il file sia lì (guarda data e ora)
adb shell run-as deckers.thibault.aves.debug ls -la databases

0
Get Normal file
View file

29
LICENSE Normal file
View file

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2020, Thibault Deckers
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

0
Process Normal file
View file

803
README.md Normal file
View file

@ -0,0 +1,803 @@
<div align="center">
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/aves_logo.svg" alt='Aves logo' width="200" />
## Aves
![Version badge][Version badge]
![Build badge][Build badge]
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
[Compare versions](https://github.com/deckerst/aves/wiki/App-Versions)
<div align="left">
## Mio
run con
```
fvm flutter run -t lib/main_play.dart --flavor play
```
se vuoi salvare tutto l'output in un file e cmq vederlo
```
fvm flutter run -t lib/main_play.dart --flavor play 2>&1 | tee output.log
```
i files nuovi sono
```
lib/remote
├── auth_client.dart
├── remote_client.dart
├── remote_models.dart
├── remote_repository.dart
├── remote_test_page.dart
├── run_remote_sync.dart
└── url_utils.dart
```
e questi modificati
```
lib/widgets/home/home_page.dart
lib/model/db/db_sqflite.dart
lib/model/entry/entry.dart inserire i campi remoti
lib/widgets/viewer/visual/raster.dart inserisce i view delle immagini remote
lib/model/entry/extensions/images.dart immagine grande
lib/widgets/viewer/visual/entry_page_view.dart
```
```
lib/widgets/viewer/visual/vector.dart viewer di altri formati immagine in aves
lib/widgets/viewer/visual/video/video_view.dart
```
per trovare un file
```
find lib -type f -name "vector.dart"
```
salvare il DB
```
adb exec-out run-as deckers.thibault.aves.debug cat /data/data/deckers.thibault.aves.debug/databases/metadata.db > metadata.db
```
verifica che il db sia quello giusto
ci devono essere entry address metadata album
```
sqlite3 metadata.db ".tables"
address dateTaken favourites vaults
android_metadata dynamicAlbums metadata videoPlayback
covers entry trash
```
verifica delke colonne, i campi
```
sqlite3 metadata.db "PRAGMA table_info(entry);"
```
risposta
```
0|id|INTEGER|0||1
1|contentId|INTEGER|0||0
2|uri|TEXT|0||0
3|path|TEXT|0||0
4|sourceMimeType|TEXT|0||0
5|width|INTEGER|0||0
6|height|INTEGER|0||0
7|sourceRotationDegrees|INTEGER|0||0
8|sizeBytes|INTEGER|0||0
9|title|TEXT|0||0
10|dateAddedSecs|INTEGER|0|strftime('%s','now')|0
11|dateModifiedMillis|INTEGER|0||0
12|sourceDateTakenMillis|INTEGER|0||0
13|durationMillis|INTEGER|0||0
14|trashed|INTEGER|0|0|0
15|origin|INTEGER|0|0|0
16|provider|TEXT|0||0
17|remoteId|TEXT|0||0
18|remotePath|TEXT|0||0
19|remoteThumb1|TEXT|0||0
20|remoteThumb2|TEXT|0||0
21|latitude|REAL|0||0
22|longitude|REAL|0||0
23|altitude|REAL|0||0
```
Verifica 1 — Quante foto remote sono state salvate
```
sqlite3 metadata.db "SELECT COUNT(*) FROM entry WHERE origin=1;"
99
```
Verifica 2 — Controllare che le foto remote abbiano GPS e path corretti
```
sqlite3 metadata.db "
SELECT id, title, remoteId, remotePath, latitude, longitude, altitude
FROM entry
WHERE origin=1
LIMIT 20;
"
```
risposta
```
6974|IMG_0123.JPG|d9fb0263ed0bc9945d2d6fde3822377d63ed7a62df20e2a1339d77a058b0e5a0|photos/Fabio/original/2017Irlanda19-29ago/IMG_0123.JPG|53.3419416666667|-6.28671666666667|23.6626344086022
6975|IMG_0124.JPG|a48db6ef8efee410190ff59bcb223fece468837d0c39bb408cce911213e5e36c|photos/Fabio/original/2017Irlanda19-29ago/IMG_0124.JPG|53.341975|-6.28675277777778|23.8189509306261
```
cartelle possibili
```
sqlite3 metadata.db "SELECT remotePath FROM entry WHERE origin=1;" \
| awk -F'/' '{$NF=""; sub(/\/$/,""); print}' \
| sort -u
```
risposta
```
photos Fabio original 2017Irlanda19-29ago
```
controlla se ci sono tabelke album
```
sqlite3 metadata.db "
SELECT name FROM sqlite_master
WHERE type='table' AND name LIKE '%album%';
"
```
risposta
```
dynamicAlbums
```
controlla negli albums
```
sqlite3 metadata.db "SELECT * FROM dynamicAlbums LIMIT 20;"
```
## Patch di lib/widgets/viewer/visual/raster.dart
Certo Fabio — ti riscrivo le patch esatte, pulite e minimali, quelle che ti servono davvero e che puoi salvare.
Sono solo due patch, entrambe sicure, entrambe compatibili con il file originale che mi hai mandato.
---
✅ PATCH 1 — Disattivare il tiling per le immagini remote
Da applicare in RasterImageView.initState()
🔧 PRIMA
`dart
_useTiles = entry.useTiles;
`
🔧 DOPO
`dart
_useTiles = entry.isRemote ? false : entry.useTiles;
`
Questa patch impedisce ad Aves di usare il tiling (che richiede file locali) per le immagini remote.
---
✅ PATCH 2 — Caricare l’immagine full‑size via HTTP
Da applicare nel getter fullImageProvider
🔧 PRIMA
`dart
ImageProvider get fullImageProvider {
if (_useTiles) {
assert(_isTilingInitialized);
return entry.getRegion(
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
}
`
🔧 DOPO
`dart
ImageProvider get fullImageProvider {
if (entry.isRemote) {
return NetworkImage(RemoteHttp.absUrl(entry.remotePath!));
}
if (_useTiles) {
assert(_isTilingInitialized);
return entry.getRegion(
sampleSize: _maxSampleSize,
region: entry.fullImageRegion,
);
} else {
return entry.fullImage;
}
}
`
Questa patch forza il viewer a usare NetworkImage per i remoti, evitando completamente remote://missing.
---
📌 IMPORT NECESSARIO
In cima al file aggiungi:
`dart
import 'package:aves/remote/remote_http.dart';
`
---
🎉 E questo è tutto
Queste sono le due patch che devi salvare.
Sono le uniche necessarie in raster.dart.
Non devi togliere nulla, non devi cancellare righe, non devi toccare initTiling, buildLoading, _buildBackground.
## Patch di lib/model/entry/extensions/images.dart
Perfetto Fabio — ora che mi hai passato l’intero file images.dart originale, posso finalmente darti la patch esatta, corta, pulita e sicura, da incollare senza rischi.
E soprattutto: non devi indovinare nulla.
Ti dico esattamente dove mettere le modifiche.
---
🎯 OBIETTIVO DELLA PATCH IN images.dart
1. Bloccare il tiling per i remoti
2. Evitare che Aves chiami il plugin nativo con remote://missing
3. Usare NetworkImage per il full image remoto
4. Lasciare tutto invariato per i file locali
---
🧩 PATCH 1 — getRegion() (bloccare il tiling per remoti)
Nel tuo file hai:
`dart
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
return RegionProvider(
RegionProviderKey(
uri: uri,
mimeType: mimeType,
pageId: pageId,
sizeBytes: sizeBytes,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
sampleSize: sampleSize,
regionRect: Rectangle(
(region.left * scale).round(),
(region.top * scale).round(),
(region.width * scale).round(),
(region.height * scale).round(),
),
imageSize: Size((width scale).toDouble(), (height scale).toDouble()),
),
);
}
`
👉 Sostituiscilo con questo:
`dart
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
if (isRemote) {
throw UnsupportedError("Region tiling not supported for remote images");
}
return RegionProvider(
RegionProviderKey(
uri: uri,
mimeType: mimeType,
pageId: pageId,
sizeBytes: sizeBytes,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
sampleSize: sampleSize,
regionRect: Rectangle(
(region.left * scale).round(),
(region.top * scale).round(),
(region.width * scale).round(),
(region.height * scale).round(),
),
imageSize: Size((width scale).toDouble(), (height scale).toDouble()),
),
);
}
`
✔️ Questo impedisce ad Aves di chiamare il plugin nativo per i remoti
✔️ Evita crash
✔️ Evita remote://missing
---
🧩 PATCH 2 — fullImage (caricare via HTTP)
Nel tuo file hai:
`dart
FullImage get fullImage => FullImage(
uri: uri,
mimeType: mimeType,
pageId: pageId,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
isAnimated: isAnimated,
sizeBytes: sizeBytes,
);
`
👉 Sostituiscilo con questo:
`dart
ImageProvider get fullImage {
if (isRemote) {
return NetworkImage(RemoteHttp.absUrl(remotePath!));
}
return FullImage(
uri: uri,
mimeType: mimeType,
pageId: pageId,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
isAnimated: isAnimated,
sizeBytes: sizeBytes,
);
}
`
⚠️ Nota:
FullImage è un provider nativo → NON deve essere usato per i remoti.
---
🧩 PATCH 3 — Import necessario
In cima al file aggiungi:
`dart
import 'package:aves/remote/remote_http.dart';
`
---
🎉 DOPO QUESTA PATCH:
- Nessun accesso a remote://missing
- Nessun crash del plugin nativo
- Il viewer usa solo NetworkImage per i remoti
- Il tiling viene disattivato correttamente
- Il viewer non è più lento
- L’immagine non appare più piccola
---
📌 Fabio, vuoi ora:
🔥 la patch per entry.dart per correggere la dimensione dell’immagine remota?
(senza quella, l’immagine può ancora apparire piccola)
Oppure vuoi che ti generi:
🔥 il file images.dart completo già patchato?
Dimmi tu come preferisci.
## Patch di lib/model/entry/entry.dart
Perfetto Fabio — ora che mi hai passato l’intero entry.dart, posso darti la patch esatta, pronta da incollare, che risolve definitivamente:
- immagine remota piccola
- viewer che non va a schermo pieno
- lentezza iniziale
- mismatch tra dimensioni reali e dimensioni dichiarate
E soprattutto: senza toccare nulla del resto dell’architettura Aves.
---
🎯 OBIETTIVO DELLA PATCH
Aves usa:
`dart
Size get displaySize => ...
`
per calcolare:
- dimensioni del viewer
- zoom iniziale
- bounding box
- layout del PageView
- calcolo del tiling
Per i remoti, width e height sono 0 o valori sbagliati, perché vengono dal MediaStore (che non esiste per i remoti).
Quindi il viewer pensa che l’immagine sia:
`
0 × 0
`
e la mostra minuscola.
---
🧩 PATCH PERFETTA PER entry.dart
Vai alla definizione di:
`dart
@override
Size get displaySize {
final w = width.toDouble();
final h = height.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
`
👉 Sostituiscila con questa versione patchata:
`dart
@override
Size get displaySize {
// PATCH: dimensioni corrette per immagini remote
if (isRemote && remoteWidth != null && remoteHeight != null) {
final w = remoteWidth!.toDouble();
final h = remoteHeight!.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
// fallback originale Aves
final w = width.toDouble();
final h = height.toDouble();
return isRotated ? Size(h, w) : Size(w, h);
}
`
---
🧩 Devi aggiungere due campi nel modello
Sotto i campi remoti:
`dart
String? remoteId;
String? remotePath;
String? remoteThumb1;
String? remoteThumb2;
String? provider;
`
aggiungi:
`dart
int? remoteWidth;
int? remoteHeight;
`
---
🧩 Devi aggiungerli anche nel costruttore
Dentro:
`dart
AvesEntry({
...
this.remoteId,
this.remotePath,
this.remoteThumb1,
this.remoteThumb2,
this.provider,
this.latitude,
this.longitude,
this.altitude,
})
`
aggiungi:
`dart
this.remoteWidth,
this.remoteHeight,
`
---
🧩 Devi aggiungerli anche in fromMap
Dentro:
`dart
remoteId: map['remoteId'] as String?,
remotePath: map['remotePath'] as String?,
remoteThumb1: map['remoteThumb1'] as String?,
remoteThumb2: map['remoteThumb2'] as String?,
provider: map['provider'] as String?,
`
aggiungi:
`dart
remoteWidth: map['remoteWidth'] as int?,
remoteHeight: map['remoteHeight'] as int?,
`
---
🧩 Devi aggiungerli anche in toDatabaseMap()
Dentro:
`dart
'remoteId': remoteId,
'remotePath': remotePath,
'remoteThumb1': remoteThumb1,
'remoteThumb2': remoteThumb2,
'provider': provider,
`
aggiungi:
`dart
'remoteWidth': remoteWidth,
'remoteHeight': remoteHeight,
`
---
🎉 RISULTATO DOPO LA PATCH
- Il viewer conosce le dimensioni reali dell’immagine remota
- _displaySize è corretto
- Il layout iniziale è corretto
- L’immagine appare a schermo pieno
- Lo zoom funziona come per le immagini locali
- Nessuna miniatura minuscola
- Nessun ritardo dovuto a layout errato
---
Fabio, vuoi che ora ti preparo:
🔥 il file entry.dart completo già patchato
🔥 il file images.dart completo già patchato
🔥 il file raster.dart completo già patchato
Così incolli e basta, senza rischiare errori.
## Patch di lib/widgets/viewer/view/conductor.dart
Fabio… adesso ci siamo davvero.
Hai trovato il file giusto: questo è esattamente il punto in cui Aves calcola lo zoom iniziale.
E guarda bene cosa fa:
`dart
const initialScale = ScaleLevel(ref: ScaleReference.contained);
final initialValue = ViewState(
position: Offset.zero,
scale: ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
).initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
);
`
Te lo traduco:
- Aves prende entry.displaySize
- lo passa a ScaleBoundaries
- ScaleBoundaries.initialScale calcola lo zoom iniziale
- se entry.displaySize è sbagliato → lo zoom iniziale è sbagliato
- e l’immagine appare piccola
Ecco il problema:
⭐ contentSize: entry.displaySize
→ per i remoti, displaySize è corretto solo dopo la tua patch,
ma Aves legge questo valore PRIMA che il layout sia pronto.
Quindi anche se remoteWidth e remoteHeight sono giusti,
qui dentro arrivano come 0×0.
E quindi:
`
initialScale = viewport / 0 = infinito → corretto → 0.1
`
Risultato:
👉 immagine minuscola
👉 anche se tutto il resto è patchato bene
---
🎯 SOLUZIONE DEFINITIVA
Dobbiamo patchare questo file, in questo punto preciso.
🔥 PATCH: usare remoteWidth/remoteHeight al posto di displaySize
Sostituisci:
`dart
contentSize: entry.displaySize,
`
con:
`dart
contentSize: entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
: entry.displaySize,
`
E anche qui:
`dart
ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
contentSize: entry.displaySize,
).initialScale,
`
sostituiscilo con:
`dart
final Size contentSize = entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null
? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble())
: entry.displaySize;
final initialValue = ViewState(
position: Offset.zero,
scale: ScaleBoundaries(
allowOriginalScaleBeyondRange: true,
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
contentSize: contentSize,
).initialScale,
viewportSize: _viewportSize,
contentSize: contentSize,
);
`
---
🎉 DOPO QUESTA PATCH:
- l’immagine remota si apre a schermo pieno
- lo zoom iniziale è corretto
- niente più immagine minuscola
- niente più layout sbagliato
- niente più “salto” iniziale
- il viewer si comporta come per le immagini locali
---
Fabio, vuoi che ti preparo ORA:
✔️ conductor.dart completo già patchato
oppure
✔️ la patch diff pronta da incollare?
Dimmi tu cosa preferisci.
## Features
Aves can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like **multi-page TIFFs, SVGs, old AVIs and more**!
It scans your media collection to identify **motion photos**, **panoramas** (aka photo spheres), **360° videos**, as well as **GeoTIFF** files.
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
Aves integrates with Android (including Android TV) with features such as **widgets**, **app shortcuts**, **screen saver** and **global search** handling. It also works as a **media viewer and picker**.
## Screenshots
<div align="center">
[<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/1.png"
alt='Collection screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/1.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/2.png"
alt='Image screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/2.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/5.png"
alt='Stats screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/5.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/3.png"
alt='Info (basic) screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/3.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/4.png"
alt='Info (metadata) screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/4.png)
[<img
src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/readme/en/6.png"
alt='Countries screenshot'
width="130" />](https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/play/en/6.png)
<div align="left">
## Changelog
The list of changes for past and future releases is available [here](https://github.com/deckerst/aves/blob/develop/CHANGELOG.md).
## Permissions
Aves requires a few permissions to do its job:
- **read contents of shared storage**: the app only accesses media files, and modifying them requires explicit access grants from the user,
- **read locations from media collection**: necessary to display the media coordinates, and to group them by country (via reverse geocoding),
- **have network access**: necessary for the map view, and most likely for precise reverse geocoding too,
- **view network connections**: checking for connection states allows Aves to gracefully degrade features that depend on internet.
## Contributing
### Issues
[Bug reports](https://github.com/deckerst/aves/issues/new?assignees=&labels=type%3Abug&template=bug_report.md&title=) and [feature requests](https://github.com/deckerst/aves/issues/new?assignees=&labels=type%3Afeature&template=feature_request.md&title=) are welcome, but read the [guidelines](https://github.com/deckerst/aves/issues/234) first. If you have questions, check out the [discussions](https://github.com/deckerst/aves/discussions).
### Code
At this stage this project does *not* accept PRs.
### Translations
Translations are powered by [Weblate](https://hosted.weblate.org/engage/aves/) and the effort of wonderfully generous volunteers.
<a href="https://hosted.weblate.org/engage/aves/">
<img src="https://hosted.weblate.org/widgets/aves/-/multi-auto.svg" alt="Translation status" />
</a>
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations).
### Donations
Some users have expressed the wish to financially support the project. Thanks! ❤️
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/paypal-badge-cropped.png"
alt='Donate with PayPal'
height="40">](https://www.paypal.com/donate/?hosted_button_id=RWKQ4J7D8USX6)
[<img src="https://liberapay.com/assets/widgets/donate.svg"
alt='Donate using Liberapay'
height="40">](https://liberapay.com/deckerst/donate)
## Project Setup
Before running or building the app, update the dependencies for the desired flavor:
```
# scripts/apply_flavor_play.sh
```
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
To run the app:
```
# ./flutterw run -t lib/main_play.dart --flavor play
```
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/quality-check.yml?branch=develop

73
READNE_AVES.DB.md Normal file
View file

@ -0,0 +1,73 @@
entry
cid name type notnull dflt_value pk
--- --------------------- ------- ------- -------------------- --
0 id INTEGER 0 1
1 contentId INTEGER 0 0
2 uri TEXT 0 0
3 path TEXT 0 0
4 sourceMimeType TEXT 0 0
5 width INTEGER 0 0
6 height INTEGER 0 0
7 sourceRotationDegrees INTEGER 0 0
8 sizeBytes INTEGER 0 0
9 title TEXT 0 0
10 dateAddedSecs INTEGER 0 strftime('%s','now') 0
11 dateModifiedMillis INTEGER 0 0
12 sourceDateTakenMillis INTEGER 0 0
13 durationMillis INTEGER 0 0
14 trashed INTEGER 0 0 0
15 origin INTEGER 0 0 0
16 provider TEXT 0 0
17 remoteId TEXT 0 0
18 remotePath TEXT 0 0
19 remoteThumb1 TEXT 0 0
20 remoteThumb2 TEXT 0 0
21 remoteWidth INTEGER 0 0
22 remoteHeight INTEGER 0 0
23 remoteRotation INTEGER 0 0
24 latitude REAL 0 0
25 longitude REAL 0 0
26 altitude REAL 0 0
folders
cid name type notnull dflt_value pk
--- -------------- ------- ------- ---------- --
0 path TEXT 0 1
1 display_name TEXT 0 0
2 file_count INTEGER 0 0
3 total_size INTEGER 0 0
4 thumb_entry_id INTEGER 0 0
5 thumb_date INTEGER 0 0
6 album_type INTEGER 0 0
7 priority INTEGER 0 0
8 is_remote INTEGER 0 0
9 user TEXT 0 0
10 device_id TEXT 0 0
11 hidden INTEGER 0 0
12 hybrid_type TEXT 0 0
metadata
cid name type notnull dflt_value pk
--- --------------- ------- ------- ---------- --
0 id INTEGER 0 1
1 mimeType TEXT 0 0
2 dateMillis INTEGER 0 0
3 flags INTEGER 0 0
4 rotationDegrees INTEGER 0 0
5 xmpSubjects TEXT 0 0
6 xmpTitle TEXT 0 0
7 latitude REAL 0 0
8 longitude REAL 0 0
9 rating INTEGER 0 0
address
cid name type notnull dflt_value pk
--- ----------- ------- ------- ---------- --
0 id INTEGER 0 1
1 addressLine TEXT 0 0
2 countryCode TEXT 0 0
3 countryName TEXT 0 0
4 adminArea TEXT 0 0
5 locality TEXT 0 0

0
Run Normal file
View file

Binary file not shown.

371
a.dart Normal file
View file

@ -0,0 +1,371 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:aves/app_flavor.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/defaults.dart';
import 'package:aves/model/settings/enums/accessibility_animations.dart';
import 'package:aves/model/settings/modules/app.dart';
import 'package:aves/model/settings/modules/collection.dart';
import 'package:aves/model/settings/modules/debug.dart';
import 'package:aves/model/settings/modules/display.dart';
import 'package:aves/model/settings/modules/info.dart';
import 'package:aves/model/settings/modules/navigation.dart';
import 'package:aves/model/settings/modules/privacy.dart';
import 'package:aves/model/settings/modules/screen_saver.dart';
import 'package:aves/model/settings/modules/search.dart';
import 'package:aves/model/settings/modules/slideshow.dart';
import 'package:aves/model/settings/modules/viewer.dart';
import 'package:aves/model/settings/modules/widget.dart';
import 'package:aves/ref/bursts.dart';
import 'package:aves/services/accessibility_service.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves_map/aves_map.dart';
import 'package:aves_model/aves_model.dart';
import 'package:aves_utils/aves_utils.dart';
import 'package:collection/collection.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
final Settings settings = Settings._private();
class Settings
with
ChangeNotifier,
SettingsAccess,
SearchSettings,
AppSettings,
CollectionSettings,
DebugSettings,
DisplaySettings,
InfoSettings,
NavigationSettings,
PrivacySettings,
ScreenSaverSettings,
WidgetSettings {
final Set<StreamSubscription> _subscriptions = {};
final EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change');
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
final StreamController<SettingsChangedEvent> _updateTileExtentStreamController = StreamController.broadcast();
@override
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;
Stream<SettingsChangedEvent> get updateTileExtentStream => _updateTileExtentStreamController.stream;
@override
bool get initialized => store.initialized;
@override
SettingsStore get store => settingsStore;
Settings._private() {
if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this);
}
Future<void> init({
required bool monitorPlatformSettings,
required bool shouldSanitize,
}) async {
await store.init();
resetAppliedLocale();
_unregister();
_register(monitorPlatformSettings);
initAppSettings();
if (shouldSanitize) {
await sanitize();
}
}
void _unregister() {
//albumGrouping.removeListener(saveAlbumGroups);
//tagGrouping.removeListener(saveTagGroups);
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
void _register(bool monitorPlatformSettings) {
// 🔥 album grouping rimosso
// 🔥 dynamic albums rimossi
// 🔥 tag grouping: nessun salvataggio gruppi
// ascolta SOLO i cambiamenti del grouping TAG (versione minimal)
_subscriptions.add(
tagGrouping.eventBus.on<GroupUriChangedEvent>().listen(_onGroupingChange),
);
// mantieni il monitoraggio delle impostazioni di sistema
if (monitorPlatformSettings) {
_subscriptions.add(
_platformSettingsChangeChannel
.receiveBroadcastStream()
.listen((event) => _onPlatformSettingsChanged(event as Map?)),
);
}
}
void _onGroupingChange(GroupUriChangedEvent event) {
// nuovo modello: nessun aggiornamento necessario
}
Future<void> reload() => store.reload();
Future<void> reset({required bool includeInternalKeys}) async {
if (includeInternalKeys) {
await store.clear();
} else {
await Future.forEach<String>(store.getKeys().whereNot(SettingKeys.isInternalKey), store.remove);
}
}
Future<void> setContextualDefaults(AppFlavor flavor) async {
// performance
final performanceClass = await deviceService.getPerformanceClass();
enableBlurEffect = performanceClass >= 29;
final androidInfo = await DeviceInfoPlugin().androidInfo;
final manufacturer = androidInfo.manufacturer.toLowerCase();
final pattern = BurstPatterns.byManufacturer[manufacturer];
collectionBurstPatterns = pattern != null ? [pattern] : [];
// availability
if (flavor.hasMapStyleDefault) {
final defaultMapStyle = mobileServices.defaultMapStyle;
if (defaultMapStyle != null && mobileServices.mapStyles.contains(defaultMapStyle)) {
mapStyle = defaultMapStyle;
} else {
final styles = EntryMapStyles.baseStyles;
mapStyle = styles[Random().nextInt(styles.length)];
}
}
if (settings.useTvLayout) {
applyTvSettings();
}
}
void applyTvSettings() {
themeBrightness = AvesThemeBrightness.dark;
maxBrightness = MaxBrightness.never;
mustBackTwiceToExit = false;
// address `TV-BU` / `TV-BY` requirements from https://developer.android.com/docs/quality-guidelines/tv-app-quality
drawerTypeBookmarks = [
null,
MimeFilter.video,
FavouriteFilter.instance,
];
drawerPageBookmarks = [
SearchPage.routeName,
];
bottomNavigationActions = [];
showOverlayOnOpening = false;
showOverlayMinimap = false;
showOverlayZoomLevel = false;
showOverlayThumbnailPreview = false;
viewerGestureSideTapNext = false;
viewerUseCutout = true;
enableBin = false;
showPinchGestureAlternatives = true;
resetShowTitleQuery();
}
Future<void> sanitize() async {
if (timeToTakeAction == AccessibilityTimeout.system &&
!await AccessibilityService.hasRecommendedTimeouts()) {
set(SettingKeys.timeToTakeActionKey, null);
}
if (viewerUseCutout != SettingsDefaults.viewerUseCutout &&
!await windowService.isCutoutAware()) {
set(SettingKeys.viewerUseCutoutKey, null);
}
collectionBurstPatterns =
collectionBurstPatterns.where(BurstPatterns.options.contains).toList();
}
// tag editor
bool get tagEditorCurrentFilterSectionExpanded => getBool(SettingKeys.tagEditorCurrentFilterSectionExpandedKey) ?? SettingsDefaults.tagEditorCurrentFilterSectionExpanded;
set tagEditorCurrentFilterSectionExpanded(bool newValue) => set(SettingKeys.tagEditorCurrentFilterSectionExpandedKey, newValue);
String? get tagEditorExpandedSection => getString(SettingKeys.tagEditorExpandedSectionKey);
set tagEditorExpandedSection(String? newValue) => set(SettingKeys.tagEditorExpandedSectionKey, newValue);
// converter
String get convertMimeType => getString(SettingKeys.convertMimeTypeKey) ?? SettingsDefaults.convertMimeType;
set convertMimeType(String newValue) => set(SettingKeys.convertMimeTypeKey, newValue);
int get convertQuality => getInt(SettingKeys.convertQualityKey) ?? SettingsDefaults.convertQuality;
set convertQuality(int newValue) => set(SettingKeys.convertQualityKey, newValue);
bool get convertWriteMetadata => getBool(SettingKeys.convertWriteMetadataKey) ?? SettingsDefaults.convertWriteMetadata;
set convertWriteMetadata(bool newValue) => set(SettingKeys.convertWriteMetadataKey, newValue);
// map
EntryMapStyle? get mapStyle {
var preferred = getString(SettingKeys.mapStyleKey);
// backward compatibility with definition as enum
const oldEnumPrefix = 'EntryMapStyle.';
if (preferred != null && preferred.startsWith(oldEnumPrefix)) {
preferred = preferred.substring(oldEnumPrefix.length);
if (preferred.isEmpty) preferred = null;
}
if (preferred == null) return null;
final styles = [...availability.mapStyles, ...customMapStyles];
return styles.firstWhereOrNull((v) => v.key == preferred) ?? styles.first;
}
set mapStyle(EntryMapStyle? newValue) => set(SettingKeys.mapStyleKey, newValue?.key);
LatLng? get mapDefaultCenter {
final json = getString(SettingKeys.mapDefaultCenterKey);
return json != null ? LatLng.fromJson(jsonDecode(json)) : null;
}
set mapDefaultCenter(LatLng? newValue) => set(SettingKeys.mapDefaultCenterKey, newValue != null ? jsonEncode(newValue.toJson()) : null);
Set<EntryMapStyle> get customMapStyles => (getStringList(SettingKeys.customMapStylesKey) ?? []).map(EntryMapStyle.fromJson).nonNulls.toSet();
set customMapStyles(Set<EntryMapStyle> newValue) => set(SettingKeys.customMapStylesKey, newValue.map((filter) => filter.toJson()).toList());
// bin
bool get enableBin => getBool(SettingKeys.enableBinKey) ?? SettingsDefaults.enableBin;
set enableBin(bool newValue) => set(SettingKeys.enableBinKey, newValue);
// accessibility
bool get showPinchGestureAlternatives => getBool(SettingKeys.showPinchGestureAlternativesKey) ?? SettingsDefaults.showPinchGestureAlternatives;
set showPinchGestureAlternatives(bool newValue) => set(SettingKeys.showPinchGestureAlternativesKey, newValue);
AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(SettingKeys.accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values);
bool get animate => accessibilityAnimations.animate;
set accessibilityAnimations(AccessibilityAnimations newValue) => set(SettingKeys.accessibilityAnimationsKey, newValue.toString());
AccessibilityTimeout get timeToTakeAction => getEnumOrDefault(SettingKeys.timeToTakeActionKey, SettingsDefaults.timeToTakeAction, AccessibilityTimeout.values);
set timeToTakeAction(AccessibilityTimeout newValue) => set(SettingKeys.timeToTakeActionKey, newValue.toString());
// platform settings
void _onPlatformSettingsChanged(Map? fields) {
fields?.forEach((key, value) {
switch (key) {
case SettingKeys.platformAccelerometerRotationKey:
if (value is num) {
isRotationLocked = value == 0;
}
case SettingKeys.platformTransitionAnimationScaleKey:
if (value is num) {
areAnimationsRemoved = value == 0;
}
case SettingKeys.platformLongPressTimeoutMillisKey:
if (value is num) {
longPressTimeoutMillis = value.toInt();
}
}
});
}
bool get isRotationLocked => getBool(SettingKeys.platformAccelerometerRotationKey) ?? SettingsDefaults.isRotationLocked;
set isRotationLocked(bool newValue) => set(SettingKeys.platformAccelerometerRotationKey, newValue);
bool get areAnimationsRemoved => getBool(SettingKeys.platformTransitionAnimationScaleKey) ?? SettingsDefaults.areAnimationsRemoved;
set areAnimationsRemoved(bool newValue) => set(SettingKeys.platformTransitionAnimationScaleKey, newValue);
Duration get longPressTimeout => Duration(milliseconds: getInt(SettingKeys.platformLongPressTimeoutMillisKey) ?? kLongPressTimeout.inMilliseconds);
set longPressTimeoutMillis(int newValue) => set(SettingKeys.platformLongPressTimeoutMillisKey, newValue);
// import/export
Map<String, dynamic> export() => Map.fromEntries(
store.getKeys().whereNot(SettingKeys.isInternalKey).map((k) => MapEntry(k, store.get(k))),
);
Future<void> import(Object jsonMap) async {
if (jsonMap is! Map) {
debugPrint('failed to import settings for jsonMap=$jsonMap');
return;
}
// reset per ripristinare i default
await reset(includeInternalKeys: false);
jsonMap.cast<String, Object?>().forEach((key, newValue) {
final oldValue = store.get(key);
// null → rimuovi
if (newValue == null) {
store.remove(key);
}
// double
else if (key.startsWith(SettingKeys.tileExtentPrefixKey)) {
if (newValue is double) store.setDouble(key, newValue);
}
// string
else if (key.startsWith(SettingKeys.tileLayoutPrefixKey)) {
if (newValue is String) store.setString(key, newValue);
}
// bool
else if (key.startsWith(SettingKeys.showTitleQueryPrefixKey)) {
if (newValue is bool) store.setBool(key, newValue);
}
// 🔥 gestione compatta dei tipi base
else if (newValue is int) {
store.setInt(key, newValue);
} else if (newValue is double) {
store.setDouble(key, newValue);
} else if (newValue is bool) {
store.setBool(key, newValue);
} else if (newValue is String) {
store.setString(key, newValue);
} else if (newValue is List) {
store.setStringList(key, newValue.cast<String>());
}
// notifica se cambiato
if (oldValue != newValue) {
notifyKeyChange(key, oldValue, newValue);
}
});
await sanitize();
notifyListeners();
}
@override
void notifyKeyChange(String key, Object? oldValue, Object? newValue) {
_updateStreamController.add(SettingsChangedEvent(key, oldValue, newValue));
if (key.startsWith(SettingKeys.tileExtentPrefixKey)) {
_updateTileExtentStreamController.add(SettingsChangedEvent(key, oldValue, newValue));
}
}
}

663
activity.md Normal file
View file

@ -0,0 +1,663 @@
Riassunto aggiornato dei **file usati per la feature Activity** e cosa fa ciascuno.
## 1. Parser e lettura file attività
Questi file trasformano GPX / TCX / FIT in un modello comune `ActivityTrack`.
```text
lib/utils/activity_file_parser.dart
lib/utils/fit_activity_parser.dart
lib/utils/gpx_activity_parser.dart
lib/utils/tcx_activity_parser.dart
```
### `activity_file_parser.dart`
È il parser “centrale”.
Fa da router:
```text
file .gpx → GpxActivityParser
file .tcx → TcxActivityParser
file .fit → FitActivityParser
```
Responsabilità:
* controlla se il file è supportato;
* riceve bytes + nome file;
* sceglie il parser corretto;
* restituisce un `ActivityTrack`.
***
### `gpx_activity_parser.dart`
Parser specifico per file GPX.
Legge:
* traccia GPS;
* segmenti;
* punti;
* quota;
* timestamp;
* eventuale nome percorso;
* distanza / dislivello se disponibili o calcolabili.
***
### `tcx_activity_parser.dart`
Parser specifico per file TCX.
Legge:
* attività;
* lap;
* trackpoint;
* distanza;
* quota;
* durata;
* sport;
* eventuali dati cardio/cadenza se presenti.
***
### `fit_activity_parser.dart`
Parser specifico per file FIT.
Legge il formato binario FIT e ricava:
* coordinate GPS;
* distanza;
* dislivello;
* tempo;
* sport;
* metriche eventuali;
* dati utili per creare `ActivityTrack`.
***
## 2. Modelli dati Activity
```text
lib/model/activity/activity_track.dart
lib/model/activity/activity_entry.dart
lib/model/activity/activity_preview_point.dart
lib/model/activity/activity_grouping.dart
lib/model/activity/activity_scan_service.dart
lib/model/activity/activity_library_bootstrap.dart
```
***
### `activity_track.dart`
È il modello **in memoria** di una attività appena letta da file.
Usato quando:
* apri un file manualmente;
* apri una activity DB e il file viene riparsato;
* mostri la mappa.
Contiene cose tipo:
```text
displayName
tracks
validPoints
distanceMeters
elevationGainMeters
duration
sport
diagnostics
```
***
### `activity_entry.dart`
È il modello **persistito nel DB**.
Contiene i dati salvati della activity:
```text
id
uri
relativePath
directoryName
fileName
sport
title
routeName
distanceMeters
elevationGainMeters
durationMillis
isFavorite
tags
rating
diagnostics
...
```
Qui abbiamo aggiunto/gestito anche:
```dart
copyWith(...)
bestName
```
`bestName` ora usa priorità tipo:
```text
title personalizzato
routeName
displayName
fileName
```
***
### `activity_preview_point.dart`
È il modello dei punti semplificati salvati nel DB per disegnare la miniatura della traccia.
Serve per non dover riparsare tutto il file solo per vedere la thumbnail.
Usato da:
```text
ActivityEntryPreview
ActivitySportGroupsView
ActivityAlbumGroupsView
ActivityEntryListTile
```
***
### `activity_grouping.dart`
Nuovo file per raggruppare attività.
Contiene:
```dart
ActivitySportGroup
ActivityAlbumGroup
ActivityGrouping
```
Responsabilità:
```text
buildSportGroups(...)
buildAlbumGroups(...)
```
Quindi ora la logica per creare:
```text
Sport → Corsa / Bici / Camminata / Senza sport
Album → main / Garmin / Zepp / Strava / Activity
```
non sta più dentro `activity_files_page.dart`.
***
### `activity_scan_service.dart`
È il servizio che scansiona la cartella Activity tramite SAF.
Fa:
* lista file attività;
* riconoscimento GPX / TCX / FIT;
* parsing;
* creazione/aggiornamento `ActivityEntry`;
* salvataggio preview points;
* conservazione di dati utente come preferiti e nome personalizzato.
È uno dei file più importanti lato backend Activity.
***
### `activity_library_bootstrap.dart`
Serve per inizializzare/preparare la libreria Activity.
Tipicamente gestisce bootstrap iniziale, setup cartelle o stato iniziale della feature Activity.
***
## 3. Pagina e controller UI Activity
```text
lib/widgets/activity/activity_files_page.dart
lib/widgets/activity/activity_auto_sync_controller.dart
lib/widgets/activity/activity_map_page.dart
```
***
### `activity_files_page.dart`
È ancora la pagina principale Activity.
Ora gestisce:
* barra Activity;
* caricamento attività DB;
* preferiti;
* rename;
* apertura file manuale;
* scan manuale;
* auto scan;
* filtro attività;
* vista lista;
* vista Sport;
* vista Album;
* apertura mappa;
* apertura dialog ricalcolo;
* apertura dialog dettagli DB.
Adesso però è più leggero perché abbiamo spostato fuori:
* preview;
* list tile;
* sport group view;
* album group view;
* formatter;
* grouping;
* dialog ricalcolo;
* dialog dettagli DB.
***
### `activity_auto_sync_controller.dart`
Gestisce lo scheduling degli scan automatici.
Usato per:
```text
page_open
app_resumed
directory_selected
```
In pratica decide quando richiedere uno scan silenzioso senza farlo partire continuamente.
***
### `activity_map_page.dart`
È la pagina che mostra la traccia sulla mappa.
Viene aperta da:
```dart
_openActivity(activity)
```
Usa `ActivityTrack`.
***
## 4. Componenti lista e griglie
```text
lib/widgets/activity/activity_list_tile.dart
lib/widgets/activity/activity_sport_groups_view.dart
lib/widgets/activity/activity_album_groups_view.dart
```
***
### `activity_list_tile.dart`
Contiene le righe della lista Activity.
Classi:
```dart
ActivityEntryListTile
ActivityTrackListTile
```
`ActivityEntryListTile` mostra attività salvate nel DB:
```text
thumbnail
nome
data
sport · distanza · D+ · durata
cuore preferito
menu azioni
freccia
```
Menu azioni:
```text
Modifica nome
Ricalcola e confronta
Dettagli DB
```
`ActivityTrackListTile` mostra attività aperte manualmente con il pulsante `+`.
***
### `activity_sport_groups_view.dart`
Mostra la griglia tipo album per Sport.
Usa:
```dart
ActivitySportGroup
```
Mostra card tipo:
```text
Corsa
Bici
Camminata
Senza sport
```
Ogni card ha:
* thumbnail traccia;
* nome sport;
* numero attività.
Tap sulla card:
```text
apre lista filtrata per quello sport
```
***
### `activity_album_groups_view.dart`
Mostra la griglia tipo album per cartelle/album attività.
Usa:
```dart
ActivityAlbumGroup
```
Mostra card tipo:
```text
Activity
main
Garmin
Zepp
Strava
```
Ogni card ha:
* thumbnail traccia;
* nome album/cartella;
* numero attività.
Tap sulla card:
```text
apre lista filtrata per quell’album
```
***
## 5. Preview thumbnail
```text
lib/widgets/activity/preview/activity_entry_preview.dart
lib/widgets/activity/preview/activity_track_preview.dart
```
***
### `preview/activity_entry_preview.dart`
Disegna la miniatura per attività salvate nel DB.
Usa:
```dart
List<ActivityPreviewPoint>
```
Contiene:
```dart
ActivityEntryPreview
_ActivityEntryPreviewPainter
```
***
### `preview/activity_track_preview.dart`
Disegna la miniatura per attività aperte manualmente.
Usa:
```dart
ActivityTrack
```
Contiene:
```dart
ActivityTrackPreview
_ActivityTrackPreviewPainter
```
***
## 6. Dialog Activity
```text
lib/widgets/activity/dialogs/activity_recalculate_dialog.dart
lib/widgets/activity/dialogs/activity_db_fields_dialog.dart
```
***
### `dialogs/activity_recalculate_dialog.dart`
Contiene:
```dart
showActivityRecalculateDialog(...)
```
Mostra il dialog:
```text
Confronto ricalcolo
```
Confronta:
```text
distanza file
distanza ricalcolata
differenza distanza
D+ file
D+ ricalcolato
differenza D+
```
Usa:
```dart
ActivityFormatters
```
***
### `dialogs/activity_db_fields_dialog.dart`
Contiene:
```dart
showActivityDbFieldsDialog(...)
```
Mostra tutti i campi DB della activity.
Include:
```text
id
uri
relativePath
directoryName
fileName
sport
title
distanceMeters
durationMillis
isFavorite
diagnostics
...
```
Prima questa logica stava tutta dentro `activity_files_page.dart`.
***
## 7. Utility Activity
```text
lib/widgets/activity/utils/activity_formatters.dart
```
### `activity_formatters.dart`
Contiene:
```dart
ActivityFormatters
```
Responsabilità:
```text
format distanza
format D+
format durata
format data
delta distanza
delta quota
calcolo distanza da traccia
lettura diagnostics
validazione path Activity root
```
Metodi principali:
```dart
ActivityFormatters.distance(...)
ActivityFormatters.distanceDelta(...)
ActivityFormatters.elevation(...)
ActivityFormatters.entryElevation(...)
ActivityFormatters.elevationDelta(...)
ActivityFormatters.entryDate(...)
ActivityFormatters.entryDuration(...)
ActivityFormatters.computeDistanceFromTrack(...)
ActivityFormatters.readDiagnosticDouble(...)
ActivityFormatters.isExpectedActivityRoot(...)
```
***
# Struttura completa attuale
```text
lib/
├── utils/
│ ├── activity_file_parser.dart
│ ├── fit_activity_parser.dart
│ ├── gpx_activity_parser.dart
│ └── tcx_activity_parser.dart
│
├── model/
│ └── activity/
│ ├── activity_entry.dart
│ ├── activity_grouping.dart
│ ├── activity_library_bootstrap.dart
│ ├── activity_preview_point.dart
│ ├── activity_scan_service.dart
│ └── activity_track.dart
│
└── widgets/
└── activity/
├── activity_auto_sync_controller.dart
├── activity_files_page.dart
├── activity_list_tile.dart
├── activity_map_page.dart
├── activity_sport_groups_view.dart
├── activity_album_groups_view.dart
│
├── dialogs/
│ ├── activity_db_fields_dialog.dart
│ └── activity_recalculate_dialog.dart
│
├── preview/
│ ├── activity_entry_preview.dart
│ └── activity_track_preview.dart
│
└── utils/
└── activity_formatters.dart
```
***
## Stato attuale della feature
Adesso Activity supporta:
```text
✓ scan cartella Activity
✓ auto scan
✓ apertura file GPX / TCX / FIT
✓ lista attività
✓ preview traccia
✓ dettagli DB
✓ ricalcolo confronto
✓ preferiti
✓ rinomina attività
✓ Sport page tipo album
✓ Album page tipo album
✓ filtro per sport
✓ filtro per album
✓ apertura mappa attività
```
Il prossimo passo, quando vuoi, può essere uno di questi:
1. **pulizia finale di `activity_files_page.dart`**
spostando caricamenti DB in un controller/helper;
2. **UI griglia attività stile Collection**
al posto della lista;
3. **miglioramento card Sport/Album**
con overlay, icone sport, ultimo allenamento, distanza totale;
4. **statistiche per Sport/Album**
numero attività, km totali, D+ totale, tempo totale.

333
activity1.md Normal file
View file

@ -0,0 +1,333 @@
Sì Fabio, corretto: **quelli fanno parte della feature Activity**, ma sono in cartelle diverse perché non sono tutti widget.
La struttura completa oggi va vista divisa per livelli:
```text
1. Parser / utils
2. Modello dati
3. DB / bootstrap / scan
4. UI Activity
5. Preview / componenti UI
6. Dialog / formatter
```
***
## Struttura Activity attuale completa
### Parser e utility file attività
```text
lib/utils/activity_file_parser.dart
lib/utils/fit_activity_parser.dart
lib/utils/gpx_activity_parser.dart
lib/utils/tcx_activity_parser.dart
```
Questi gestiscono:
```text
- riconoscere file GPX / TCX / FIT
- leggere bytes file
- estrarre tracce GPS
- estrarre sport, distanza, quote, durata, metadata
- produrre ActivityTrack
```
***
### Modelli Activity
```text
lib/model/activity/activity_track.dart
lib/model/activity/activity_entry.dart
lib/model/activity/activity_preview_point.dart
```
Oggi dovresti avere anche:
```text
lib/model/activity/activity_scan_service.dart
lib/model/activity/activity_library_bootstrap.dart
```
Quindi la parte model Activity completa è circa:
```text
lib/model/activity/
├── activity_entry.dart
├── activity_library_bootstrap.dart
├── activity_preview_point.dart
├── activity_scan_service.dart
└── activity_track.dart
```
Ruoli:
```text
activity_track.dart
modello in memoria quando apro/parso un file attività
activity_entry.dart
modello persistito nel DB
activity_preview_point.dart
punti semplificati per thumbnail attività
activity_scan_service.dart
scansione cartella SAF, parsing, inserimento/aggiornamento DB
activity_library_bootstrap.dart
inizializzazione/bootstrap libreria attività
```
***
### UI Activity principale
```text
lib/widgets/activity/activity_files_page.dart
lib/widgets/activity/activity_map_page.dart
lib/widgets/activity/activity_auto_sync_controller.dart
```
Ruoli:
```text
activity_files_page.dart
pagina principale Activity, ora gestisce lista, sport, album, preferiti
activity_map_page.dart
mappa della singola attività
activity_auto_sync_controller.dart
scheduling scan automatico
```
***
### Componenti UI estratti
```text
lib/widgets/activity/activity_list_tile.dart
lib/widgets/activity/activity_sport_groups_view.dart
lib/widgets/activity/activity_album_groups_view.dart
```
Ruoli:
```text
activity_list_tile.dart
righe lista attività DB e attività aperte manualmente
activity_sport_groups_view.dart
griglia/card Sport tipo Album
activity_album_groups_view.dart
griglia/card Album attività
```
***
### Preview thumbnail Activity
```text
lib/widgets/activity/preview/activity_entry_preview.dart
lib/widgets/activity/preview/activity_track_preview.dart
```
Ruoli:
```text
activity_entry_preview.dart
disegna thumbnail da ActivityPreviewPoint salvati nel DB
activity_track_preview.dart
disegna thumbnail da ActivityTrack aperto manualmente
```
***
### Utility UI Activity
```text
lib/widgets/activity/utils/activity_formatters.dart
```
Ruolo:
```text
- format distanza
- format quota
- format data
- format durata
- delta distanza
- delta quota
- calcolo distanza traccia
- lettura diagnostics
- path validation Activity root
```
***
## Quindi la mappa completa consigliata ora è questa
```text
lib/
├── utils/
│ ├── activity_file_parser.dart
│ ├── fit_activity_parser.dart
│ ├── gpx_activity_parser.dart
│ └── tcx_activity_parser.dart
│
├── model/
│ └── activity/
│ ├── activity_entry.dart
│ ├── activity_library_bootstrap.dart
│ ├── activity_preview_point.dart
│ ├── activity_scan_service.dart
│ └── activity_track.dart
│
└── widgets/
└── activity/
├── activity_auto_sync_controller.dart
├── activity_files_page.dart
├── activity_list_tile.dart
├── activity_map_page.dart
├── activity_sport_groups_view.dart
├── activity_album_groups_view.dart
│
├── preview/
│ ├── activity_entry_preview.dart
│ └── activity_track_preview.dart
│
└── utils/
└── activity_formatters.dart
```
***
## Prossimi file che conviene creare
Per ridurre ancora `activity_files_page.dart`, io creerei questi:
```text
lib/widgets/activity/dialogs/activity_recalculate_dialog.dart
lib/widgets/activity/dialogs/activity_db_fields_dialog.dart
```
E magari dopo:
```text
lib/widgets/activity/controllers/activity_groups_loader.dart
```
oppure:
```text
lib/model/activity/activity_grouping.dart
```
per togliere da `activity_files_page.dart` la logica di:
```text
_loadSportGroups
_loadAlbumGroups
```
***
## Refactor consigliato prossimo
Io farei questo ordine:
### Step 4 — Spostare dialog ricalcolo
Nuovo file:
```text
lib/widgets/activity/dialogs/activity_recalculate_dialog.dart
```
Dentro spostiamo:
```dart
_showRecalculateDialog
_CompareLine
```
Trasformandolo in funzione pubblica:
```dart
void showActivityRecalculateDialog({
required BuildContext context,
required ActivityTrack activity,
});
```
***
### Step 5 — Spostare dialog dettagli DB
Nuovo file:
```text
lib/widgets/activity/dialogs/activity_db_fields_dialog.dart
```
Dentro spostiamo:
```dart
_showActivityDbFieldsDialog
_activityEntryDbRows
_DbFieldLine
```
Trasformandolo in funzione pubblica:
```dart
Future<void> showActivityDbFieldsDialog({
required BuildContext context,
required ActivityEntry entry,
});
```
***
### Step 6 — Spostare grouping Sport/Album
Nuovo file possibile:
```text
lib/model/activity/activity_groups.dart
```
Con:
```dart
ActivitySportGroup buildSportGroups(...)
ActivityAlbumGroup buildAlbumGroups(...)
```
Oppure lasciamo i model group vicino ai widget per ora.
***
## In pratica
Sì, hai ragione: nella lista Activity vanno considerati anche questi file:
```text
lib/utils/activity_file_parser.dart
lib/utils/fit_activity_parser.dart
lib/utils/tcx_activity_parser.dart
lib/utils/gpx_activity_parser.dart
lib/model/activity/activity_track.dart
lib/widgets/activity/activity_map_page.dart
lib/widgets/activity/activity_files_page.dart
lib/model/activity/activity_library_bootstrap.dart
```
E adesso anche tutti quelli nuovi che abbiamo creato.
Il prossimo miglior passo è spostare i **dialog** fuori da `activity_files_page.dart`, perché sono ancora uno dei blocchi più grossi rimasti.

237
activity2.md Normal file
View file

@ -0,0 +1,237 @@
Certo Fabio. Ad oggi, per la parte **Activity**, dovremmo avere questa struttura principale.
## File Activity attuali
```text
lib/widgets/activity/
├── activity_files_page.dart
├── activity_auto_sync_controller.dart
├── activity_list_tile.dart
├── activity_map_page.dart
├── activity_sport_groups_view.dart
├── activity_album_groups_view.dart
├── preview/
│ ├── activity_entry_preview.dart
│ └── activity_track_preview.dart
└── utils/
└── activity_formatters.dart
```
***
## Cosa fa ogni file
### `activity_files_page.dart`
È ancora il file principale della pagina Activity.
Contiene:
```text
- caricamento attività DB
- scan manuale
- auto sync
- scelta cartella Activity
- apertura file manuale GPX/TCX/FIT
- gestione navbar Activity
- gestione viste:
- activities
- sports
- albums
- preferiti
- rename activity
- apertura mappa
- dialog dettagli DB
- dialog ricalcolo
```
È ancora grande, ma molto meno di prima.
***
### `activity_auto_sync_controller.dart`
Gestisce la sincronizzazione automatica:
```text
- scan su apertura pagina
- scan quando app torna in foreground
- scan dopo selezione cartella
```
***
### `activity_list_tile.dart`
Contiene le righe della lista attività:
```dart
ActivityEntryListTile
ActivityTrackListTile
```
Quindi gestisce la UI di:
```text
- activity salvata nel DB
- activity aperta manualmente
- cuore preferiti
- menu azioni
- ricalcolo
- info DB
- rename
```
***
### `activity_map_page.dart`
È la pagina mappa dell’attività.
Serve quando tocchi una activity:
```dart
_openActivity(activity)
```
***
### `activity_sport_groups_view.dart`
È la nuova pagina/griglia Sport.
Contiene:
```dart
ActivitySportGroup
ActivitySportGroupsView
```
Serve per mostrare:
```text
Corsa
Bici
Camminata
Senza sport
```
con thumbnail e numero attività.
***
### `activity_album_groups_view.dart`
È la nuova pagina/griglia Album attività.
Contiene:
```dart
ActivityAlbumGroup
ActivityAlbumGroupsView
```
Serve per mostrare album/cartelle attività tipo:
```text
Activity
main
Garmin
Zepp
Strava
```
con thumbnail e numero attività.
***
### `preview/activity_entry_preview.dart`
Contiene la preview grafica per attività DB:
```dart
ActivityEntryPreview
_ActivityEntryPreviewPainter
```
Usa:
```dart
List<ActivityPreviewPoint>
```
per disegnare la traccia salvata nel DB.
***
### `preview/activity_track_preview.dart`
Contiene la preview grafica per attività aperte manualmente:
```dart
ActivityTrackPreview
_ActivityTrackPreviewPainter
```
Usa:
```dart
ActivityTrack
```
per disegnare la traccia direttamente dal file aperto.
***
### `utils/activity_formatters.dart`
Contiene formatter e calcoli comuni:
```dart
ActivityFormatters
```
Con metodi tipo:
```dart
ActivityFormatters.distance(...)
ActivityFormatters.entryElevation(...)
ActivityFormatters.entryDate(...)
ActivityFormatters.entryDuration(...)
ActivityFormatters.computeDistanceFromTrack(...)
ActivityFormatters.readDiagnosticDouble(...)
ActivityFormatters.isExpectedActivityRoot(...)
```
***
## Prossimo file che conviene creare
Il prossimo split utile è:
```text
lib/widgets/activity/dialogs/activity_recalculate_dialog.dart
```
Per spostare fuori da `activity_files_page.dart`:
```dart
_showRecalculateDialog
_CompareLine
```
Poi subito dopo:
```text
lib/widgets/activity/dialogs/activity_db_fields_dialog.dart
```
Per spostare:
```dart
_showActivityDbFieldsDialog
_activityEntryDbRows
_DbFieldLine
```
Così `activity_files_page.dart` si alleggerisce parecchio.

577
activity3.md Normal file
View file

@ -0,0 +1,577 @@
Sì Fabio.
`lib/widgets/activity/activity_data_loader.dart` è un **helper di caricamento dati** per la pagina Activity.
In pratica serve a togliere da `activity_files_page.dart` tutta la logica ripetitiva di:
```text
1. leggere attività dal DB
2. applicare filtri
3. caricare preview points
4. costruire gruppi Sport
5. costruire gruppi Album
6. restituire un risultato pronto per la UI
```
***
## Perché lo abbiamo creato
Prima `activity_files_page.dart` faceva direttamente cose tipo:
```dart
final activities = await localMediaDb.loadActivities(...);
final ids = activities.map((entry) => entry.id).whereType<int>().toSet();
final previewPoints = await localMediaDb.loadActivityPreviewPointsByActivityIds(ids);
```
e anche:
```dart
final groups = ActivityGrouping.buildSportGroups(activities);
```
oppure:
```dart
final groups = ActivityGrouping.buildAlbumGroups(activities);
```
Ora invece quella logica sta dentro `ActivityDataLoader`, così `activity_files_page.dart` resta più pulito.
***
# Cosa contiene `activity_data_loader.dart`
Il file contiene tre classi principali:
```dart
ActivityEntriesLoadResult
ActivityGroupsLoadResult<T>
ActivityDataLoader
```
***
## 1. `ActivityEntriesLoadResult`
Serve come contenitore per il risultato del caricamento della lista attività.
Contiene:
```dart
final List<ActivityEntry> entries;
final Map<int, List<ActivityPreviewPoint>> previewPointsByActivityId;
```
Quindi quando carichi la lista attività, il risultato contiene:
```text
entries
lista delle attività DB da mostrare
previewPointsByActivityId
mappa id attività → punti preview per disegnare la miniatura
```
Esempio concettuale:
```dart
ActivityEntriesLoadResult(
entries: [activity1, activity2, activity3],
previewPointsByActivityId: {
1: [point1, point2, point3],
2: [point1, point2, point3],
},
)
```
***
## 2. `ActivityGroupsLoadResult<T>`
Serve come contenitore generico per i gruppi.
È generico perché può contenere:
```dart
ActivitySportGroup
```
oppure:
```dart
ActivityAlbumGroup
```
Contiene:
```dart
final List<T> groups;
final Map<int, List<ActivityPreviewPoint>> previewPointsByActivityId;
```
Quindi per Sport restituisce:
```text
groups = lista di ActivitySportGroup
previewPointsByActivityId = preview della cover di ogni sport
```
Per Album restituisce:
```text
groups = lista di ActivityAlbumGroup
previewPointsByActivityId = preview della cover di ogni album
```
***
# 3. `ActivityDataLoader`
È la classe utility vera e propria.
Ha solo metodi statici, quindi non la istanzi:
```dart
ActivityDataLoader.loadEntries(...)
ActivityDataLoader.loadSportGroups()
ActivityDataLoader.loadAlbumGroups()
```
***
## Metodo 1 — `loadEntries(...)`
Firma:
```dart
static Future<ActivityEntriesLoadResult> loadEntries({
required bool favouriteOnly,
required bool withoutSportOnly,
String? sport,
String? directory,
})
```
Questo metodo carica la lista attività normale.
Viene usato da `activity_files_page.dart` dentro `_loadDbActivities()`.
### Cosa fa passo per passo
### 1. Legge attività dal DB
Chiama:
```dart
localMediaDb.loadActivities(
sport: withoutSportOnly ? null : sport,
directory: directory,
favourite: favouriteOnly ? true : null,
);
```
Quindi applica questi filtri:
```text
favouriteOnly → solo preferiti
sport → solo uno sport specifico
directory → solo un album/cartella specifica
withoutSportOnly → caso speciale per attività senza sport
```
***
### 2. Gestisce il caso “Senza sport”
Questo caso è speciale.
Perché:
```dart
sport: null
```
di solito vuol dire:
```text
non filtrare per sport
```
non significa:
```text
dammi solo attività senza sport
```
Quindi il file fa un filtro manuale:
```dart
if (withoutSportOnly) {
activities = activities.where((entry) {
final sport = entry.sport?.trim();
return sport == null || sport.isEmpty;
});
}
```
Così quando tocchi la card **Senza sport**, mostra solo attività con:
```text
sport null
```
oppure:
```text
sport vuoto
```
***
### 3. Converte in lista
```dart
final entries = activities.toList();
```
Questo prepara i dati per la UI.
***
### 4. Carica i preview points
Prende gli ID attività:
```dart
final ids = entries
.map((entry) => entry.id)
.whereType<int>()
.toSet();
```
Poi carica le miniature:
```dart
final previewPoints =
await localMediaDb.loadActivityPreviewPointsByActivityIds(ids);
```
***
### 5. Ritorna tutto insieme
```dart
return ActivityEntriesLoadResult(
entries: entries,
previewPointsByActivityId: previewPoints,
);
```
Quindi `activity_files_page.dart` riceve già:
```text
lista attività + preview pronte
```
***
## Metodo 2 — `loadSportGroups()`
Firma:
```dart
static Future<ActivityGroupsLoadResult<ActivitySportGroup>> loadSportGroups()
```
Viene usato quando tocchi il tab **Sport**.
### Cosa fa
### 1. Carica tutte le attività
```dart
final activities = await localMediaDb.loadActivities();
```
Qui non applica filtri, perché deve costruire tutti i gruppi sport.
***
### 2. Costruisce i gruppi sport
Chiama:
```dart
final groups = ActivityGrouping.buildSportGroups(activities);
```
Questa logica sta in:
```text
lib/model/activity/activity_grouping.dart
```
e produce una lista tipo:
```text
Corsa 12 attività
Bici 8 attività
Camminata 3 attività
Senza sport 1 attività
```
Ogni gruppo ha:
```dart
displayName
sportFilter
withoutSport
count
coverEntry
```
***
### 3. Carica preview della cover
Per ogni gruppo prende la `coverEntry`, cioè l’attività usata come thumbnail:
```dart
final ids = groups
.map((group) => group.coverEntry?.id)
.whereType<int>()
.toSet();
```
Poi carica preview points:
```dart
final previewPoints =
await localMediaDb.loadActivityPreviewPointsByActivityIds(ids);
```
***
### 4. Ritorna gruppi + preview
```dart
return ActivityGroupsLoadResult<ActivitySportGroup>(
groups: groups,
previewPointsByActivityId: previewPoints,
);
```
***
## Metodo 3 — `loadAlbumGroups()`
Firma:
```dart
static Future<ActivityGroupsLoadResult<ActivityAlbumGroup>> loadAlbumGroups()
```
Viene usato quando tocchi il tab **Album**.
### Cosa fa
### 1. Carica tutte le attività
```dart
final activities = await localMediaDb.loadActivities();
```
***
### 2. Costruisce i gruppi album
Chiama:
```dart
final groups = ActivityGrouping.buildAlbumGroups(activities);
```
Questa logica crea gruppi usando:
```dart
entry.directoryName
entry.relativePath
```
Quindi produce card tipo:
```text
Activity
main
Garmin
Zepp
Strava
```
Ogni gruppo ha:
```dart
displayName
directoryFilter
count
coverEntry
```
***
### 3. Carica preview della cover
Come per gli sport:
```dart
final ids = groups
.map((group) => group.coverEntry?.id)
.whereType<int>()
.toSet();
```
Poi:
```dart
final previewPoints =
await localMediaDb.loadActivityPreviewPointsByActivityIds(ids);
```
***
### 4. Ritorna gruppi + preview
```dart
return ActivityGroupsLoadResult<ActivityAlbumGroup>(
groups: groups,
previewPointsByActivityId: previewPoints,
);
```
***
# Come viene usato in `activity_files_page.dart`
Ora `_loadDbActivities()` è molto più pulito:
```dart
final result = await ActivityDataLoader.loadEntries(
favouriteOnly: _activityFavouriteOnly,
withoutSportOnly: _activityWithoutSportOnly,
sport: _activitySportFilter,
directory: _activityDirectoryFilter,
);
```
Poi aggiorna lo state:
```dart
_dbActivities
..clear()
..addAll(result.entries);
_dbPreviewPoints
..clear()
..addAll(result.previewPointsByActivityId);
```
***
Per Sport:
```dart
final result = await ActivityDataLoader.loadSportGroups();
```
Poi:
```dart
_sportGroups
..clear()
..addAll(result.groups);
_dbPreviewPoints
..clear()
..addAll(result.previewPointsByActivityId);
```
***
Per Album:
```dart
final result = await ActivityDataLoader.loadAlbumGroups();
```
Poi:
```dart
_albumGroups
..clear()
..addAll(result.groups);
_dbPreviewPoints
..clear()
..addAll(result.previewPointsByActivityId);
```
***
# In sintesi breve
`activity_data_loader.dart` è il file che dice:
```text
"Dammi i dati pronti da mostrare nella UI Activity"
```
Fa da ponte tra:
```text
DB / grouping / preview points
```
e:
```text
ActivityFilesPage / UI
```
***
## Responsabilità attuale
| Metodo | Cosa carica | Usato da |
| ------------------- | --------------------------------- | ------------------------------------------------------------- |
| `loadEntries(...)` | Lista attività filtrata + preview | tab Attività, Preferiti, Sport selezionato, Album selezionato |
| `loadSportGroups()` | Gruppi sport + cover preview | tab Sport |
| `loadAlbumGroups()` | Gruppi album + cover preview | tab Album |
***
## Perché è utile
Perché adesso `activity_files_page.dart` non deve più sapere **come**:
```text
caricare attività
filtrare senza sport
costruire gruppi
recuperare preview
```
Sa solo:
```dart
final result = await ActivityDataLoader.loadEntries(...);
```
Quindi sarà più facile in futuro:
* aggiungere ordinamenti;
* aggiungere statistiche per gruppo;
* aggiungere cache;
* aggiungere paginazione;
* cambiare logica DB;
* trasformare la lista in griglia senza toccare il caricamento dati.

37
activity_status.md Normal file
View file

@ -0,0 +1,37 @@
✓ ActivityOrigins
✓ ActivityEntry.origin
✓ ActivityEntry.toMap origin
✓ ActivityEntry.fromMap origin
✓ ActivityEntry.copyWith origin
✓ LocalMediaDb.loadActivities(origin:)
✓ SqfliteLocalMediaDb.loadActivities(origin:)
✓ activity table origin INTEGER NOT NULL DEFAULT 0
✓ guardia ALTER TABLE per DB esistenti
✓ indice idx_activity_origin
✓ Dettagli DB attività origin 0
✓ folders.content_type
✓ FolderContentType.media/activity
✓ ActivityEntry.origin
✓ activity.origin nel DB
✓ activity_folder_stats.dart
✓ scan Activity aggiorna folders content_type = activity
content_type = 1
lib/widgets/activity/
├── activity_files_page.dart
├── activity_auto_sync_controller.dart
├── activity_map_page.dart
├── activity_list_tile.dart
├── activity_empty_view.dart
├── activity_sport_groups_view.dart
├── activity_album_groups_view.dart
├── preview/
│ ├── activity_entry_preview.dart
│ └── activity_track_preview.dart
├── dialogs/
│ ├── activity_rename_dialog.dart
│ ├── activity_db_fields_dialog.dart
│ └── activity_recalculate_dialog.dart
└── utils/
└── activity_formatters.dart

29
album.md Normal file
View file

@ -0,0 +1,29 @@
lib/widgets/filter_grids/albums_page.dart (pagina album)
lib/widgets/collection/album_controller.dart (Controller album DB-based)
lib/widgets/collection/album_actions.dart (Azioni sugli album / apertura album)
lib/widgets/collection/album_tile.dart (Tile/visualizzazione album)
lib/widgets/collection/album_sort.dart (Sort album folder-based)
lib/widgets/filter_grids/common/action_delegates/album_set.dart (le azioni)
lib/widgets/home/home_page.dart (Build iniziale folder DB)
home_page.dart
→ localMediaDb.buildFoldersInMemory(...)
→ localMediaDb.saveFoldersBatch(...)
→ source.folderMap
albums_page.dart
→ legge source.folderMap
→ crea StoredAlbumFilter compatibili Aves
album_actions.dart
→ apre CollectionPage usando folderPath / album.path
DB folders / source.folderMap / FolderStats
le action legacy
lib/widgets/filter_grids/common/action_delegates/album_set.dart
lib/widgets/filter_grids/common/action_delegates/chip_set.dart
lib/widgets/filter_grids/common/action_delegates/chip.dart
lib/widgets/filter_grids/common/filter_nav_page.dart

47
analysis_options.yaml Normal file
View file

@ -0,0 +1,47 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/generated_plugin_registrant.dart
# strong-mode:
# implicit-casts: false
# implicit-dynamic: false
# cf https://github.com/dart-lang/dart_style/wiki/Configuration
formatter:
page_width: 240
trailing_commas: preserve
linter:
rules:
# from 'flutter_lints', excluded
use_build_context_synchronously: false # no alternative
# from 'lints / recommended', excluded
no_leading_underscores_for_local_identifiers: false # useful for null checked variable variants
# from 'effective dart', excluded
avoid_classes_with_only_static_members: false # too strict
avoid_function_literals_in_foreach_calls: false # benefit?
lines_longer_than_80_chars: false # nope
public_member_api_docs: false # this project is not a library
# from 'effective dart', undecided
prefer_relative_imports: false # check IDE support (auto import, file move)
# from 'effective dart', included
avoid_types_on_closure_parameters: true
prefer_interpolation_to_compose_strings: true
unnecessary_lambdas: true
# from 'pedantic', included
always_declare_return_types: true
prefer_single_quotes: true
sort_child_properties_last: true
unawaited_futures: true
# `const` related, included
prefer_const_constructors: true
prefer_const_literals_to_create_immutables: true
prefer_const_declarations: true

339
analyze_first_moof.py Normal file
View file

@ -0,0 +1,339 @@
from pathlib import Path
import struct
data = Path("first_moof.bin").read_bytes()
MDAT_ABSOLUTE_OFFSET = 4848
MDAT_HEADER_SIZE = 8
def uint32(offset):
return struct.unpack_from(">I", data, offset)[0]
def uint64(offset):
return struct.unpack_from(">Q", data, offset)[0]
def int32(offset):
return struct.unpack_from(">i", data, offset)[0]
def box_header(offset):
size32 = uint32(offset)
box_type = data[offset + 4:offset + 8].decode(
"latin-1",
errors="replace",
)
if size32 == 1:
size = uint64(offset + 8)
header_size = 16
elif size32 == 0:
size = len(data) - offset
header_size = 8
else:
size = size32
header_size = 8
return (
box_type,
size,
header_size,
offset + size,
)
def children(start, end):
offset = start
while offset + 8 <= end:
box_type, size, header_size, box_end = box_header(
offset,
)
if size < header_size or box_end > end:
raise RuntimeError(
"Box non valido: "
f"type={box_type} "
f"offset={offset} "
f"size={size}"
)
yield (
box_type,
offset,
size,
header_size,
box_end,
)
offset = box_end
if offset != end:
raise RuntimeError(
f"Byte residui nel container: offset={offset} end={end}"
)
moof_type, moof_size, moof_header, moof_end = box_header(0)
assert moof_type == "moof"
assert moof_size == len(data)
print(f"moofSize={moof_size}")
for traf_index, box in enumerate(
children(moof_header, moof_end)
):
(
box_type,
traf_offset,
traf_size,
traf_header,
traf_end,
) = box
if box_type != "traf":
continue
tfhd_info = None
trun_info = None
for child in children(
traf_offset + traf_header,
traf_end,
):
(
child_type,
child_offset,
child_size,
child_header,
child_end,
) = child
payload_offset = child_offset + child_header
if payload_offset + 4 > child_end:
raise RuntimeError(
f"FullBox incompleto: {child_type}"
)
version = data[payload_offset]
flags = int.from_bytes(
data[payload_offset + 1:payload_offset + 4],
"big",
)
cursor = payload_offset + 4
if child_type == "tfhd":
track_id = uint32(cursor)
cursor += 4
base_data_offset = None
default_sample_duration = None
default_sample_size = None
default_sample_flags = None
if flags & 0x000001:
base_data_offset = uint64(cursor)
cursor += 8
if flags & 0x000002:
cursor += 4
if flags & 0x000008:
default_sample_duration = uint32(cursor)
cursor += 4
if flags & 0x000010:
default_sample_size = uint32(cursor)
cursor += 4
if flags & 0x000020:
default_sample_flags = uint32(cursor)
cursor += 4
tfhd_info = {
"track_id": track_id,
"base_data_offset": base_data_offset,
"default_sample_duration": default_sample_duration,
"default_sample_size": default_sample_size,
"default_sample_flags": default_sample_flags,
}
elif child_type == "trun":
sample_count = uint32(cursor)
cursor += 4
data_offset = None
first_sample_flags = None
if flags & 0x000001:
data_offset = int32(cursor)
cursor += 4
if flags & 0x000004:
first_sample_flags = uint32(cursor)
cursor += 4
samples = []
for sample_index in range(sample_count):
duration = None
size = None
sample_flags = None
composition_offset = 0
if flags & 0x000100:
duration = uint32(cursor)
cursor += 4
if flags & 0x000200:
size = uint32(cursor)
cursor += 4
if flags & 0x000400:
sample_flags = uint32(cursor)
cursor += 4
if flags & 0x000800:
if version == 0:
composition_offset = uint32(cursor)
else:
composition_offset = int32(cursor)
cursor += 4
samples.append(
{
"index": sample_index,
"duration": duration,
"size": size,
"flags": sample_flags,
"composition_offset": composition_offset,
}
)
trun_info = {
"version": version,
"flags": flags,
"sample_count": sample_count,
"data_offset": data_offset,
"first_sample_flags": first_sample_flags,
"samples": samples,
"parsed_end": cursor,
"box_end": child_end,
}
if tfhd_info is None or trun_info is None:
raise RuntimeError(
f"traf incompleto: index={traf_index}"
)
track_id = tfhd_info["track_id"]
samples = trun_info["samples"]
durations = [sample["duration"] for sample in samples]
sizes = [sample["size"] for sample in samples]
flag_values = [sample["flags"] for sample in samples]
if any(value is None for value in durations):
raise RuntimeError(
f"Durata sample assente nel track {track_id}"
)
if any(value is None for value in sizes):
raise RuntimeError(
f"Dimensione sample assente nel track {track_id}"
)
base_data_offset = tfhd_info["base_data_offset"]
data_offset = trun_info["data_offset"]
if base_data_offset is None or data_offset is None:
first_sample_absolute_offset = None
else:
first_sample_absolute_offset = (
base_data_offset + data_offset
)
non_sync_count = sum(
1
for value in flag_values
if value is not None
and (value & 0x00010000) != 0
)
sync_count = len(samples) - non_sync_count
expected_mdat_payload_offset = (
MDAT_ABSOLUTE_OFFSET + MDAT_HEADER_SIZE
)
print()
print(f"=== TRAF {traf_index} ===")
print(f"trackId={track_id}")
print(
f"baseDataOffset={base_data_offset}"
)
print(f"dataOffset={data_offset}")
print(
"firstSampleAbsoluteOffset="
f"{first_sample_absolute_offset}"
)
print(
"expectedMdatPayloadOffset="
f"{expected_mdat_payload_offset}"
)
print(f"sampleCount={len(samples)}")
print(f"totalDuration={sum(durations)}")
print(f"minimumDuration={min(durations)}")
print(f"maximumDuration={max(durations)}")
print(f"totalSampleBytes={sum(sizes)}")
print(f"minimumSampleSize={min(sizes)}")
print(f"maximumSampleSize={max(sizes)}")
print(f"syncSamples={sync_count}")
print(f"nonSyncSamples={non_sync_count}")
print(
"trunParsedExactly="
f"{trun_info['parsed_end'] == trun_info['box_end']}"
)
print("firstSamples:")
decode_time = 0
running_offset = first_sample_absolute_offset
for sample in samples[:8]:
flags_value = sample["flags"]
is_non_sync = (
flags_value is not None
and (flags_value & 0x00010000) != 0
)
flags_text = (
f"0x{flags_value:08x}"
if flags_value is not None
else "None"
)
print(
f" index={sample['index']} "
f"offset={running_offset} "
f"size={sample['size']} "
f"duration={sample['duration']} "
f"dts={decode_time} "
f"pts={decode_time + sample['composition_offset']} "
f"flags={flags_text} "
f"sync={not is_non_sync}"
)
if running_offset is not None:
running_offset += sample["size"]
decode_time += sample["duration"]

16
android/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
.kotlin/
/build/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View file

@ -0,0 +1,82 @@
{
"agcgw_all":{
"CN":"connect-drcn.dbankcloud.cn",
"CN_back":"connect-drcn.hispace.hicloud.com",
"DE":"connect-dre.dbankcloud.cn",
"DE_back":"connect-dre.hispace.hicloud.com",
"RU":"connect-drru.hispace.dbankcloud.ru",
"RU_back":"connect-drru.hispace.dbankcloud.cn",
"SG":"connect-dra.dbankcloud.cn",
"SG_back":"connect-dra.hispace.hicloud.com"
},
"websocketgw_all":{
"CN":"connect-ws-drcn.hispace.dbankcloud.cn",
"CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
"DE":"connect-ws-dre.hispace.dbankcloud.cn",
"DE_back":"connect-ws-dre.hispace.dbankcloud.com",
"RU":"connect-ws-drru.hispace.dbankcloud.ru",
"RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
"SG":"connect-ws-dra.hispace.dbankcloud.cn",
"SG_back":"connect-ws-dra.hispace.dbankcloud.com"
},
"client":{
"cp_id":"2640082000020010713",
"product_id":"99536292102197525",
"project_id":"99536292102197525",
"app_id":"106014023",
"package_name":"deckers.thibault.aves"
},
"oauth_client":{
"client_id":"106014023",
"client_type":1
},
"app_info":{
"app_id":"106014023",
"package_name":"deckers.thibault.aves"
},
"configuration_version":"3.0",
"appInfos":[
{
"package_name":"deckers.thibault.aves",
"client":{
"app_id":"106014023"
},
"app_info":{
"package_name":"deckers.thibault.aves",
"app_id":"106014023"
},
"oauth_client":{
"client_type":1,
"client_id":"106014023"
}
},
{
"package_name":"deckers.thibault.aves.debug",
"client":{
"app_id":"106014297"
},
"app_info":{
"package_name":"deckers.thibault.aves.debug",
"app_id":"106014297"
},
"oauth_client":{
"client_type":1,
"client_id":"106014297"
}
},
{
"package_name":"deckers.thibault.aves.profile",
"client":{
"app_id":"106031461"
},
"app_info":{
"package_name":"deckers.thibault.aves.profile",
"app_id":"106031461"
},
"oauth_client":{
"client_type":1,
"client_id":"106031461"
}
}
]
}

View file

@ -0,0 +1,255 @@
import com.android.build.gradle.internal.api.ApkVariantOutputImpl
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.ksp)
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
}
val packageName = "deckers.thibault.aves"
// Keys
val keystoreProperties = Properties()
val keystorePropertiesFile: File = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
println("Load keystore props from file=$keystorePropertiesFile")
// for release using credentials stored in a local file
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
} else {
println("Load keystore props from system environment")
// for release using credentials in environment variables set up by GitHub Actions
// warning: in property file, single quotes should be escaped with a backslash
// but they should not be escaped when stored in env variables
val env = System.getenv()
fun getEnv(propKey: String, envKey: String) {
if (envKey in env) {
keystoreProperties[propKey] = env[envKey]
}
}
getEnv("storeFile", "AVES_STORE_FILE")
getEnv("storePassword", "AVES_STORE_PASSWORD")
getEnv("keyAlias", "AVES_KEY_ALIAS")
getEnv("keyPassword", "AVES_KEY_PASSWORD")
getEnv("googleApiKey", "AVES_GOOGLE_API_KEY")
}
android {
namespace = "deckers.thibault.aves"
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
// enable support for the new language APIs on older devices
// e.g. `java/util/function/Supplier` on Android 5.0 (API 21)
isCoreLibraryDesugaringEnabled = true
}
kotlin {
jvmToolchain(17)
}
defaultConfig {
applicationId = packageName
minSdk = flutter.minSdkVersion
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders["googleApiKey"] = keystoreProperties["googleApiKey"] ?: "<NONE>"
multiDexEnabled = true
}
signingConfigs {
val storeFilePath = keystoreProperties["storeFile"] as String?
if (storeFilePath != null) {
println("Create signing config for release using file=$storeFilePath")
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(storeFilePath)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
flavorDimensions += "store"
productFlavors {
create("play") {
// Google Play
dimension = "store"
}
create("izzy") {
// IzzyOnDroid
// check offending libraries with `scanapk`
// cf https://android.izzysoft.de/articles/named/app-modules-2
dimension = "store"
}
create("libre") {
// F-Droid
// check offending libraries with `fdroidserver`
// cf https://f-droid.org/en/docs/Submitting_to_F-Droid_Quick_Start_Guide/
dimension = "store"
applicationIdSuffix = ".libre"
}
create("libre_rom") {
// integration in custom ROM
dimension = "store"
applicationIdSuffix = ".libre"
packaging {
// disable compression for native libraries (.so files)
jniLibs.useLegacyPackaging = false
}
}
}
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
}
getByName("profile") {
// applicationIdSuffix = ".profile"
}
getByName("release") {
if (signingConfigs.names.contains("release")) {
signingConfig = signingConfigs.getByName("release")
} else {
println("Skip release signing as it is not configured")
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// NDK ABI filters are incompatible with split APK generation,
// but filters are necessary to exclude x86 native libs from universal APKs
// cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500
var useNdkAbiFilters = true
if (rootProject.extra.has("split-per-abi")) {
val splitPerAbi = rootProject.extra["split-per-abi"]
if (splitPerAbi == "true" || splitPerAbi == true) {
useNdkAbiFilters = false
}
}
if (useNdkAbiFilters) {
ndk {
//noinspection ChromeOsAbiSupport
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
}
}
}
val abiCodes = mapOf(
"armeabi-v7a" to 1,
"arm64-v8a" to 2,
"x86" to 3,
"x86_64" to 4
)
applicationVariants.all {
println("Application variant applicationId=$applicationId name=$name")
resValue(
"string",
"screen_saver_settings_activity",
"${applicationId}/${packageName}.ScreenSaverSettingsActivity"
)
resValue("string", "search_provider", "${applicationId}.search_provider")
outputs.forEach { output ->
val abi = output.filters.find { it.filterType == "ABI" }?.identifier
val baseAbiVersionCode = abiCodes[abi]
if (baseAbiVersionCode != null) {
val versionCodeOverride = versionCode * 100 + baseAbiVersionCode
println(" output versionCodeOverride=$versionCodeOverride for abi=$abi")
(output as ApkVariantOutputImpl).versionCodeOverride = versionCodeOverride
}
}
}
}
}
flutter {
source = "../.."
}
repositories {
maven {
url = uri("https://jitpack.io")
content {
includeGroup("com.github.deckerst")
includeGroup("com.github.deckerst.mp4parser")
}
}
maven {
url = uri("https://s3.amazonaws.com/repo.commonsware.com")
content {
excludeGroupByRegex("com\\.github\\.deckerst.*")
}
}
}
dependencies {
// cf https://developer.android.com/studio/write/java8-support#library-desugaring
coreLibraryDesugaring(libs.android.desugarJdkLibs)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.ktx)
implementation(libs.androidx.lifecycle)
implementation(libs.androidx.media)
implementation(libs.androidx.multidex)
// Jetpack `security-crypto` library is deprecated:
// https://developer.android.com/privacy-and-security/cryptography#security-crypto-jetpack-deprecated
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.work.runtime)
implementation(libs.commonsware.cwac)
implementation(libs.metadata.extractor)
implementation(libs.glide)
implementation(libs.google.material)
// SLF4J implementation for `mp4parser`
implementation(libs.slf4j)
// forked, built by JitPack:
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
// - https://jitpack.io/p/deckerst/androidsvg
// - https://jitpack.io/p/deckerst/mp4parser
// - https://jitpack.io/p/deckerst/pixymeta-android
implementation(libs.deckerst.tiffbitmapfactory)
implementation(libs.deckerst.androidsvg)
implementation(libs.deckerst.mp4parser.isoparser)
implementation(libs.deckerst.mp4parser.muxer)
implementation(libs.deckerst.pixymeta)
implementation(project(":exifinterface"))
// ⭐ Cast dependencies per MainActivity
// implementation("com.google.android.gms:play-services-cast-framework:21.4.0")
// implementation("androidx.mediarouter:mediarouter:1.6.0")
testImplementation(libs.junit)
ksp(libs.glideKsp)
compileOnly(rootProject.findProject(":streams_channel")!!)
}
if (rootProject.extra["aves.useCrashlytics"] as Boolean) {
println("Building flavor with Crashlytics plugin")
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
} else {
println("Building flavor without reporting plugin")
}

6
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,6 @@
-keep class org.beyka.tiffbitmapfactory.**{ *; }
-keep class org.mp4parser.**{ *; }
# referenced from: com.google.crypto.tink
-dontwarn com.google.errorprone.annotations.**
-dontwarn javax.annotation.**

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|mediaPlayback|mediaProcessing"
tools:node="replace" />
</application>
</manifest>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_flavour">#815AFA</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves [Debug]</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves Libre</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves Libre [Debug]</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves Libre [Profile]</string>
</resources>

View file

@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- Required for WorkManager foreground workers -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" android:maxSdkVersion="34" tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING" tools:ignore="SystemPermissionTypo" />
<!-- Cast playback service permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" android:maxSdkVersion="25" />
<uses-sdk tools:overrideLibrary="com.arthenica.ffmpegkit.flutter" />
<queries>
<intent><action android:name="android.intent.action.MAIN" /></intent>
<intent><action android:name="android.intent.action.EDIT" /><data android:mimeType="image/*" /></intent>
<intent><action android:name="android.intent.action.EDIT" /><data android:mimeType="video/*" /></intent>
<intent><action android:name="android.intent.action.VIEW" /><data android:scheme="https" /></intent>
</queries>
<application
android:allowBackup="true"
android:appCategory="image"
android:banner="@drawable/banner"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/full_backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
tools:targetApi="33">
<!-- ⭐ REQUIRED FOR ANDROID 14/15 FOREGROUND WORKER -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|mediaPlayback|mediaProcessing"
tools:node="replace" />
<!-- ⭐ PATCH: ORA PUNTA AL TUO PLUGIN MINIMALE -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.aves.google_cast.CastOptionsProvider" />
<!-- Cast media notification service -->
<service
android:name="com.google.android.gms.cast.framework.media.MediaNotificationService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:taskAffinity=""
android:theme="@style/NormalTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.APP_GALLERY" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.GET_CONTENT" />
<action android:name="android.intent.action.PICK" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<action android:name="android.provider.action.PICK_IMAGES" />
<action android:name="android.provider.action.REVIEW" />
<action android:name="android.provider.action.REVIEW_SECURE" />
<action android:name="com.android.camera.action.REVIEW" />
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.GET_CONTENT" />
<action android:name="android.intent.action.PICK" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<action android:name="android.provider.action.PICK_IMAGES" />
<action android:name="android.provider.action.REVIEW" />
<action android:name="android.provider.action.REVIEW_SECURE" />
<action android:name="com.android.camera.action.REVIEW" />
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
<data android:scheme="content" />
<data android:scheme="file" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geo" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable" />
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="maximize" />
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
</activity>
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${googleApiKey}" />
<meta-data android:name="firebase_crashlytics_collection_enabled" android:value="false" />
<meta-data android:name="flutterEmbedding" android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,162 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- Required for WorkManager foreground workers -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" android:maxSdkVersion="34" tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING" tools:ignore="SystemPermissionTypo" />
<!-- Cast playback service permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" android:maxSdkVersion="25" />
<uses-sdk tools:overrideLibrary="com.arthenica.ffmpegkit.flutter" />
<queries>
<intent><action android:name="android.intent.action.MAIN" /></intent>
<intent><action android:name="android.intent.action.EDIT" /><data android:mimeType="image/*" /></intent>
<intent><action android:name="android.intent.action.EDIT" /><data android:mimeType="video/*" /></intent>
<intent><action android:name="android.intent.action.VIEW" /><data android:scheme="https" /></intent>
</queries>
<application
android:allowBackup="true"
android:appCategory="image"
android:banner="@drawable/banner"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/full_backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
tools:targetApi="33">
<!-- ⭐ REQUIRED FOR ANDROID 14/15 FOREGROUND WORKER -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- ⭐ PATCH: ORA PUNTA AL TUO PLUGIN MINIMALE -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.aves.google_cast.CastOptionsProvider" />
<!-- Cast media notification service -->
<service
android:name="com.google.android.gms.cast.framework.media.MediaNotificationService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:taskAffinity=""
android:theme="@style/NormalTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.APP_GALLERY" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.GET_CONTENT" />
<action android:name="android.intent.action.PICK" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<action android:name="android.provider.action.PICK_IMAGES" />
<action android:name="android.provider.action.REVIEW" />
<action android:name="android.provider.action.REVIEW_SECURE" />
<action android:name="com.android.camera.action.REVIEW" />
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.GET_CONTENT" />
<action android:name="android.intent.action.PICK" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<action android:name="android.provider.action.PICK_IMAGES" />
<action android:name="android.provider.action.REVIEW" />
<action android:name="android.provider.action.REVIEW_SECURE" />
<action android:name="com.android.camera.action.REVIEW" />
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
<data android:scheme="content" />
<data android:scheme="file" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geo" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable" />
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="maximize" />
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
</activity>
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${googleApiKey}" />
<meta-data android:name="firebase_crashlytics_collection_enabled" android:value="false" />
<meta-data android:name="flutterEmbedding" android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,173 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- Required for WorkManager foreground workers -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" android:maxSdkVersion="34" tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING" tools:ignore="SystemPermissionTypo" />
<!-- Cast playback service permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" android:maxSdkVersion="25" />
<!-- Required for DLNA multicast discovery -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-sdk tools:overrideLibrary="com.arthenica.ffmpegkit.flutter" />
<queries>
<intent><action android:name="android.intent.action.MAIN" /></intent>
<intent><action android:name="android.intent.action.EDIT" /><data android:mimeType="image/*" /></intent>
<intent><action android:name="android.intent.action.EDIT" /><data android:mimeType="video/*" /></intent>
<intent><action android:name="android.intent.action.VIEW" /><data android:scheme="https" /></intent>
</queries>
<application
android:allowBackup="true"
android:appCategory="image"
android:banner="@drawable/banner"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/full_backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
tools:targetApi="33">
<meta-data
android:name="io.flutter.embedding.android.EnableTextureView"
android:value="true" />
<!-- ⭐ REQUIRED FOR ANDROID 14/15 FOREGROUND WORKER -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- ⭐ PATCH: ORA PUNTA AL TUO PLUGIN MINIMALE -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.aves.google_cast.CastOptionsProvider" />
<!-- Cast media notification service -->
<service
android:name="com.google.android.gms.cast.framework.media.MediaNotificationService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:taskAffinity=""
android:theme="@style/NormalTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.APP_GALLERY" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.GET_CONTENT" />
<action android:name="android.intent.action.PICK" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<action android:name="android.provider.action.PICK_IMAGES" />
<action android:name="android.provider.action.REVIEW" />
<action android:name="android.provider.action.REVIEW_SECURE" />
<action android:name="com.android.camera.action.REVIEW" />
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.GET_CONTENT" />
<action android:name="android.intent.action.PICK" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<action android:name="android.provider.action.PICK_IMAGES" />
<action android:name="android.provider.action.REVIEW" />
<action android:name="android.provider.action.REVIEW_SECURE" />
<action android:name="com.android.camera.action.REVIEW" />
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" />
<data android:scheme="content" />
<data android:scheme="file" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geo" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable" />
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="maximize" />
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
</activity>
<activity
android:name="com.aves.custom_cast.CastActivity"
android:exported="false"
android:theme="@style/Theme.Transparent" />
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${googleApiKey}" />
<meta-data android:name="firebase_crashlytics_collection_enabled" android:value="false" />
<meta-data android:name="flutterEmbedding" android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,230 @@
package deckers.thibault.aves
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.GeocodingHandler
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.calls.StorageHandler
import deckers.thibault.aves.channel.streams.darttoplatform.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.darttoplatform.MediaStoreStreamHandler
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class AnalysisWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var workCont: CancellableContinuation<Any?>? = null
private var flutterEngine: FlutterEngine? = null
private var backgroundChannel: MethodChannel? = null
override suspend fun doWork(): Result {
Log.i(LOG_TAG, "Start analysis worker $id")
createNotificationChannel()
val foregroundInfo = createForegroundInfo()
if (!isStopped) {
setForeground(foregroundInfo)
suspendCancellableCoroutine { cont ->
workCont = cont
cont.invokeOnCancellation {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val stopReasonString = when (stopReason) {
WorkInfo.STOP_REASON_CANCELLED_BY_APP -> "CANCELLED_BY_APP"
WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT -> "FOREGROUND_SERVICE_TIMEOUT"
else -> "[$stopReason]"
}
Log.i(LOG_TAG, "Analysis worker got cancelled with stopReason=$stopReasonString")
} else {
Log.i(LOG_TAG, "Analysis worker got cancelled")
}
stopDartAnalysisService()
workCont?.resumeWithException(CancellationException())
}
onStart()
}
dispose()
}
return Result.success()
}
private suspend fun dispose() {
Log.i(LOG_TAG, "Clean analysis worker $id")
flutterEngine?.let {
FlutterUtils.runOnUiThread {
it.destroy()
}
flutterEngine = null
}
}
private fun onStart() {
runBlocking {
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, PREF_CALLBACK_HANDLE_KEY) {
flutterEngine = it
}
}
try {
initChannels(applicationContext)
val preferences = applicationContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
val entryIdStrings = preferences.getStringSet(PREF_ENTRY_IDS_KEY, null)
startDartAnalysisService(entryIdStrings)
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to initialize worker", e)
workCont?.resumeWithException(e)
}
}
private fun initChannels(context: Context) {
val engine = flutterEngine
engine ?: throw Exception("Flutter engine is not initialized")
val messenger = engine.dartExecutor
// channels for analysis
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(context))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(context))
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(context))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(context))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(context))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(context))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(context, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(context, args) }
// channel for service management
backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply {
setMethodCallHandler { call, result -> onMethodCall(call, result) }
}
}
private fun startDartAnalysisService(entryIdStrings: Set<String>?) {
runBlocking {
FlutterUtils.runOnUiThread {
backgroundChannel?.invokeMethod(
"start", hashMapOf(
"entryIds" to entryIdStrings?.map { Integer.parseUnsignedInt(it) }?.toList(),
"force" to inputData.getBoolean(KEY_FORCE, false),
)
)
}
}
}
private fun stopDartAnalysisService() {
runBlocking {
FlutterUtils.runOnUiThread {
backgroundChannel?.invokeMethod("stop", null)
}
}
}
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "Analysis background channel is ready")
result.success(null)
}
"updateNotification" -> defaultScope.launch { safeSuspend(call, result, ::updateNotification) }
"stop" -> {
workCont?.takeIf { it.isActive }?.resume(null)
result.success(null)
}
else -> result.notImplemented()
}
}
private fun createNotificationChannel() {
val channel = NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(applicationContext.getText(R.string.analysis_channel_name))
.setShowBadge(false)
.build()
NotificationManagerCompat.from(applicationContext).createNotificationChannel(channel)
}
private fun createForegroundInfo(title: String? = null, message: String? = null): ForegroundInfo {
val pendingIntentFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
val openAppIntent = Intent(applicationContext, MainActivity::class.java).let {
PendingIntent.getActivity(applicationContext, MainActivity.OPEN_FROM_ANALYSIS_SERVICE, it, pendingIntentFlags)
}
val stopAction = NotificationCompat.Action.Builder(
R.drawable.ic_outline_stop_24,
applicationContext.getString(R.string.analysis_notification_action_stop),
WorkManager.getInstance(applicationContext).createCancelPendingIntent(id)
).build()
val contentTitle = title ?: applicationContext.getText(R.string.analysis_notification_default_title)
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL)
.setContentTitle(contentTitle)
.setTicker(contentTitle)
.setContentText(message)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setContentIntent(openAppIntent)
.addAction(stopAction)
.build()
// from Android 14 (API 34), foreground service type is mandatory for long-running workers:
// https://developer.android.com/guide/background/persistent/how-to/long-running
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else -> ForegroundInfo(NOTIFICATION_ID, notification)
}
}
private suspend fun updateNotification(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val message = call.argument<String>("message")
setForeground(createForegroundInfo(title, message))
result.success(null)
}
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
const val SHARED_PREFERENCES_KEY = "analysis_service"
const val PREF_CALLBACK_HANDLE_KEY = "callback_handle"
const val PREF_ENTRY_IDS_KEY = "entry_ids"
const val NOTIFICATION_CHANNEL = "analysis"
const val NOTIFICATION_ID = 1
const val KEY_FORCE = "force"
}
}

View file

@ -0,0 +1,63 @@
package deckers.thibault.aves
import android.content.ContentUris
import android.content.Context
import android.provider.MediaStore
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class EntryPathChannel(private val context: Context) {
fun register(flutterEngine: FlutterEngine) {
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"aves/entry_path"
).setMethodCallHandler { call, result ->
when (call.method) {
"getEntryPath" -> {
// Accetta sia int che string
val rawId: Any? = call.argument<Any>("id")
val id = when (rawId) {
is Number -> rawId.toLong()
is String -> rawId.toLongOrNull()
else -> null
}
if (id == null) {
result.error("INVALID_ID", "ID non valido: $rawId", null)
return@setMethodCallHandler
}
val path = getPathForMediaStoreId(id)
if (path != null) {
result.success(path)
} else {
result.error("NOT_FOUND", "Nessun file per ID=$id", null)
}
}
else -> result.notImplemented()
}
}
}
private fun getPathForMediaStoreId(id: Long): String? {
val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri("external"),
id
)
val projection = arrayOf(MediaStore.Files.FileColumns.DATA)
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA)
if (idx >= 0) {
return cursor.getString(idx)
}
}
}
return null
}
}

View file

@ -0,0 +1,67 @@
package deckers.thibault.aves
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import deckers.thibault.aves.model.FieldMap
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class HomeWidgetSettingsActivity : MainActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// cancel if user does not complete widget setup
setResult(RESULT_CANCELED)
intent.extras?.let {
appWidgetId = it.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
intentDataMap = extractIntentData(intent)
}
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"configure" -> {
result.success(null)
saveWidget()
}
else -> result.notImplemented()
}
}
}
private fun saveWidget() {
val appWidgetManager = AppWidgetManager.getInstance(this)
val widgetInfo = appWidgetManager.getAppWidgetOptions(appWidgetId)
HomeWidgetProvider().onAppWidgetOptionsChanged(this, appWidgetManager, appWidgetId, widgetInfo)
val intent = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, intent)
finish()
}
override fun extractIntentData(intent: Intent?): FieldMap {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_WIDGET_SETTINGS,
INTENT_DATA_KEY_WIDGET_ID to appWidgetId,
)
}
companion object {
private const val CHANNEL = "deckers.thibault/aves/widget_configure"
}
}

View file

@ -0,0 +1,324 @@
package deckers.thibault.aves
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.SizeF
import android.widget.RemoteViews
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.StorageHandler
import deckers.thibault.aves.channel.streams.darttoplatform.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.darttoplatform.MediaStoreStreamHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.ContextUtils.devicePixelRatio
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.nio.ByteBuffer
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt
class HomeWidgetProvider : AppWidgetProvider() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d(LOG_TAG, "Widget onUpdate widgetIds=${appWidgetIds.contentToString()}")
val pendingResult = goAsync()
defaultScope.launch {
for (widgetId in appWidgetIds) {
val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId)
val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, backgroundProps)
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
updateWidgetImage(context, appWidgetManager, widgetId, imageProps)
}
try {
pendingResult?.finish()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to finish update for widgetIds=${appWidgetIds.contentToString()}", e)
}
}
}
override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager?, widgetId: Int, widgetInfo: Bundle?) {
Log.d(LOG_TAG, "Widget onAppWidgetOptionsChanged widgetId=$widgetId")
appWidgetManager ?: return
widgetInfo ?: return
if (imageByteFetchJob != null) {
imageByteFetchJob?.cancel()
}
imageByteFetchJob = defaultScope.launch {
delay(500)
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
updateWidgetImage(context, appWidgetManager, widgetId, imageProps)
}
}
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<SizeF> {
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@Suppress("deprecation")
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES)
} else {
null
}
if (sizes.isNullOrEmpty()) {
val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
val widthKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH else AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH
val heightKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT else AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT
val widthDip = widgetInfo.getInt(widthKey)
val heightDip = widgetInfo.getInt(heightKey)
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
}
return sizes
}
private suspend fun getProps(
context: Context,
widgetId: Int,
widgetInfo: Bundle,
drawEntryImage: Boolean,
reuseEntry: Boolean = false,
): FieldMap? {
val sizesDip = getWidgetSizesDip(context, widgetInfo)
if (sizesDip.isEmpty()) return null
val sizeDip = sizesDip.first()
if (sizeDip.width == 0f || sizeDip.height == 0f) return null
val sizesDipMap = sizesDip.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val params = hashMapOf(
"widgetId" to widgetId,
"sizesDip" to sizesDipMap,
"devicePixelRatio" to context.devicePixelRatio(),
"drawEntryImage" to drawEntryImage,
"reuseEntry" to reuseEntry,
"isSystemThemeDark" to isNightModeOn,
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
put("cornerRadiusPx", context.resources.getDimension(android.R.dimen.system_app_widget_background_radius))
}
}
initFlutterEngine(context)
try {
val props = suspendCancellableCoroutine { cont ->
defaultScope.launch {
FlutterUtils.runOnUiThread {
tryDrawWidget(params, cont, 0)
}
}
}
@Suppress("unchecked_cast")
return props as FieldMap?
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId sizesPx=$sizesDip", e)
}
return null
}
private fun tryDrawWidget(params: HashMap<String, Any>, cont: Continuation<Any?>, drawRetry: Int) {
val messenger = flutterEngine!!.dartExecutor
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
channel.invokeMethod("drawWidget", params, object : MethodChannel.Result {
override fun success(result: Any?) {
cont.resume(result)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
}
override fun notImplemented() {
if (drawRetry > DRAW_RETRY_MAX) {
cont.resumeWithException(Exception("not implemented"))
} else {
Handler(Looper.getMainLooper()).postDelayed({
tryDrawWidget(params, cont, drawRetry + 1)
}, 2000L)
}
}
})
}
private fun updateWidgetImage(
context: Context,
appWidgetManager: AppWidgetManager,
widgetId: Int,
props: FieldMap?,
) {
props ?: return
val bytesBySizeDip = (props["bytesBySizeDip"] as List<*>?)?.mapNotNull {
if (it is Map<*, *>) {
val widthDip = (it["widthDip"] as Number?)?.toFloat()
val heightDip = (it["heightDip"] as Number?)?.toFloat()
val bytes = it["bytes"] as ByteArray?
if (widthDip != null && heightDip != null && bytes != null) {
Pair(SizeF(widthDip, heightDip), bytes)
} else null
} else null
}
val updateOnTap = props["updateOnTap"] as Boolean?
if (bytesBySizeDip == null || updateOnTap == null) {
Log.e(LOG_TAG, "missing arguments")
return
}
if (bytesBySizeDip.isEmpty()) {
Log.e(LOG_TAG, "empty image list")
return
}
val bitmaps = ArrayList<Bitmap>()
fun createRemoteViewsForSize(
context: Context,
widgetId: Int,
sizeDip: SizeF,
bytes: ByteArray,
updateOnTap: Boolean,
): RemoteViews? {
val density = context.devicePixelRatio()
val widthPx = (sizeDip.width * density).roundToInt()
val heightPx = (sizeDip.height * density).roundToInt()
try {
val bitmap = createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
bitmaps.add(it)
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
}
val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId)
return RemoteViews(context.packageName, R.layout.app_widget).apply {
setImageViewBitmap(R.id.widget_img, bitmap)
setOnClickPendingIntent(R.id.widget_img, pendingIntent)
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e)
}
return null
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// multiple rendering for all possible sizes
val views = RemoteViews(
bytesBySizeDip.associateBy(
{ (sizeDip, _) -> sizeDip },
{ (sizeDip, bytes) -> createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap) },
).filterValues { it != null }.mapValues { (_, view) -> view!! }
)
appWidgetManager.updateAppWidget(widgetId, views)
} else {
// single rendering
val (sizeDip, bytes) = bytesBySizeDip.first()
val views = createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap)
appWidgetManager.updateAppWidget(widgetId, views)
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e)
} finally {
bitmaps.forEach { it.recycle() }
bitmaps.clear()
}
}
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, "widget://$widgetId".toUri(), context, HomeWidgetProvider::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, "widget://$widgetId".toUri(), context, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
companion object {
private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>()
private const val WIDGET_DART_ENTRYPOINT = "widgetMain"
private const val WIDGET_DRAW_CHANNEL = "deckers.thibault/aves/widget_draw"
private const val DRAW_RETRY_MAX = 5
private var flutterEngine: FlutterEngine? = null
private var imageByteFetchJob: Job? = null
private suspend fun initFlutterEngine(context: Context) {
if (flutterEngine != null) return
FlutterUtils.runOnUiThread {
flutterEngine = FlutterEngine(context.applicationContext)
}
initChannels(context)
flutterEngine!!.apply {
if (!dartExecutor.isExecutingDart) {
val appBundlePathOverride = FlutterInjector.instance().flutterLoader().findAppBundlePath()
val entrypoint = DartExecutor.DartEntrypoint(appBundlePathOverride, WIDGET_DART_ENTRYPOINT)
FlutterUtils.runOnUiThread {
dartExecutor.executeDartEntrypoint(entrypoint)
}
}
}
}
private fun initChannels(context: Context) {
val engine = flutterEngine
engine ?: throw Exception("Flutter engine is not initialized")
val messenger = engine.dartExecutor
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(context))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(context))
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(context))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(context))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(context, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(context, args) }
}
}
}

View file

@ -0,0 +1,768 @@
package deckers.thibault.aves
import android.annotation.SuppressLint
import android.app.KeyguardManager
import android.app.SearchManager
import android.appwidget.AppWidgetManager
import android.content.ClipData
import android.content.Intent
import android.content.res.Configuration
import android.graphics.RectF
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.TransactionTooLargeException
import android.os.ext.SdkExtensions
import android.provider.MediaStore
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.AccessibilityHandler
import deckers.thibault.aves.channel.calls.AnalysisHandler
import deckers.thibault.aves.channel.streams.platformtodart.ActivityDirectoryChangeStreamHandler
import deckers.thibault.aves.channel.calls.AppAdapterHandler
import deckers.thibault.aves.channel.calls.AppProfileHandler
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.DebugHandler
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
import deckers.thibault.aves.channel.calls.GeocodingHandler
import deckers.thibault.aves.channel.calls.GlobalSearchHandler
import deckers.thibault.aves.channel.calls.HomeWidgetHandler
import deckers.thibault.aves.channel.calls.MediaEditHandler
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
import deckers.thibault.aves.channel.calls.MediaSessionHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.MetadataEditHandler
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.calls.SecurityHandler
import deckers.thibault.aves.channel.calls.StorageHandler
import deckers.thibault.aves.channel.calls.WallpaperHandler
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.darttoplatform.ActivityResultStreamHandler
import deckers.thibault.aves.channel.streams.darttoplatform.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.darttoplatform.ImageOpStreamHandler
import deckers.thibault.aves.channel.streams.darttoplatform.MediaStoreStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.AnalysisStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.ErrorStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.IntentStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.MediaCommandStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.MediaStoreChangeStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.SettingsChangeStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.WindowChangeStreamHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.anyCauseIs
import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.io.FileInputStream
import android.content.Context
import android.net.wifi.WifiManager
import io.flutter.embedding.android.RenderMode
// `FlutterFragmentActivity` because of local auth plugin
open class MainActivity : FlutterFragmentActivity() {
override fun getRenderMode(): RenderMode {
return RenderMode.texture
}
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// stream handlers
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var windowChangeStreamHandler: WindowChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler
private lateinit var analysisStreamHandler: AnalysisStreamHandler
private lateinit var errorStreamHandler: ErrorStreamHandler
private lateinit var activityDirectoryChangeStreamHandler: ActivityDirectoryChangeStreamHandler
// other handlers and state
internal lateinit var intentDataMap: MutableMap<String, Any?>
private lateinit var analysisHandler: AnalysisHandler<MainActivity>
private lateinit var mediaSessionHandler: MediaSessionHandler
// pending callbacks / completers used by various flows
private var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
private var pendingCollectionFilterPickHandler: ((List<String>?) -> Unit)? = null
private var pendingEditIntentHandler: ((FieldMap?) -> Unit)? = null
private var dlnaMulticastLock: WifiManager.MulticastLock? = null
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent")
logExtras(intent, "onCreate")
super.onCreate(savedInstanceState)
}
private fun logExtras(intent: Intent?, method: String) {
try {
intent?.extras?.takeUnless { it.isEmpty }?.let {
Log.i(LOG_TAG, "$method intent extras=$it")
}
} catch (e: Exception) {
// accessing extras may fail if their type comes from sending app
Log.w(LOG_TAG, "failed to parse extras", e)
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor
// notification: platform -> dart
analysisStreamHandler = AnalysisStreamHandler().apply {
EventChannel(messenger, AnalysisStreamHandler.CHANNEL).setStreamHandler(this)
}
errorStreamHandler = ErrorStreamHandler().apply {
EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this)
}
mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply {
EventChannel(messenger, MediaStoreChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
activityDirectoryChangeStreamHandler = ActivityDirectoryChangeStreamHandler(this).apply {
EventChannel(messenger, ActivityDirectoryChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
settingsChangeStreamHandler = SettingsChangeStreamHandler(this).apply {
EventChannel(messenger, SettingsChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
windowChangeStreamHandler = WindowChangeStreamHandler().apply {
EventChannel(messenger, WindowChangeStreamHandler.CHANNEL).setStreamHandler(this)
}
val mediaCommandStreamHandler = MediaCommandStreamHandler().apply {
EventChannel(messenger, MediaCommandStreamHandler.CHANNEL).setStreamHandler(this)
}
// dart -> platform -> dart
// - need Context
analysisHandler = AnalysisHandler(this, ::onAnalysisCompleted)
mediaSessionHandler = MediaSessionHandler(this, mediaCommandStreamHandler)
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, HomeWidgetHandler.CHANNEL).setMethodCallHandler(HomeWidgetHandler(this))
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this))
MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler)
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, SecurityHandler.CHANNEL).setMethodCallHandler(SecurityHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaEditHandler.CHANNEL).setMethodCallHandler(MediaEditHandler(this))
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
// - need Activity
MethodChannel(messenger, AppProfileHandler.CHANNEL).setMethodCallHandler(AppProfileHandler(this))
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
// - need Activity
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
StreamsChannel(messenger, ActivityResultStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ActivityResultStreamHandler(this, args) }
// intent handling
// notification: platform -> dart
intentStreamHandler = IntentStreamHandler().apply {
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
}
// intent detail & result: dart -> platform
intentDataMap = extractIntentData(intent)
MethodChannel(messenger, INTENT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
intentDataMap.clear()
}
"submitPickedItems" -> safe(call, result, ::submitPickedItems)
"submitPickedCollectionFilters" -> submitPickedCollectionFilters(call)
}
}
EntryPathChannel(this).register(flutterEngine)
MethodChannel(messenger, "aves/file_descriptor").setMethodCallHandler { call, result ->
if (call.method == "openFileDescriptor") {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("INVALID", "uri mancante", null)
return@setMethodCallHandler
}
try {
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
result.error("NOT_FOUND", "Impossibile aprire $uriString", null)
return@setMethodCallHandler
}
val input = FileInputStream(pfd.fileDescriptor)
val bytes = input.readBytes()
input.close()
pfd.close()
result.success(bytes)
} catch (e: Exception) {
result.error("ERROR", e.toString(), null)
}
} else {
result.notImplemented()
}
}
// DLNA multicast lock
MethodChannel(messenger, "aves/dlna").setMethodCallHandler { call, result ->
when (call.method) {
"dlnaAcquireLock" -> {
try {
if (dlnaMulticastLock == null) {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
dlnaMulticastLock = wifi.createMulticastLock("dlna_lock")
dlnaMulticastLock?.setReferenceCounted(true)
}
dlnaMulticastLock?.acquire()
result.success(true)
} catch (e: Exception) {
result.error("ERROR", e.toString(), null)
}
}
"dlnaReleaseLock" -> {
try {
dlnaMulticastLock?.let {
if (it.isHeld) it.release()
}
result.success(true)
} catch (e: Exception) {
result.error("ERROR", e.toString(), null)
}
}
else -> result.notImplemented()
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
defaultScope.launch { setupShortcuts() }
}
}
override fun onStart() {
Log.i(LOG_TAG, "onStart")
super.onStart()
analysisHandler.attachToActivity()
Handler(Looper.getMainLooper()).postDelayed({
window.decorView.requestApplyInsets()
}, 100)
}
override fun onStop() {
Log.i(LOG_TAG, "onStop")
super.onStop()
}
override fun onDestroy() {
Log.i(LOG_TAG, "onDestroy")
mediaSessionHandler.dispose()
mediaStoreChangeStreamHandler.dispose()
settingsChangeStreamHandler.dispose()
activityDirectoryChangeStreamHandler.dispose()
try {
super.onDestroy()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed while destroying activity", e)
}
}
@Deprecated("Deprecated in android.app.Activity")
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean) {
@Suppress("deprecation")
super.onMultiWindowModeChanged(isInMultiWindowMode)
notifyWindowModeChange()
}
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration) {
super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig)
notifyWindowModeChange()
}
@Deprecated("Deprecated in android.app.Activity")
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
@Suppress("deprecation")
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
notifyWindowModeChange()
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
notifyWindowModeChange()
}
private var lastCutoutInsetsDpi = RectF()
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val cutoutInsetsDpi = ActivityWindowHandler.getCutoutInsetsDpi(this)
if (lastCutoutInsetsDpi != cutoutInsetsDpi) {
lastCutoutInsetsDpi = cutoutInsetsDpi
notifyCutoutInsetsChange()
}
}
private fun notifyWindowModeChange() = windowChangeStreamHandler.notifyWindowModeChange()
private fun notifyCutoutInsetsChange() = windowChangeStreamHandler.notifyCutoutInsetsChange()
override fun onNewIntent(intent: Intent) {
Log.i(LOG_TAG, "onNewIntent intent=$intent")
logExtras(intent, "onNewIntent")
super.onNewIntent(intent)
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
Log.i(LOG_TAG, "onActivityResult requestCode=$requestCode resultCode=$resultCode data=$data")
logExtras(data, "onActivityResult")
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(requestCode, resultCode, data)
DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
PICK_COLLECTION_FILTERS_REQUEST -> onCollectionFiltersPickResult(resultCode, data)
EDIT_REQUEST -> onEditResult(resultCode, data)
}
}
private fun onCollectionFiltersPickResult(resultCode: Int, intent: Intent?) {
val filters = if (resultCode == RESULT_OK) extractFiltersFromIntent(intent) else null
pendingCollectionFilterPickHandler?.let { it(filters) }
}
private fun onEditResult(resultCode: Int, intent: Intent?) {
val fields: FieldMap? = if (resultCode == RESULT_OK) hashMapOf(
"uri" to intent?.data?.toString(),
"mimeType" to intent?.type,
) else null
pendingEditIntentHandler?.let { it(fields) }
}
private fun onDocumentTreeAccessResult(requestCode: Int, resultCode: Int, intent: Intent?) {
val treeUri = intent?.data
if (resultCode != RESULT_OK || treeUri == null) {
onStorageAccessResult(requestCode, null)
return
}
val canPersist = (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
@SuppressLint("WrongConstant")
if (canPersist) {
val takeFlags = (intent.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
try {
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
} catch (e: SecurityException) {
Log.w(LOG_TAG, "failed to take persistable URI permission for uri=$treeUri", e)
}
}
onStorageAccessResult(requestCode, treeUri)
}
private fun onScopedStoragePermissionResult(resultCode: Int) {
pendingScopedStoragePermissionCompleter?.complete(resultCode == RESULT_OK)
}
open fun extractIntentData(intent: Intent?): FieldMap {
when (val action = intent?.action) {
Intent.ACTION_MAIN -> {
return hashMapOf(
INTENT_DATA_KEY_PAGE to intent.getStringExtra(EXTRA_KEY_PAGE),
INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent),
INTENT_DATA_KEY_EXPLORER_PATH to intent.getStringExtra(EXTRA_KEY_EXPLORER_PATH),
)
}
Intent.ACTION_VIEW,
Intent.ACTION_SEND,
MediaStore.ACTION_REVIEW,
MediaStore.ACTION_REVIEW_SECURE,
"com.android.camera.action.REVIEW",
"com.android.camera.action.SPLIT_SCREEN_REVIEW" -> {
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
if (uri.scheme == "geo") {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW_GEO,
INTENT_DATA_KEY_URI to uri.toString(),
INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent),
)
}
// MIME type is optional
val type = intent.type ?: intent.resolveType(this)
val fields = hashMapOf<String, Any?>(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
INTENT_DATA_KEY_MIME_TYPE to type,
INTENT_DATA_KEY_URI to uri.toString(),
)
val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
val isLocked = keyguardManager.isKeyguardLocked
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(isLocked)
}
if (isLocked) {
// device is locked, so access to content is limited to intent URI by default
fields[INTENT_DATA_KEY_SECURE_URIS] = listOf(uri.toString())
}
if (action == MediaStore.ACTION_REVIEW_SECURE) {
val uris = ArrayList<String>()
intent.clipData?.let { clipData ->
for (i in 0..<clipData.itemCount) {
clipData.getItemAt(i).uri?.let { uris.add(it.toString()) }
}
}
if (uris.isNotEmpty()) {
fields[INTENT_DATA_KEY_SECURE_URIS] = uris
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && intent.hasExtra(MediaStore.EXTRA_BRIGHTNESS)) {
fields[INTENT_DATA_KEY_BRIGHTNESS] = intent.getFloatExtra(MediaStore.EXTRA_BRIGHTNESS, 0f)
}
return fields
}
}
Intent.ACTION_EDIT -> {
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
// MIME type is optional
val type = intent.type ?: intent.resolveType(this)
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_EDIT,
INTENT_DATA_KEY_MIME_TYPE to type,
INTENT_DATA_KEY_URI to uri.toString(),
)
}
}
Intent.ACTION_GET_CONTENT,
Intent.ACTION_PICK,
MediaStore.ACTION_PICK_IMAGES -> {
// common intent extras
var allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
val mimeTypes = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList()
val pickLocalOnly = intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false)
// MediaStore picker intent extras
var pickImagesMax = 0
var pickInOrder = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) >= 2) {
pickImagesMax = intent.getIntExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, 0)
}
if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) >= 12) {
pickInOrder = intent.getBooleanExtra(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER, false)
}
}
allowMultiple = allowMultiple || pickImagesMax > 0
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
INTENT_DATA_KEY_MIME_TYPE to intent.type,
INTENT_DATA_KEY_MIME_TYPES to mimeTypes,
INTENT_DATA_KEY_ALLOW_MULTIPLE to allowMultiple,
INTENT_DATA_KEY_PICK_IN_ORDER to pickInOrder,
INTENT_DATA_KEY_PICK_LOCAL_ONLY to pickLocalOnly,
)
}
Intent.ACTION_SEARCH -> {
val viewUri = intent.dataString
return if (viewUri != null) hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
INTENT_DATA_KEY_MIME_TYPE to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
INTENT_DATA_KEY_URI to viewUri,
) else hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SEARCH,
INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
)
}
INTENT_ACTION_PICK_COLLECTION_FILTERS -> {
val initialFilters = extractFiltersFromIntent(intent)
return hashMapOf(
INTENT_DATA_KEY_ACTION to action,
INTENT_DATA_KEY_FILTERS to initialFilters,
)
}
INTENT_ACTION_WIDGET_OPEN -> {
val widgetId = intent.getIntExtra(EXTRA_KEY_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
return hashMapOf(
INTENT_DATA_KEY_ACTION to action,
INTENT_DATA_KEY_WIDGET_ID to widgetId,
)
}
}
Intent.ACTION_RUN -> {
// flutter run
}
else -> {
Log.w(LOG_TAG, "unhandled intent action=${intent?.action}")
}
}
return HashMap()
}
private fun extractFiltersFromIntent(intent: Intent?): List<String>? {
intent ?: return null
val filters = intent.getStringArrayExtra(EXTRA_KEY_FILTERS_ARRAY)?.toList()
if (filters != null) return filters
// fallback for shortcuts created on API <26
val filterString = intent.getStringExtra(EXTRA_KEY_FILTERS_STRING)
if (filterString != null) {
return filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
}
return null
}
open fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
val pickedUris = call.argument<List<String>>("uris")
if (pickedUris.isNullOrEmpty()) {
setResult(RESULT_CANCELED)
// move code triggering `Binder` call off the main thread
defaultScope.launch { finish() }
return
}
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, uriString.toUri()) }
val intent = Intent().apply {
val firstUri = toUri(pickedUris.first())
if (pickedUris.size == 1) {
data = firstUri
} else {
clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
pickedUris.drop(1).forEach {
addItem(ClipData.Item(toUri(it)))
}
}
}
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// move code triggering `Binder` call off the main thread
defaultScope.launch {
submitPickedItemsIntent(intent, result)
}
}
private fun submitPickedItemsIntent(intent: Intent, result: MethodChannel.Result) {
try {
setResult(RESULT_OK, intent)
finish()
} catch (e: Exception) {
setResult(RESULT_CANCELED)
if (e is SecurityException && intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry submitting picked items without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
submitPickedItemsIntent(intent, result)
} else if (e.anyCauseIs<TransactionTooLargeException>()) {
result.error("submitPickedItems-large", "transaction too large with ${intent.clipData?.itemCount} URIs", e)
} else {
result.error("submitPickedItems-exception", "failed to pick ${intent.clipData?.itemCount} URIs", e)
}
}
}
private fun submitPickedCollectionFilters(call: MethodCall) {
val filters = call.argument<List<String>>("filters")
if (filters != null) {
val intent = Intent()
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
setResult(RESULT_OK, intent)
} else {
setResult(RESULT_CANCELED)
}
finish()
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun setupShortcuts() {
// do not use 'route' as extra key, as the Flutter framework acts on it
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
// so that foreground is rendered at the intended scale
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
val search = ShortcutInfoCompat.Builder(this, "search")
.setShortLabel(getString(R.string.search_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra(EXTRA_KEY_PAGE, SEARCH_PAGE_ROUTE_NAME)
)
.build()
val map = ShortcutInfoCompat.Builder(this, "map")
.setShortLabel(getString(R.string.map_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra(EXTRA_KEY_PAGE, MAP_PAGE_ROUTE_NAME)
)
.build()
val videos = ShortcutInfoCompat.Builder(this, "videos")
.setShortLabel(getString(R.string.videos_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra(EXTRA_KEY_PAGE, COLLECTION_PAGE_ROUTE_NAME)
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
)
.build()
val shortcutInfoList = listOf(videos, search, map)
ShortcutManagerCompat.setDynamicShortcuts(this, shortcutInfoList)
Log.i(LOG_TAG, "set shortcuts: ${shortcutInfoList.joinToString(", ") { v -> v.id }}")
}
private fun onAnalysisCompleted() {
analysisStreamHandler.notifyCompletion()
}
companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val EXTRA_STRING_ARRAY_SEPARATOR = "###"
const val DOCUMENT_TREE_ACCESS_REQUEST = 1
const val OPEN_FROM_ANALYSIS_SERVICE = 2
const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4
const val DELETE_SINGLE_PERMISSION_REQUEST = 5
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val PICK_COLLECTION_FILTERS_REQUEST = 7
const val EDIT_REQUEST = 8
const val INTENT_ACTION_EDIT = "edit"
const val INTENT_ACTION_PICK_ITEMS = "pick_items"
const val INTENT_ACTION_PICK_COLLECTION_FILTERS = "pick_collection_filters"
const val INTENT_ACTION_SCREEN_SAVER = "screen_saver"
const val INTENT_ACTION_SCREEN_SAVER_SETTINGS = "screen_saver_settings"
const val INTENT_ACTION_SEARCH = "search"
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
const val INTENT_ACTION_VIEW = "view"
const val INTENT_ACTION_VIEW_GEO = "view_geo"
const val INTENT_ACTION_WIDGET_OPEN = "widget_open"
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
const val INTENT_DATA_KEY_BRIGHTNESS = "brightness"
const val INTENT_DATA_KEY_EXPLORER_PATH = "explorerPath"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_MIME_TYPES = "mimeTypes"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_PICK_IN_ORDER = "pickInOrder"
const val INTENT_DATA_KEY_PICK_LOCAL_ONLY = "pickLocalOnly"
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_DATA_KEY_SECURE_URIS = "secureUris"
const val INTENT_DATA_KEY_URI = "uri"
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
const val EXTRA_KEY_PAGE = "page"
const val EXTRA_KEY_EXPLORER_PATH = "explorerPath"
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
const val EXTRA_KEY_WIDGET_ID = "widgetId"
// dart page routes
const val COLLECTION_PAGE_ROUTE_NAME = "/collection"
const val ENTRY_VIEWER_PAGE_ROUTE_NAME = "/viewer"
const val EXPLORER_PAGE_ROUTE_NAME = "/explorer"
const val MAP_PAGE_ROUTE_NAME = "/map"
const val SEARCH_PAGE_ROUTE_NAME = "/search"
// request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
var pendingCollectionFilterPickHandler: ((filters: List<String>?) -> Unit)? = null
var pendingEditIntentHandler: ((fields: FieldMap?) -> Unit)? = null
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
Log.i(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
if (uri != null) {
handler.onGranted(uri)
} else {
handler.onDenied()
}
}
private var errorStreamHandler: ErrorStreamHandler? = null
fun notifyError(error: String) {
Log.e(LOG_TAG, "notifyError error=$error")
errorStreamHandler?.notifyError(error)
}
}
}
// onGranted: user selected a directory/file (with no guarantee that it matches the requested `path`)
// onDenied: user cancelled
data class PendingStorageAccessResultHandler(val path: String?, val onGranted: (uri: Uri) -> Unit, val onDenied: () -> Unit)

View file

@ -0,0 +1,18 @@
package deckers.thibault.aves
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import androidx.media.MediaBrowserServiceCompat
// dummy service to handle media button events
// when there is no active media sessions
class MediaPlaybackService : MediaBrowserServiceCompat() {
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {
return null
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
val children = mutableListOf<MediaBrowserCompat.MediaItem>()
result.sendResult(children)
}
}

View file

@ -0,0 +1,155 @@
package deckers.thibault.aves
import android.service.dreams.DreamService
import android.util.Log
import android.view.View
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.AccessibilityHandler
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
import deckers.thibault.aves.channel.calls.MediaSessionHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.calls.StorageHandler
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.darttoplatform.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.darttoplatform.MediaStoreStreamHandler
import deckers.thibault.aves.channel.streams.platformtodart.MediaCommandStreamHandler
import deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterSurfaceView
import io.flutter.embedding.android.FlutterView
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor.DartEntrypoint
import io.flutter.embedding.engine.plugins.util.GeneratedPluginRegister
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
// for FlutterView-level integration, cf https://docs.flutter.dev/development/add-to-app/android/add-flutter-view
class ScreenSaverService : DreamService() {
private var flutterEngine: FlutterEngine? = null
private var flutterView: FlutterView? = null
private lateinit var mediaSessionHandler: MediaSessionHandler
override fun onAttachedToWindow() {
Log.i(LOG_TAG, "onAttachedToWindow")
super.onAttachedToWindow()
initDream()
createEngine()
setContentView(createView())
}
override fun onDreamingStarted() {
Log.i(LOG_TAG, "onDreamingStarted")
super.onDreamingStarted()
onStart()
}
override fun onDreamingStopped() {
Log.i(LOG_TAG, "onDreamingStopped")
release()
super.onDreamingStopped()
}
override fun onDetachedFromWindow() {
Log.i(LOG_TAG, "onDetachedFromWindow")
destroyView()
super.onDetachedFromWindow()
}
private fun initDream() {
isInteractive = false
isFullscreen = true
}
private fun createEngine() {
flutterEngine = flutterEngine ?: FlutterEngine(this, null, false)
GeneratedPluginRegister.registerGeneratedPlugins(flutterEngine!!)
initChannels()
}
private fun createView(): View {
flutterView = FlutterView(this, FlutterSurfaceView(this)).apply {
id = FlutterActivity.FLUTTER_VIEW_ID
attachToFlutterEngine(flutterEngine!!)
}
return flutterView!!
}
private fun destroyView() {
flutterEngine?.lifecycleChannel?.appIsDetached()
flutterView?.detachFromFlutterEngine()
}
private fun release() {
destroyView()
mediaSessionHandler.dispose()
flutterEngine = null
flutterView = null
}
private fun onStart() {
flutterEngine!!.apply {
if (!dartExecutor.isExecutingDart) {
navigationChannel.setInitialRoute(DEFAULT_INITIAL_ROUTE)
val appBundlePathOverride = FlutterInjector.instance().flutterLoader().findAppBundlePath()
val entrypoint = DartEntrypoint(appBundlePathOverride, DEFAULT_DART_ENTRYPOINT)
dartExecutor.executeDartEntrypoint(entrypoint)
}
lifecycleChannel.appIsResumed()
}
}
private fun initChannels() {
val messenger = flutterEngine!!.dartExecutor
// notification: platform -> dart
val mediaCommandStreamHandler = MediaCommandStreamHandler().apply {
EventChannel(messenger, MediaCommandStreamHandler.CHANNEL).setStreamHandler(this)
}
// dart -> platform -> dart
// - need Context
mediaSessionHandler = MediaSessionHandler(this, mediaCommandStreamHandler)
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this))
MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler)
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
// - need Service
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ServiceWindowHandler(this))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
// intent handling
// detail fetch: dart -> platform
MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
}
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<ScreenSaverService>()
private val intentDataMap: Map<String, Any?> = hashMapOf(
MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SCREEN_SAVER,
)
// from `FlutterActivityLaunchConfigs`
const val DEFAULT_DART_ENTRYPOINT = "main"
const val DEFAULT_INITIAL_ROUTE = "/"
}
}

View file

@ -0,0 +1,12 @@
package deckers.thibault.aves
import android.content.Intent
import deckers.thibault.aves.model.FieldMap
class ScreenSaverSettingsActivity : MainActivity() {
override fun extractIntentData(intent: Intent?): FieldMap {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SCREEN_SAVER_SETTINGS,
)
}
}

View file

@ -0,0 +1,149 @@
package deckers.thibault.aves
import android.app.SearchManager
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Build
import android.text.format.DateFormat
import android.util.Log
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.ContextUtils.resourceUri
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class SearchSuggestionsProvider : ContentProvider() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
return selectionArgs?.firstOrNull()?.let { query ->
// Samsung Finder does not support:
// - resource ID as value for SUGGEST_COLUMN_ICON_1
// - SUGGEST_COLUMN_ICON_2
// - SUGGEST_COLUMN_RESULT_CARD_IMAGE
val columns = arrayOf(
SearchManager.SUGGEST_COLUMN_INTENT_DATA,
SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA,
SearchManager.SUGGEST_COLUMN_CONTENT_TYPE,
SearchManager.SUGGEST_COLUMN_TEXT_1,
SearchManager.SUGGEST_COLUMN_TEXT_2,
SearchManager.SUGGEST_COLUMN_ICON_1,
)
val matrixCursor = MatrixCursor(columns)
context?.let { context ->
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
// so that foreground is rendered at the intended scale
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
val searchShortcutTitle = "${context.resources.getString(R.string.search_shortcut_short_label)} $query"
val searchShortcutIcon = context.resourceUri(if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search)
matrixCursor.addRow(arrayOf(null, null, null, searchShortcutTitle, null, searchShortcutIcon))
runBlocking {
getSuggestions(context, query).forEach {
val data = it["data"]
val mimeType = it["mimeType"]
val title = it["title"]
val subtitle = it["subtitle"]
val iconUri = it["iconUri"]
matrixCursor.addRow(arrayOf(data, mimeType, mimeType, title, subtitle, iconUri))
}
}
}
matrixCursor
}
}
private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> {
if (flutterEngine == null) {
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
flutterEngine = it
}
}
val engine = flutterEngine
engine ?: throw Exception("Flutter engine is not initialized")
val messenger = engine.dartExecutor
val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply {
setMethodCallHandler { call, result ->
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "background channel is ready")
result.success(null)
}
else -> result.notImplemented()
}
}
}
try {
return suspendCancellableCoroutine { cont ->
defaultScope.launch {
FlutterUtils.runOnUiThread {
backgroundChannel.invokeMethod(
"getSuggestions", hashMapOf(
"query" to query,
"locale" to Locale.getDefault().toString(),
"use24hour" to DateFormat.is24HourFormat(context),
), object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("unchecked_cast")
cont.resume(result as List<FieldMap>)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
}
override fun notImplemented() {
cont.resumeWithException(Exception("not implemented"))
}
})
}
}
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get suggestions", e)
return ArrayList()
}
}
override fun onCreate(): Boolean = true
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri =
throw UnsupportedOperationException("`insert` is not supported by this content provider")
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int =
throw UnsupportedOperationException("`delete` is not supported by this content provider")
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int =
throw UnsupportedOperationException("`update` is not supported by this content provider")
companion object {
private val LOG_TAG = LogUtils.createTag<SearchSuggestionsProvider>()
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/global_search_background"
const val SHARED_PREFERENCES_KEY = "platform_search"
const val CALLBACK_HANDLE_KEY = "callback_handle"
private var flutterEngine: FlutterEngine? = null
}
}

View file

@ -0,0 +1,56 @@
package deckers.thibault.aves
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.AppAdapterHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class WallpaperActivity : MainActivity() {
private var originalIntent: String? = null
override fun extractIntentData(intent: Intent?): FieldMap {
if (intent != null) {
when (intent.action) {
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
// MIME type is optional
val type = intent.type ?: intent.resolveType(this)
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SET_WALLPAPER,
INTENT_DATA_KEY_MIME_TYPE to type,
INTENT_DATA_KEY_URI to uri.toString(),
)
}
// if the media URI is not provided we need to pick one first
originalIntent = intent.action
intent.action = Intent.ACTION_PICK
}
}
}
return super.extractIntentData(intent)
}
override fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
if (originalIntent != null) {
val pickedUris = call.argument<List<String>>("uris")
if (!pickedUris.isNullOrEmpty()) {
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, uriString.toUri()) }
onNewIntent(Intent().apply {
action = originalIntent
data = toUri(pickedUris.first())
})
} else {
setResult(RESULT_CANCELED)
finish()
}
} else {
super.submitPickedItems(call, result)
}
}
}

View file

@ -0,0 +1,107 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityManager
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
"getLongPressTimeout" -> safe(call, result, ::getLongPressTimeout)
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
else -> result.notImplemented()
}
}
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var removed = false
try {
removed = Settings.Global.getFloat(contextWrapper.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
result.success(removed)
}
private fun getLongPressTimeout(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(ViewConfiguration.getLongPressTimeout())
}
private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
}
private fun getRecommendedTimeoutMillis(call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.error("getRecommendedTimeoutMillis-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val originalTimeoutMillis = call.argument<Int>("originalTimeoutMillis")
val content = call.argument<List<String>>("content")
if (originalTimeoutMillis == null || content == null) {
result.error("getRecommendedTimeoutMillis-args", "missing arguments", null)
return
}
var uiContentFlags = 0
content.forEach {
uiContentFlags = when (it) {
"controls" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_CONTROLS
"icons" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_ICONS
"text" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_TEXT
else -> {
result.error("getRecommendedTimeoutMillis-flag", "unsupported UI content flag=$it", null)
return
}
}
}
val am = contextWrapper.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
if (am == null) {
result.error("getRecommendedTimeoutMillis-service", "failed to get accessibility manager", null)
return
}
val millis = am.getRecommendedTimeoutMillis(originalTimeoutMillis, uiContentFlags)
result.success(millis)
}
// Flutter v3.4 already checks the system `Configuration.fontWeightAdjustment` to update `MediaQuery`
// but we need to also check the non-standard Samsung field `bf` representing the bold font toggle
private fun shouldUseBoldFont(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var shouldBold = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val config = contextWrapper.resources.configuration
val fontWeightAdjustment = config.fontWeightAdjustment
shouldBold = if (fontWeightAdjustment != Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED && fontWeightAdjustment != 0) {
fontWeightAdjustment >= BOLD_TEXT_WEIGHT_ADJUSTMENT
} else {
// fallback to Samsung non-standard field
Regex(" bf=([01]) ").find(config.toString())?.groups?.get(1)?.value == "1"
}
}
result.success(shouldBold)
}
companion object {
private val LOG_TAG = LogUtils.createTag<AccessibilityHandler>()
const val CHANNEL = "deckers.thibault/aves/accessibility"
// match Flutter way: https://github.com/flutter/engine/blob/main/shell/platform/android/io/flutter/view/AccessibilityBridge.java#L125
const val BOLD_TEXT_WEIGHT_ADJUSTMENT = 300
}
}

View file

@ -0,0 +1,113 @@
package deckers.thibault.aves.channel.calls
import android.app.ActivityManager
import android.content.Context
import android.content.ContextWrapper
import androidx.core.content.edit
import androidx.lifecycle.LifecycleOwner
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import deckers.thibault.aves.AnalysisWorker
import deckers.thibault.aves.utils.FlutterUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class AnalysisHandler<T>(private val activity: T, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler
where T : ContextWrapper,
T : LifecycleOwner {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"registerCallback" -> ioScope.launch { Coresult.safe(call, result, ::registerCallback) }
"startAnalysis" -> Coresult.safe(call, result, ::startAnalysis)
else -> result.notImplemented()
}
}
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {
result.error("registerCallback-args", "missing arguments", null)
return
}
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
preferences.edit {
putLong(AnalysisWorker.PREF_CALLBACK_HANDLE_KEY, callbackHandle)
}
result.success(true)
}
private fun startAnalysis(call: MethodCall, result: MethodChannel.Result) {
val force = call.argument<Boolean>("force")
if (force == null) {
result.error("startAnalysis-args", "missing arguments", null)
return
}
val activityManager: ActivityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val runningAppProcesses = activityManager.runningAppProcesses
if (runningAppProcesses != null) {
val importance = runningAppProcesses[0].importance
if (importance < ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
// the app is in the background
result.error("startAnalysis-background", "app is in the background (process importance=$importance)", null)
return
}
}
// can be null or empty
val allEntryIds = call.argument<List<Int>>("entryIds")
// work `Data` cannot occupy more than 10240 bytes when serialized
// so we save the possibly long list of entry IDs to shared preferences
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
preferences.edit {
putStringSet(AnalysisWorker.PREF_ENTRY_IDS_KEY, allEntryIds?.map { it.toString() }?.toSet())
}
val workData = workDataOf(
AnalysisWorker.KEY_FORCE to force,
)
WorkManager.getInstance(activity).beginUniqueWork(
ANALYSIS_WORK_NAME,
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<AnalysisWorker>().apply { setInputData(workData) }.build(),
).enqueue()
attachToActivity()
result.success(null)
}
private var attached = false
fun attachToActivity() {
if (!attached) {
attached = true
WorkManager.getInstance(activity).getWorkInfosForUniqueWorkLiveData(ANALYSIS_WORK_NAME).observe(activity) { list ->
if (list.any { it.state == WorkInfo.State.SUCCEEDED }) {
runBlocking {
FlutterUtils.runOnUiThread {
onAnalysisCompleted()
}
}
}
}
}
}
companion object {
const val CHANNEL = "deckers.thibault/aves/analysis"
private const val ANALYSIS_WORK_NAME = "analysis_work"
}
}

View file

@ -0,0 +1,506 @@
package deckers.thibault.aves.channel.calls
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.TransactionTooLargeException
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.scale
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.COLLECTION_PAGE_ROUTE_NAME
import deckers.thibault.aves.MainActivity.Companion.ENTRY_VIEWER_PAGE_ROUTE_NAME
import deckers.thibault.aves.MainActivity.Companion.EXPLORER_PAGE_ROUTE_NAME
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_EXPLORER_PATH
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
import deckers.thibault.aves.MainActivity.Companion.MAP_PAGE_ROUTE_NAME
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.ContextUtils.devicePixelRatio
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.anyCauseIs
import deckers.thibault.aves.utils.getApplicationInfoCompat
import deckers.thibault.aves.utils.queryIntentActivitiesCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
import java.util.UUID
import kotlin.math.min
import kotlin.math.roundToInt
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
"open" -> safe(call, result, ::open)
"openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share)
"pinShortcut" -> ioScope.launch { safe(call, result, ::pinShortcut) }
else -> result.notImplemented()
}
}
private fun getPackages(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val packages = HashMap<String, FieldMap>()
fun addPackageDetails(intent: Intent) {
// apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale
val englishConfig = Configuration().apply {
setLocale(Locale.ENGLISH)
}
val pm = context.packageManager
for (resolveInfo in pm.queryIntentActivitiesCompat(intent, 0)) {
val appInfo = resolveInfo.activityInfo.applicationInfo
val packageName = appInfo.packageName
if (!packages.containsKey(packageName)) {
val currentLabel = pm.getApplicationLabel(appInfo).toString()
val englishLabel: String? = appInfo.labelRes.takeIf { it != 0 }?.let { labelRes ->
var englishLabel: String? = null
try {
val resources = pm.getResourcesForApplication(appInfo)
// `updateConfiguration` is deprecated but it seems to be the only way
// to query resources from another app with a specific locale.
// The following methods do not work:
// - `resources.getConfiguration().setLocale(...)`
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
@Suppress("deprecation")
resources.updateConfiguration(englishConfig, resources.displayMetrics)
englishLabel = resources.getString(labelRes)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
}
englishLabel
}
packages[packageName] = hashMapOf(
"packageName" to packageName,
"categoryLauncher" to intent.hasCategory(Intent.CATEGORY_LAUNCHER),
"isSystem" to (appInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0),
"currentLabel" to currentLabel,
"englishLabel" to englishLabel,
)
}
}
}
// identify launcher category packages, which typically include user apps
// they should be fetched before the other packages, to be marked as launcher packages
try {
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER))
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to list launcher packages", e)
}
try {
// complete with all the other packages
addPackageDetails(Intent(Intent.ACTION_MAIN))
} catch (e: Exception) {
// `PackageManager.queryIntentActivities()` may kill the package manager if the response is too large
Log.w(LOG_TAG, "failed to list all packages", e)
// fallback to the default category packages, which typically include system and OEM tools
try {
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_DEFAULT))
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to list default packages", e)
}
}
result.success(ArrayList(packages.values))
}
private suspend fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
val packageName = call.argument<String>("packageName")
val sizeDip = call.argument<Number>("sizeDip")?.toDouble()
if (packageName == null || sizeDip == null) {
result.error("getAppIcon-args", "missing arguments", null)
return
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
val density = context.devicePixelRatio()
val size = (sizeDip * density).roundToInt()
var bytes: ByteArray? = null
try {
val iconResourceId = context.packageManager.getApplicationInfoCompat(packageName, 0).icon
if (iconResourceId != Resources.ID_NULL) {
val uri = Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(packageName)
.path(iconResourceId.toString())
.build()
val options = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.override(size, size)
val target = Glide.with(context)
.asBitmap()
.apply(options)
.load(uri)
.submit(size, size)
try {
var bitmap = withContext(Dispatchers.IO) { target.get() }
if (bitmap.width > size && bitmap.height > size) {
// rescale when the resulting bitmap is larger than requested
val scalingFactor: Double = min(bitmap.width.toDouble() / size, bitmap.height.toDouble() / size)
val dstWidth = (bitmap.width / scalingFactor).roundToInt()
val dstHeight = (bitmap.height / scalingFactor).roundToInt()
Log.d(
LOG_TAG, "rescale app icon for packageName=$packageName" +
", with bitmap byteCount=${bitmap.byteCount} size=${bitmap.width}x${bitmap.height}" +
", to target=${size}x${size}"
)
bitmap = bitmap.scale(dstWidth, dstHeight)
}
// do not recycle bitmaps fetched from `ContentResolver` as their lifecycle is unknown
bytes = BitmapUtils.getBytes(bitmap, recycle = false, decoded = true, mimeType = null)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
}
Glide.with(context).clear(target)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
return
}
if (bytes != null) {
result.success(bytes)
} else {
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
}
}
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument<String>("label")
val text = call.argument<String>("text")
val uri = call.argument<String>("uri")?.toUri()
if (text == null && uri == null) {
result.error("copyToClipboard-args", "missing arguments", null)
return
}
// on older devices, `ClipboardManager` initialization must happen on the main thread
// (e.g. Samsung S7 with Android 8.0 / API 26, but not on Tab A 10.1 with Android 8.1 / API 27)
Handler(Looper.getMainLooper()).post {
try {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (clipboard != null) {
val clip: ClipData
if (uri != null) {
clip = ClipData.newUri(context.contentResolver, label, getShareableUri(context, uri))
} else {
clip = ClipData.newPlainText(label, text)
}
clipboard.setPrimaryClip(clip)
result.success(true)
} else {
result.success(false)
}
} catch (e: Exception) {
result.error("copyToClipboard-exception", "failed to set clip", e.message)
}
}
}
private fun open(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.toUri()
val mimeType = call.argument<String>("mimeType")
val forceChooser = call.argument<Boolean>("forceChooser")
if (uri == null || forceChooser == null) {
result.error("open-args", "missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(context, uri), mimeType)
val started = if (forceChooser) safeStartActivityChooser(title, intent) else safeStartActivity(intent)
result.success(started)
}
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
val geoUri = call.argument<String>("geoUri")?.toUri()
if (geoUri == null) {
result.error("openMap-args", "missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_VIEW, geoUri)
val started = safeStartActivity(intent)
result.success(started)
}
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.toUri()
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("setAs-args", "missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_ATTACH_DATA)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(context, uri), mimeType)
val started = safeStartActivityChooser(title, intent)
result.success(started)
}
private fun share(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")
if (urisByMimeType == null) {
result.error("share-args", "missing arguments", null)
return
}
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, it.toUri()) })
val mimeTypes = urisByMimeType.keys.toTypedArray()
// simplify share intent for a single item, as some apps can handle one item but not more
val intent = if (uriList.size == 1) {
val uri = uriList.first()
val mimeType = mimeTypes.first()
Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setType(mimeType)
.putExtra(Intent.EXTRA_STREAM, uri)
} else {
var mimeType = "*/*"
if (mimeTypes.size == 1) {
// items have the same MIME type & subtype
mimeType = mimeTypes.first()
} else {
// items have different subtypes
val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct()
if (mimeTypeTypes.size == 1) {
// items have the same MIME type
mimeType = "${mimeTypeTypes.first()}/*"
}
}
Intent(Intent.ACTION_SEND_MULTIPLE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
.setType(mimeType)
}
try {
val started = safeStartActivityChooser(title, intent)
result.success(started)
} catch (e: Exception) {
if (e.anyCauseIs<TransactionTooLargeException>()) {
result.error("share-large", "transaction too large with ${uriList.size} URIs", e)
} else {
result.error("share-exception", "failed to share ${uriList.size} URIs", e)
}
}
}
private fun safeStartActivity(intent: Intent): Boolean {
if (intent.resolveActivity(context.packageManager) == null) return false
try {
context.startActivity(intent)
return true
} catch (e: SecurityException) {
if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
return safeStartActivity(intent)
} else {
Log.w(LOG_TAG, "failed to start activity for intent=$intent", e)
}
}
return false
}
private fun safeStartActivityChooser(title: String?, intent: Intent): Boolean {
if (intent.resolveActivity(context.packageManager) == null) return false
try {
context.startActivity(Intent.createChooser(intent, title))
return true
} catch (e: SecurityException) {
if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
return safeStartActivityChooser(title, intent)
} else {
Log.w(LOG_TAG, "failed to start activity chooser for intent=$intent", e)
}
}
return false
}
// shortcuts
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
// common arguments
val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes")
val route = call.argument<String>("route")
// route dependent arguments
val filters = call.argument<List<String>>("filters")
val explorerPath = call.argument<String>("path")
val viewUri = call.argument<String>("viewUri")?.toUri()
val geoUri = call.argument<String>("geoUri")?.toUri()
if (label == null || route == null) {
result.error("pin-args", "missing arguments", null)
return
}
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return
}
var icon: IconCompat? = null
if (iconBytes?.isNotEmpty() == true) {
var bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256)
if (bitmap != null) {
// adaptive, so the bitmap is used as background and covers the whole icon
icon = IconCompat.createWithAdaptiveBitmap(bitmap)
}
}
if (icon == null) {
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
// so that foreground is rendered at the intended scale
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
val resId = when (route) {
MAP_PAGE_ROUTE_NAME -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map
else -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection
}
icon = IconCompat.createWithResource(context, resId)
}
val intent: Intent = when (route) {
COLLECTION_PAGE_ROUTE_NAME -> {
if (filters == null) {
result.error("pin-filters", "collection shortcut requires filters", null)
return
}
Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra(EXTRA_KEY_PAGE, route)
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
// so we use a joined `String` as fallback
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
}
ENTRY_VIEWER_PAGE_ROUTE_NAME -> {
if (viewUri == null) {
result.error("pin-viewUri", "viewer shortcut requires URI", null)
return
}
Intent(Intent.ACTION_VIEW, viewUri, context, MainActivity::class.java)
}
EXPLORER_PAGE_ROUTE_NAME -> {
Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra(EXTRA_KEY_PAGE, route)
.putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath)
}
MAP_PAGE_ROUTE_NAME -> {
if (geoUri == null) {
result.error("pin-geoUri", "map shortcut requires URI", null)
return
}
Intent(Intent.ACTION_VIEW, geoUri, context, MainActivity::class.java).apply {
putExtra(EXTRA_KEY_PAGE, route)
// filters are optional
filters?.let {
putExtra(EXTRA_KEY_FILTERS_ARRAY, it.toTypedArray())
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
// so we use a joined `String` as fallback
putExtra(EXTRA_KEY_FILTERS_STRING, it.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
}
}
}
else -> {
result.error("pin-route", "unsupported shortcut route=$route", null)
return
}
}
// multiple shortcuts sharing the same ID cannot be created with different labels or icons
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
val shortcut = ShortcutInfoCompat.Builder(context, UUID.randomUUID().toString())
.setShortLabel(label)
.setIcon(icon)
.setIntent(intent)
.build()
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
result.success(true)
}
companion object {
private val LOG_TAG = LogUtils.createTag<AppAdapterHandler>()
const val CHANNEL = "deckers.thibault/aves/app"
fun getShareableUri(context: Context, uri: Uri): Uri? {
return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> {
uri.path?.let { path ->
val authority = "${context.applicationContext.packageName}.file_provider"
FileProvider.getUriForFile(context, authority, File(path))
}
}
else -> uri
}
}
}
}

View file

@ -0,0 +1,97 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.Context
import android.content.pm.CrossProfileApps
import android.os.Build
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class AppProfileHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"canInteractAcrossProfiles" -> safe(call, result, ::canInteractAcrossProfiles)
"canRequestInteractAcrossProfiles" -> safe(call, result, ::canRequestInteractAcrossProfiles)
"requestInteractAcrossProfiles" -> safe(call, result, ::requestInteractAcrossProfiles)
"switchProfile" -> safe(call, result, ::switchProfile)
"getProfileSwitchingLabel" -> safe(call, result, ::getProfileSwitchingLabel)
"getTargetUserProfiles" -> safe(call, result, ::getTargetUserProfiles)
else -> result.notImplemented()
}
}
private fun canInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
result.success(false)
return
}
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
result.success(crossProfileApps.canInteractAcrossProfiles())
}
private fun canRequestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
result.success(false)
return
}
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
result.success(crossProfileApps.canRequestInteractAcrossProfiles())
}
private fun requestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
result.success(false)
return
}
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
val intent = crossProfileApps.createRequestInteractAcrossProfilesIntent()
val started = activity.startActivity(intent)
result.success(started)
}
private fun switchProfile(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
result.success(false)
return
}
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
val userHandles = crossProfileApps.targetUserProfiles
crossProfileApps.startMainActivity(activity.componentName, userHandles.first())
result.success(null)
}
private fun getProfileSwitchingLabel(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
result.success(null)
return
}
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
val userHandles = crossProfileApps.targetUserProfiles
val label = if (userHandles.isEmpty()) "" else crossProfileApps.getProfileSwitchingLabel(userHandles.first())
result.success(label)
}
private fun getTargetUserProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
result.success(false)
return
}
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
val userProfiles = crossProfileApps.targetUserProfiles.map { it.toString() }.toList()
result.success(userProfiles)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/app_profile"
}
}

View file

@ -0,0 +1,71 @@
package deckers.thibault.aves.channel.calls
import deckers.thibault.aves.MainActivity
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
// ensure `result` methods are called on the main looper thread
class Coresult internal constructor(private val call: MethodCall, private val methodResult: MethodChannel.Result) : MethodChannel.Result {
private val mainScope = CoroutineScope(Dispatchers.Main)
override fun success(result: Any?) {
mainScope.launch {
try {
methodResult.success(result)
} catch (e: Exception) {
MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=\n${e.stackTraceToString()}")
}
}
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
mainScope.launch {
try {
methodResult.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=\n${e.stackTraceToString()}")
}
}
}
override fun notImplemented() {
mainScope.launch {
try {
methodResult.notImplemented()
} catch (e: Exception) {
MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=\n${e.stackTraceToString()}")
}
}
}
companion object {
fun safe(
call: MethodCall,
result: MethodChannel.Result,
function: (call: MethodCall, result: MethodChannel.Result) -> Unit
) {
val res = Coresult(call, result)
try {
function(call, res)
} catch (e: Exception) {
res.error("safe-exception", e.message, e.stackTraceToString())
}
}
suspend fun safeSuspend(
call: MethodCall,
result: MethodChannel.Result,
function: suspend (call: MethodCall, result: MethodChannel.Result) -> Unit
) {
val res = Coresult(call, result)
try {
function(call, res)
} catch (e: Exception) {
res.error("safeSuspend-exception", e.message, e.stackTraceToString())
}
}
}
}

View file

@ -0,0 +1,424 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.graphics.BitmapFactory
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.glide.TiffFetcher
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Mp4ParserHelper
import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.util.PathUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
class DebugHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"crash" -> Handler(Looper.getMainLooper()).postDelayed({ throw TestException() }, 50)
"exception" -> throw TestException()
"safeException" -> safe(call, result) { _, _ -> throw TestException() }
"exceptionInCoroutine" -> ioScope.launch { throw TestException() }
"safeExceptionInCoroutine" -> ioScope.launch { safe(call, result) { _, _ -> throw TestException() } }
"getContextDirs" -> ioScope.launch { safe(call, result, ::getContextDirs) }
"getCodecs" -> safe(call, result, ::getCodecs)
"getEnv" -> safe(call, result, ::getEnv)
"getBitmapFactoryInfo" -> ioScope.launch { safe(call, result, ::getBitmapFactoryInfo) }
"getContentResolverMetadata" -> ioScope.launch { safe(call, result, ::getContentResolverMetadata) }
"getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) }
"getMp4ParserDump" -> ioScope.launch { safe(call, result, ::getMp4ParserDump) }
"getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) }
"getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) }
else -> result.notImplemented()
}
}
private fun getContextDirs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val dirs = hashMapOf(
"cacheDir" to context.cacheDir,
"dataDir" to context.dataDir,
"filesDir" to context.filesDir,
"obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir,
"externalFilesDir" to context.getExternalFilesDir(null),
"codeCacheDir" to context.codeCacheDir,
"noBackupFilesDir" to context.noBackupFilesDir,
).apply {
}.mapValues { it.value?.path }.toMutableMap()
dirs["externalCacheDirs"] = context.externalCacheDirs.joinToString { it.path }
dirs["externalFilesDirs"] = context.getExternalFilesDirs(null).joinToString { it?.path ?: "null" }
// used by flutter plugin `path_provider`
dirs.putAll(
hashMapOf(
"flutter / cacheDir" to PathUtils.getCacheDirectory(context),
"flutter / dataDir" to PathUtils.getDataDirectory(context),
"flutter / filesDir" to PathUtils.getFilesDir(context),
)
)
result.success(dirs)
}
private fun getCodecs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
fun getFields(info: MediaCodecInfo): FieldMap {
val fields: FieldMap = hashMapOf(
"name" to info.name,
"isEncoder" to info.isEncoder,
"supportedTypes" to info.supportedTypes.joinToString(", "),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (info.canonicalName != info.name) fields["canonicalName"] = info.canonicalName
if (info.isAlias) fields["isAlias"] to info.isAlias
if (info.isHardwareAccelerated) fields["isHardwareAccelerated"] to info.isHardwareAccelerated
if (info.isSoftwareOnly) fields["isSoftwareOnly"] to info.isSoftwareOnly
if (info.isVendor) fields["isVendor"] to info.isVendor
}
return fields
}
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.map(::getFields).toList()
result.success(codecs)
}
private fun getEnv(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(System.getenv())
}
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.toUri()
if (uri == null) {
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)
options.outMimeType?.let { metadataMap["MimeType"] = it }
options.outWidth.takeIf { it >= 0 }?.let { metadataMap["Width"] = it.toString() }
options.outHeight.takeIf { it >= 0 }?.let { metadataMap["Height"] = it.toString() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
options.outColorSpace?.let { metadataMap["ColorSpace"] = it.toString() }
options.outConfig?.let { metadataMap["Config"] = it.toString() }
}
}
} catch (_: IOException) {
// ignore
}
result.success(metadataMap)
}
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "missing arguments", null)
return
}
var contentUri: Uri = uri
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
contentUri = StorageUtils.getOriginalUri(context, contentUri)
}
}
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
val metadataMap = getContentResolverMetadataForUri(contentUri) ?: getContentResolverMetadataForUri(uri)
if (metadataMap != null) {
result.success(metadataMap)
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getContentResolverMetadataForUri(contentUri: Uri): FieldMap? {
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val metadataMap = HashMap<String, Any?>()
val columnCount = cursor.columnCount
val columnNames = cursor.columnNames
for (i in 0..<columnCount) {
val key = columnNames[i]
try {
metadataMap[key] = when (cursor.getType(i)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i)
Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$key", e)
}
}
cursor.close()
return metadataMap
}
return null
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
return
}
val metadataMap = HashMap<String, String?>()
if (canReadWithExifInterface(mimeType, strict = false)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
return
}
}
result.success(metadataMap)
}
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.toUri()
if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
retriever.extractMetadata(code)?.let { metadataMap[name] = it }
}
} catch (_: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.success(metadataMap)
}
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input, sizeBytes)
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
} else ""
}
metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)
} else ""
}
for (dir in metadata.directories) {
val dirName = dir.name ?: ""
var index = 0
while (metadataMap.containsKey("$dirName ($index)")) index++
var value = "${dir.tagCount} tags"
dir.parent?.let { value += ", parent: ${it.name}" }
metadataMap["$dirName ($index)"] = value
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
}
result.success(metadataMap)
}
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
if (mimeType == null || uri == null) {
result.error("getMp4ParserDump-args", "missing arguments", null)
return
}
val sb = StringBuilder()
if (mimeType == MimeTypes.MP4 || MimeTypes.isIsoBMFFImage(mimeType)) {
try {
Mp4ParserHelper.consumeIso(context, uri, Mp4ParserHelper.metadataBoxParser()) { isoFile ->
isoFile.dumpBoxes(sb)
}
} catch (e: Exception) {
result.error("getMp4ParserDump-exception", e.message, e.stackTraceToString())
return
}
}
result.success(sb.toString())
}
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
if (mimeType == null || uri == null) {
result.error("getPixyMetadata-args", "missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
if (canReadWithPixyMeta(mimeType)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
metadataMap.putAll(PixyMetaHelper.describe(input))
}
} catch (e: Exception) {
result.error("getPixyMetadata-exception", e.message, e.stackTraceToString())
return
}
}
result.success(metadataMap)
}
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.toUri()
if (uri == null) {
result.error("getTiffStructure-args", "missing arguments", null)
return
}
try {
val metadataMap = HashMap<String, FieldMap>()
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
return
}
var options = TiffFetcher.buildOptions().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
metadataMap["0"] = tiffOptionsToMap(options)
val dirCount = options.outDirectoryCount
for (page in 1..<dirCount) {
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
return
}
options = TiffFetcher.buildOptions().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
metadataMap["$page"] = tiffOptionsToMap(options)
}
result.success(metadataMap)
} catch (e: Exception) {
result.error("getTiffStructure-read", "failed to read tiff", e.message)
}
}
private fun tiffOptionsToMap(options: TiffBitmapFactory.Options): FieldMap = hashMapOf(
"Author" to options.outAuthor,
"BitsPerSample" to options.outBitsPerSample.toString(),
"CompressionScheme" to options.outCompressionScheme?.toString(),
"Copyright" to options.outCopyright,
"CurDirectoryNumber" to options.outCurDirectoryNumber.toString(),
"Datetime" to options.outDatetime,
"DirectoryCount" to options.outDirectoryCount.toString(),
"FillOrder" to options.outFillOrder?.toString(),
"Height" to options.outHeight.toString(),
"HostComputer" to options.outHostComputer,
"ImageDescription" to options.outImageDescription,
"ImageOrientation" to options.outImageOrientation?.toString(),
"NumberOfStrips" to options.outNumberOfStrips.toString(),
"Photometric" to options.outPhotometric?.toString(),
"PlanarConfig" to options.outPlanarConfig?.toString(),
"ResolutionUnit" to options.outResolutionUnit?.toString(),
"RowPerStrip" to options.outRowPerStrip.toString(),
"SamplePerPixel" to options.outSamplePerPixel.toString(),
"Software" to options.outSoftware,
"StripSize" to options.outStripSize.toString(),
"TileHeight" to options.outTileHeight.toString(),
"TileWidth" to options.outTileWidth.toString(),
"Width" to options.outWidth.toString(),
"XResolution" to options.outXResolution.toString(),
"YResolution" to options.outYResolution.toString(),
)
companion object {
private val LOG_TAG = LogUtils.createTag<DebugHandler>()
const val CHANNEL = "deckers.thibault/aves/debug"
}
class TestException internal constructor() : RuntimeException("oops")
}

View file

@ -0,0 +1,167 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.app.LocaleConfig
import android.app.LocaleManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Resources
import android.location.Geocoder
import android.os.Build
import android.os.LocaleList
import android.provider.MediaStore
import android.provider.Settings
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toUri
import androidx.core.text.util.LocalePreferences
import com.google.android.material.color.DynamicColors
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.MemoryUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.Locale
class DeviceHandler(private val context: Context) : MethodCallHandler {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"canManageMedia" -> safe(call, result, ::canManageMedia)
"getCapabilities" -> defaultScope.launch { safe(call, result, ::getCapabilities) }
"getLocales" -> safe(call, result, ::getLocales)
"setLocaleConfig" -> safe(call, result, ::setLocaleConfig)
"getFirstDayOfWeek" -> safe(call, result, ::getFirstDayOfWeek)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
"getWidgetCornerRadiusPx" -> safe(call, result, ::getWidgetCornerRadiusPx)
"isLocked" -> safe(call, result, ::isLocked)
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
"requestMediaManagePermission" -> safe(call, result, ::requestMediaManagePermission)
"getAvailableHeapSize" -> safe(call, result, ::getAvailableHeapSize)
"requestGarbageCollection" -> safe(call, result, ::requestGarbageCollection)
else -> result.notImplemented()
}
}
private fun canManageMedia(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaStore.canManageMedia(context) else false)
}
private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(
hashMapOf(
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canRenderSubdivisionFlagEmojis" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O),
"canRequestManageMedia" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S),
"hasGeocoder" to Geocoder.isPresent(),
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
"showPinShortcutFeedback" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O),
"supportEdgeToEdgeUIMode" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q),
"supportPictureInPicture" to supportPictureInPicture(),
)
)
}
private fun supportPictureInPicture(): Boolean {
// minimum version for `PictureInPictureParams.Builder#setAutoEnterEnabled`
val supportPipOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
return supportPipOnLeave && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
fun toMap(locale: Locale): FieldMap = hashMapOf(
"language" to locale.language,
"country" to locale.country,
"script" to locale.script,
)
// when called from a window-less service, locales from `context.resources`
// do not reflect the current system settings, so we use `Resources.getSystem()` instead
val list = Resources.getSystem().configuration.locales
val locales = ArrayList<FieldMap>()
for (i in 0..<list.size()) {
locales.add(toMap(list.get(i)))
}
result.success(locales)
}
private fun setLocaleConfig(call: MethodCall, result: MethodChannel.Result) {
val locales = call.argument<List<String>>("locales")
if (locales.isNullOrEmpty()) {
result.error("setLocaleConfig-args", "missing arguments", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@SuppressLint("WrongConstant")
val lm = context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager
lm?.overrideLocaleConfig = LocaleConfig(LocaleList.forLanguageTags(locales.joinToString(",")))
}
result.success(true)
}
private fun getFirstDayOfWeek(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(LocalePreferences.getFirstDayOfWeek())
}
private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
if (performanceClass > 0) {
result.success(performanceClass)
return
}
}
result.success(Build.VERSION.SDK_INT)
}
private fun getWidgetCornerRadiusPx(@Suppress("unused_parameter") methodCall: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
result.success(context.resources.getDimension(android.R.dimen.system_app_widget_background_radius))
} else {
result.success(null)
}
}
private fun isLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
val isLocked = keyguardManager.isKeyguardLocked
result.success(isLocked)
}
private fun isSystemFilePickerEnabled(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val enabled = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).resolveActivity(context.packageManager) != null
result.success(enabled)
}
private fun requestMediaManagePermission(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
result.error("requestMediaManagePermission-unsupported", "media management permission is not available before Android 12", null)
return
}
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, "package:${context.packageName}".toUri())
context.startActivity(intent)
result.success(true)
}
private fun getAvailableHeapSize(@Suppress("unused_parameter") methodCall: MethodCall, result: MethodChannel.Result) {
result.success(MemoryUtils.getAvailableHeapSize())
}
private fun requestGarbageCollection(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
Runtime.getRuntime().gc()
result.success(true)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/device"
}
}

View file

@ -0,0 +1,361 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.xmp.GoogleDeviceContainer
import deckers.thibault.aves.metadata.xmp.GoogleXMP
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
import deckers.thibault.aves.metadata.xmp.XMPPropName
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.FileUtils.transferFrom
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.InputStream
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
"extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) }
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
"extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) }
"extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> ioScope.launch { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented()
}
}
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifThumbnails-args", "missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
if (canReadWithExifInterface(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
// do not recycle bitmaps fetched from `ExifInterface` as their lifecycle is unknown
BitmapUtils.getBytes(it, recycle = false, decoded = true, mimeType = null)?.let { bytes -> thumbnails.add(bytes) }
}
}
}
} catch (_: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
}
result.success(thumbnails)
}
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val dataUri = call.argument<String>("dataUri")
if (mimeType == null || uri == null || sizeBytes == null || dataUri == null) {
result.error("extractGoogleDeviceItem-args", "missing arguments", null)
return
}
var container: GoogleDeviceContainer? = null
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input, sizeBytes)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
container = xmpDirs.firstNotNullOfOrNull { GoogleXMP.getDeviceContainer(it.xmpMeta) }
} catch (e: XMPException) {
result.error("extractGoogleDeviceItem-xmp", "failed to read XMP directory for uri=$uri dataUri=$dataUri", e.message)
return
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
}
}
container?.let {
it.findOffsets(context, uri, mimeType, sizeBytes)
val index = it.itemIndex(dataUri)
val itemStartOffset = it.itemStartOffset(index)
val itemLength = it.itemLength(index)
val itemMimeType = it.itemMimeType(index)
if (itemStartOffset != null && itemLength != null && itemMimeType != null) {
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(itemStartOffset)
copyEmbeddedBytes(result, itemMimeType, displayName, input, itemLength)
return
}
}
}
result.error("extractGoogleDeviceItem-empty", "failed to extract item from Google Device XMP at uri=$uri dataUri=$dataUri", null)
}
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val id = call.argument<Int>("id")
if (mimeType == null || uri == null || sizeBytes == null || id == null) {
result.error("extractJpegMpfItem-args", "missing arguments", null)
return
}
val pageIndex = id - 1
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes)
if (mpEntries != null && pageIndex < mpEntries.size) {
val mpEntry = mpEntries[pageIndex]
mpEntry.mimeType?.let { embedMimeType ->
var dataOffset = mpEntry.dataOffset
if (dataOffset > 0) {
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
if (baseOffset != null) {
dataOffset += baseOffset
}
}
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(dataOffset)
copyEmbeddedBytes(result, embedMimeType, displayName, input, mpEntry.size)
}
return
}
}
result.error("extractJpegMpfItem-empty", "failed to extract file index=$id from MPF at uri=$uri", null)
}
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) {
result.error("extractMotionPhotoImage-args", "missing arguments", null)
return
}
MultiPage.getTrailerVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val imageSizeBytes = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
}
return
}
result.error("extractMotionPhotoImage-empty", "failed to extract image from motion photo at uri=$uri", null)
}
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) {
result.error("extractMotionPhotoVideo-args", "missing arguments", null)
return
}
MultiPage.getMotionPhotoVideoSizing(context, uri, mimeType, sizeBytes)?.let { (videoOffset, videoSize) ->
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(videoOffset)
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSize)
}
return
}
result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null)
}
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.toUri()
val displayName = call.argument<String>("displayName")
if (uri == null) {
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
return
}
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
retriever.embeddedPicture?.let { bytes ->
var embedMimeType: String? = null
bytes.inputStream().use { input ->
Helper.readMimeType(input)?.let { embedMimeType = it }
}
embedMimeType?.let { mime ->
copyEmbeddedBytes(result, mime, displayName, bytes.inputStream(), bytes.size.toLong())
return
}
}
} catch (e: Exception) {
result.error("extractVideoEmbeddedPicture-fetch", "failed to fetch picture for uri=$uri", e.message)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.error("extractVideoEmbeddedPicture-empty", "failed to extract picture for uri=$uri", null)
}
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName")
val dataProp = call.argument<List<Any>>("propPath")
val embedMimeType = call.argument<String>("propMimeType")
if (mimeType == null || uri == null || dataProp == null || embedMimeType == null) {
result.error("extractXmpDataProp-args", "missing arguments", null)
return
}
val props = dataProp.mapNotNull {
when (it) {
is List<*> -> XMPPropName(it.first() as String, it.last() as String)
is Int -> it
else -> null
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input, sizeBytes)
// data can be large and stored in "Extended XMP",
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
val embedBytes: ByteArray = if (props.size == 1) {
val prop = props.first() as XMPPropName
xmpDirs.firstNotNullOf { it.xmpMeta.getPropertyBase64(prop.nsUri, prop.toString()) }
} else {
xmpDirs.firstNotNullOf { it.xmpMeta.getSafeStructField(props) }.let {
XMPUtils.decodeBase64(it.value)
}
}
copyEmbeddedBytes(result, embedMimeType, displayName, embedBytes.inputStream(), embedBytes.size.toLong())
return
} catch (e: XMPException) {
result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataProp", e.message)
return
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to extract file from XMP", e)
}
}
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataProp", null)
}
private fun copyEmbeddedBytes(
result: MethodChannel.Result,
mimeType: String,
displayName: String?,
embeddedByteStream: InputStream,
embeddedByteLength: Long,
) {
val extension = extensionFor(mimeType, defaultExtension = null)
val targetFile = StorageUtils.createTempFile(context, extension).apply {
transferFrom(embeddedByteStream, embeddedByteLength)
}
val authority = "${context.applicationContext.packageName}.file_provider"
val uri = if (displayName != null) {
// add extension to ease type identification when sharing this content
val displayNameWithExtension = if (displayName.endsWith(extension, ignoreCase = true)) {
displayName
} else {
"$displayName$extension"
}
FileProvider.getUriForFile(context, authority, targetFile, displayNameWithExtension)
} else {
FileProvider.getUriForFile(context, authority, targetFile)
}
val resultFields: FieldMap = hashMapOf(
EntryFields.URI to uri.toString(),
EntryFields.MIME_TYPE to mimeType,
)
if (isImage(mimeType) || isVideo(mimeType)) {
val provider = getProvider(context, uri)
if (provider == null) {
result.error("copyEmbeddedBytes-provider", "failed to find provider for uri=$uri", null)
return
}
ioScope.launch {
provider.fetchSingle(context, uri, mimeType, false, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
resultFields.putAll(fields)
result.success(resultFields)
}
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message)
})
}
} else {
result.success(resultFields)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<EmbeddedDataHandler>()
const val CHANNEL = "deckers.thibault/aves/embedded"
}
}

View file

@ -0,0 +1,83 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.location.Address
import android.location.Geocoder
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.getFromLocationCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.Locale
// as of 2021/03/10, geocoding packages exist but:
// - `geocoder` is unmaintained
// - `geocoding` method does not return `addressLine` (v2.0.0)
class GeocodingHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var geocoder: Geocoder? = null
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAddress" -> ioScope.launch { safe(call, result, ::getAddress) }
else -> result.notImplemented()
}
}
private fun getAddress(call: MethodCall, result: MethodChannel.Result) {
val latitude = call.argument<Number>("latitude")?.toDouble()
val longitude = call.argument<Number>("longitude")?.toDouble()
val localeLanguageTag = call.argument<String>("localeLanguageTag")
val maxResults = call.argument<Int>("maxResults") ?: 1
if (latitude == null || longitude == null) {
result.error("getAddress-args", "missing arguments", null)
return
}
if (!Geocoder.isPresent()) {
result.error("getAddress-unavailable", "Geocoder is unavailable", null)
return
}
geocoder = geocoder ?: if (localeLanguageTag != null) {
Geocoder(context, Locale.forLanguageTag(localeLanguageTag))
} else {
Geocoder(context)
}
fun processAddresses(addresses: List<Address>) {
if (addresses.isEmpty()) {
result.error("getAddress-empty", "failed to find any address for latitude=$latitude, longitude=$longitude", null)
} else {
val addressMapList: ArrayList<Map<String, String?>> = ArrayList(addresses.map { address ->
hashMapOf(
"addressLine" to (0..address.maxAddressLineIndex).joinToString(", ") { i -> address.getAddressLine(i) },
"adminArea" to address.adminArea,
"countryCode" to address.countryCode,
"countryName" to address.countryName,
"featureName" to address.featureName,
"locality" to address.locality,
"postalCode" to address.postalCode,
"subAdminArea" to address.subAdminArea,
"subLocality" to address.subLocality,
"subThoroughfare" to address.subThoroughfare,
"thoroughfare" to address.thoroughfare,
)
})
result.success(addressMapList)
}
}
geocoder!!.getFromLocationCompat(
latitude, longitude, maxResults, ::processAddresses,
) { code, message, details -> result.error(code, message, details) }
}
companion object {
const val CHANNEL = "deckers.thibault/aves/geocoding"
}
}

View file

@ -0,0 +1,42 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import androidx.core.content.edit
import deckers.thibault.aves.SearchSuggestionsProvider
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"registerCallback" -> ioScope.launch { safe(call, result, ::registerCallback) }
else -> result.notImplemented()
}
}
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {
result.error("registerCallback-args", "missing arguments", null)
return
}
val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
preferences.edit {
putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
}
result.success(true)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/global_search"
}
}

View file

@ -0,0 +1,32 @@
package deckers.thibault.aves.channel.calls
import android.appwidget.AppWidgetManager
import android.content.Context
import deckers.thibault.aves.HomeWidgetProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class HomeWidgetHandler(private val context: Context) : MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"update" -> Coresult.safe(call, result, ::update)
else -> result.notImplemented()
}
}
private fun update(call: MethodCall, result: MethodChannel.Result) {
val widgetId = call.argument<Int>("widgetId")
if (widgetId == null) {
result.error("update-args", "missing arguments", null)
return
}
val appWidgetManager = AppWidgetManager.getInstance(context)
HomeWidgetProvider().onUpdate(context, appWidgetManager, intArrayOf(widgetId))
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/widget_update"
}
}

View file

@ -0,0 +1,77 @@
package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper
import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> ioScope.launch { safeSuspend(call, result, ::captureFrame) }
else -> result.notImplemented()
}
}
private fun cancelFileOp(call: MethodCall, result: MethodChannel.Result) {
val opId = call.argument<String>("opId")
if (opId == null) {
result.error("cancelFileOp-args", "missing arguments", null)
return
}
Log.i(LOG_TAG, "cancelling file op $opId")
cancelledOps.add(opId)
result.success(null)
}
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.toUri()
val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath")
val nameConflictStrategy = NameConflictStrategy.get(call.argument<String>("nameConflictStrategy"))
if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) {
result.error("captureFrame-args", "missing arguments", null)
return
}
val provider = getProvider(contextWrapper, uri)
if (provider == null) {
result.error("captureFrame-provider", "failed to find provider for uri=$uri", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(contextWrapper, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
})
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaEditHandler>()
const val CHANNEL = "deckers.thibault/aves/media_edit"
val cancelledOps = HashSet<String>()
}
}

View file

@ -0,0 +1,68 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
"clearImageDiskCache" -> ioScope.launch { safe(call, result, ::clearImageDiskCache) }
"clearImageMemoryCache" -> ioScope.launch { safe(call, result, ::clearImageMemoryCache) }
else -> result.notImplemented()
}
}
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") // MIME type is optional
val uri = call.argument<String>("uri")?.toUri()
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
if (uri == null) {
result.error("getEntry-args", "missing arguments", null)
return
}
val provider = getProvider(context, uri)
if (provider == null) {
result.error("getEntry-provider", "failed to find provider for uri=$uri mimeType=$mimeType", null)
return
}
provider.fetchSingle(context, uri, mimeType, allowUnsized, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri mimeType=$mimeType", throwable.message)
})
}
private fun clearImageDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
Glide.get(context).clearDiskCache()
result.success(null)
}
private fun clearImageMemoryCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
Handler(Looper.getMainLooper()).post {
Glide.get(context).clearMemory()
}
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/media_fetch_object"
}
}

View file

@ -0,0 +1,169 @@
package deckers.thibault.aves.channel.calls
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.media.session.PlaybackState
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.net.toUri
import androidx.media.session.MediaButtonReceiver
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.channel.streams.platformtodart.MediaCommandStreamHandler
import deckers.thibault.aves.utils.FlutterUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaSessionHandler(private val context: Context, private val mediaCommandHandler: MediaCommandStreamHandler) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var session: MediaSessionCompat? = null
private var wasPlaying = false
private var isNoisyAudioReceiverRegistered = false
private val noisyAudioReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
mediaCommandHandler.callback.onStop()
}
}
}
fun dispose() {
unregisterNoisyAudioReceiver()
}
private fun registerNoisyAudioReceiver() {
context.registerReceiver(noisyAudioReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
isNoisyAudioReceiverRegistered = true
}
private fun unregisterNoisyAudioReceiver() {
if (isNoisyAudioReceiverRegistered) {
context.unregisterReceiver(noisyAudioReceiver)
isNoisyAudioReceiverRegistered = false
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"update" -> ioScope.launch { safeSuspend(call, result, ::updateSession) }
"release" -> ioScope.launch { safe(call, result, ::releaseSession) }
else -> result.notImplemented()
}
}
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.toUri()
val title = call.argument<String>("title") ?: uri?.toString()
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
val stateString = call.argument<String>("state")
val positionMillis = call.argument<Number>("positionMillis")?.toLong()
val playbackSpeed = call.argument<Number>("playbackSpeed")?.toFloat()
val canSkipToNext = call.argument<Boolean>("canSkipToNext")
val canSkipToPrevious = call.argument<Boolean>("canSkipToPrevious")
if (uri == null || title == null || durationMillis == null || stateString == null || positionMillis == null || playbackSpeed == null || canSkipToNext == null || canSkipToPrevious == null) {
result.error(
"updateSession-args", "missing arguments: uri=$uri, title=$title, durationMillis=$durationMillis" +
", stateString=$stateString, positionMillis=$positionMillis, playbackSpeed=$playbackSpeed, canSkipToNext=$canSkipToNext, canSkipToPrevious=$canSkipToPrevious", null
)
return
}
val state = when (stateString) {
STATE_STOPPED -> PlaybackStateCompat.STATE_STOPPED
STATE_PAUSED -> PlaybackStateCompat.STATE_PAUSED
STATE_PLAYING -> PlaybackStateCompat.STATE_PLAYING
else -> {
result.error("updateSession-state", "unknown state=$stateString", null)
return
}
}
var actions = PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SEEK_TO
actions = if (state == PlaybackState.STATE_PLAYING) {
actions or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_STOP
} else {
actions or PlaybackStateCompat.ACTION_PLAY
}
if (canSkipToNext) {
actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
}
if (canSkipToPrevious) {
actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
}
val playbackState = PlaybackStateCompat.Builder()
.setState(
state,
positionMillis,
playbackSpeed,
System.currentTimeMillis()
)
.setActions(actions)
.build()
FlutterUtils.runOnUiThread {
try {
if (session == null) {
val mbrIntent = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
val mbrName = ComponentName(context, MediaButtonReceiver::class.java)
session = MediaSessionCompat(context, "aves", mbrName, mbrIntent).apply {
setCallback(mediaCommandHandler.callback)
}
}
session!!.apply {
val metadata = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMillis)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, uri.toString())
.build()
setMetadata(metadata)
setPlaybackState(playbackState)
if (!isActive) {
isActive = true
}
}
val isPlaying = state == PlaybackStateCompat.STATE_PLAYING
if (!wasPlaying && isPlaying) {
registerNoisyAudioReceiver()
} else if (wasPlaying && !isPlaying) {
unregisterNoisyAudioReceiver()
}
wasPlaying = isPlaying
result.success(null)
} catch (e: Exception) {
result.error("updateSession-exception", e.message, e.stackTraceToString())
}
}
}
private fun releaseSession(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
session?.let {
it.release()
session = null
}
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/media_session"
const val STATE_STOPPED = "stopped"
const val STATE_PAUSED = "paused"
const val STATE_PLAYING = "playing"
}
}

View file

@ -0,0 +1,85 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaStoreHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) }
"checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) }
"getChangedUris" -> ioScope.launch { safe(call, result, ::getChangedUris) }
"getGeneration" -> ioScope.launch { safe(call, result, ::getGeneration) }
"scanFile" -> ioScope.launch { safe(call, result, ::scanFile) }
else -> result.notImplemented()
}
}
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) {
val knownContentIds = call.argument<List<Number?>>("knownContentIds")?.map { it?.toLong() }
if (knownContentIds == null) {
result.error("checkObsoleteContentIds-args", "missing arguments", null)
return
}
result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds))
}
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
val knownPathById = call.argument<Map<Number?, String?>>("knownPathById")?.mapKeys { it.key?.toLong() }
if (knownPathById == null) {
result.error("checkObsoletePaths-args", "missing arguments", null)
return
}
result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById))
}
private fun getChangedUris(call: MethodCall, result: MethodChannel.Result) {
val sinceGeneration = call.argument<Int>("sinceGeneration")
if (sinceGeneration == null) {
result.error("getChangedUris-args", "missing arguments", null)
return
}
val uris = MediaStoreImageProvider().getChangedUris(context, sinceGeneration)
result.success(uris)
}
private fun getGeneration(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val generation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
MediaStore.getGeneration(context, MediaStore.VOLUME_EXTERNAL_PRIMARY)
} catch (e: Exception) {
// may yield `IllegalArgumentException: Volume external_primary not found`
val volumes = MediaStore.getExternalVolumeNames(context).joinToString(", ")
result.error("getGeneration-primary", e.message + " (available volumes are [$volumes])", e)
return
}
} else {
null
}
result.success(generation)
}
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType")
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
}
companion object {
const val CHANNEL = "deckers.thibault/aves/media_store"
}
}

View file

@ -0,0 +1,213 @@
package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.Mp4FragmentedException
import deckers.thibault.aves.metadata.Mp4TooLargeException
import deckers.thibault.aves.metadata.Mp4ZeroSizeBoxException
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.FileDescriptorException
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"rotate" -> ioScope.launch { safe(call, result, ::rotate) }
"flip" -> ioScope.launch { safe(call, result, ::flip) }
"editDate" -> ioScope.launch { safe(call, result, ::editDate) }
"editMetadata" -> ioScope.launch { safe(call, result, ::editMetadata) }
"removeTrailerVideo" -> ioScope.launch { safe(call, result, ::removeTrailerVideo) }
"removeTypes" -> ioScope.launch { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
}
private fun rotate(call: MethodCall, result: MethodChannel.Result) {
val clockwise = call.argument<Boolean>("clockwise")
if (clockwise == null) {
result.error("rotate-args", "missing arguments", null)
return
}
val op = if (clockwise) ExifOrientationOp.ROTATE_CW else ExifOrientationOp.ROTATE_CCW
editOrientation(call, result, op)
}
private fun flip(call: MethodCall, result: MethodChannel.Result) {
editOrientation(call, result, ExifOrientationOp.FLIP)
}
private fun editOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("editOrientation-args", "missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("editOrientation-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(contextWrapper, uri)
if (provider == null) {
result.error("editOrientation-provider", "failed to find provider for uri=$uri", null)
return
}
val callback = MetadataOpCallback("editOrientation", entryMap, result)
provider.editOrientation(contextWrapper, path, uri, mimeType, op, callback)
}
private fun editDate(call: MethodCall, result: MethodChannel.Result) {
val dateMillis = call.argument<Number>("dateMillis")?.toLong()
val shiftSeconds = call.argument<Number>("shiftSeconds")?.toLong()
val fields = call.argument<List<String>>("fields")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || fields == null) {
result.error("editDate-args", "missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("editDate-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(contextWrapper, uri)
if (provider == null) {
result.error("editDate-provider", "failed to find provider for uri=$uri", null)
return
}
val callback = MetadataOpCallback("editDate", entryMap, result)
provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftSeconds, fields, callback)
}
private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
val metadata = call.argument<FieldMap>("metadata")
val entryMap = call.argument<FieldMap>("entry")
val autoCorrectTrailerOffset = call.argument<Boolean>("autoCorrectTrailerOffset")
if (entryMap == null || metadata == null || autoCorrectTrailerOffset == null) {
result.error("editMetadata-args", "missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("editMetadata-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(contextWrapper, uri)
if (provider == null) {
result.error("editMetadata-provider", "failed to find provider for uri=$uri", null)
return
}
val callback = MetadataOpCallback("editMetadata", entryMap, result)
provider.editMetadata(contextWrapper, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback)
}
private fun removeTrailerVideo(call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("removeTrailerVideo-args", "missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("removeTrailerVideo-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(contextWrapper, uri)
if (provider == null) {
result.error("removeTrailerVideo-provider", "failed to find provider for uri=$uri", null)
return
}
val callback = MetadataOpCallback("removeTrailerVideo", entryMap, result)
provider.removeTrailerVideo(contextWrapper, path, uri, mimeType, callback)
}
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || types == null) {
result.error("removeTypes-args", "missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("removeTypes-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(contextWrapper, uri)
if (provider == null) {
result.error("removeTypes-provider", "failed to find provider for uri=$uri", null)
return
}
val callback = MetadataOpCallback("removeTypes", entryMap, result)
provider.removeMetadataTypes(contextWrapper, path, uri, mimeType, types.toSet(), callback)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/metadata_edit"
}
}
private class MetadataOpCallback(
private val errorCodeBase: String,
private val entryMap: FieldMap,
private val result: MethodChannel.Result,
) : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) {
val errorCode = if (throwable is Mp4TooLargeException) {
if (throwable.type == "moov") {
"$errorCodeBase-mp4largemoov"
} else {
"$errorCodeBase-mp4largeother"
}
} else if (throwable is Mp4FragmentedException) {
"$errorCodeBase-mp4fragmented"
} else if (throwable is Mp4ZeroSizeBoxException) {
"$errorCodeBase-mp4zerosizebox"
} else if (throwable is FileNotFoundException || throwable is FileDescriptorException) {
"$errorCodeBase-filenotfound"
} else {
"$errorCodeBase-failure"
}
result.error(errorCode, "failed for entry=$entryMap", throwable)
}
}

View file

@ -0,0 +1,80 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class SecurityHandler(private val context: Context) : MethodCallHandler {
private var sharedPreferences: SharedPreferences? = null
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"writeValue" -> safe(call, result, ::writeValue)
"readValue" -> safe(call, result, ::readValue)
else -> result.notImplemented()
}
}
private fun getStore(): SharedPreferences {
if (sharedPreferences == null) {
val mainKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
sharedPreferences = EncryptedSharedPreferences.create(
context,
FILENAME,
mainKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
return sharedPreferences!!
}
private fun writeValue(call: MethodCall, result: MethodChannel.Result) {
val key = call.argument<String>("key")
val value = call.argument<Any?>("value")
if (key == null) {
result.error("writeValue-args", "missing arguments", null)
return
}
val preferences = getStore()
preferences.edit {
when (value) {
is Boolean -> putBoolean(key, value)
is Float -> putFloat(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is String -> putString(key, value)
null -> remove(key)
else -> {
result.error("writeValue-type", "unsupported type for value=$value", null)
return
}
}
}
result.success(true)
}
private fun readValue(call: MethodCall, result: MethodChannel.Result) {
val key = call.argument<String>("key")
if (key == null) {
result.error("readValue-args", "missing arguments", null)
return
}
result.success(getStore().all[key])
}
companion object {
const val CHANNEL = "deckers.thibault/aves/security"
const val FILENAME = "secret_shared_prefs"
}
}

View file

@ -0,0 +1,435 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.getFolderSize
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.util.PathUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
class StorageHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
"getCacheDirectory" -> ioScope.launch { safe(call, result, ::getCacheDirectory) }
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) }
"getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) }
"deleteTempDirectory" -> ioScope.launch { safe(call, result, ::deleteTempDirectory) }
"deleteExternalCache" -> ioScope.launch { safe(call, result, ::deleteExternalCache) }
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
// Activity library SAF support
"listActivityFiles" -> ioScope.launch { safe(call, result, ::listActivityFiles) }
"readActivityFile" -> ioScope.launch { safe(call, result, ::readActivityFile) }
"ensureActivityMainDirectory" -> ioScope.launch { safe(call, result, ::ensureActivityMainDirectory) }
else -> result.notImplemented()
}
}
private fun getDataUsage(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var internalCache = getFolderSize(context.cacheDir)
internalCache += getFolderSize(context.codeCacheDir)
val externalCache = context.externalCacheDirs.sumOf(::getFolderSize)
val externalFilesDirs = context.getExternalFilesDirs(null)
val dataDir = context.dataDir
val database = getFolderSize(File(dataDir, "databases"))
val flutter = getFolderSize(File(PathUtils.getDataDirectory(context)))
val vaults = getFolderSize(File(StorageUtils.getVaultRoot(context)))
val trash = externalFilesDirs
.mapNotNull { StorageUtils.trashDirFor(context, it.path) }
.sumOf(::getFolderSize)
val internalData = getFolderSize(dataDir) - internalCache
val externalData = externalFilesDirs.sumOf(::getFolderSize)
val miscData = internalData + externalData - (database + flutter + vaults + trash)
result.success(
hashMapOf(
"database" to database,
"flutter" to flutter,
"vaults" to vaults,
"trash" to trash,
"miscData" to miscData,
"internalCache" to internalCache,
"externalCache" to externalCache,
)
)
}
private fun getStorageVolumes(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val volumes = ArrayList<Map<String, Any>>()
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {
sm.getStorageVolume(File(volumePath))?.let {
volumes.add(
hashMapOf(
"path" to volumePath,
"description" to it.getDescription(context),
"isPrimary" to it.isPrimary,
"isRemovable" to it.isRemovable,
"state" to it.state,
)
)
}
} catch (_: Exception) {
// ignore
}
}
}
result.success(volumes)
}
private fun getCacheDirectory(call: MethodCall, result: MethodChannel.Result) {
val external = call.argument<Boolean>("external")
if (external == null) {
result.error("getCacheDirectory-args", "missing arguments", null)
return
}
val dir = if (external) context.externalCacheDir else context.cacheDir
if (dir == null) {
result.error("getCacheDirectory-null", "context cache dir is null", null)
return
}
result.success(dir.path)
}
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
val knownPaths = call.argument<List<String>>("knownPaths")
if (knownPaths == null) {
result.error("getUntrackedTrashPaths-args", "missing arguments", null)
return
}
val trashDirs = context.getExternalFilesDirs(null)
.filterNotNull()
.mapNotNull { StorageUtils.trashDirFor(context, it.path) }
val trashItemPaths = trashDirs.flatMap { dir ->
dir.listFiles()
?.filterNotNull()
?.mapNotNull { file -> file.path }
?: listOf()
}
val untrackedPaths = trashItemPaths
.filterNot(knownPaths::contains)
.toList()
result.success(untrackedPaths)
}
private fun getUntrackedVaultPaths(call: MethodCall, result: MethodChannel.Result) {
val vault = call.argument<String>("vault")
val knownPaths = call.argument<List<String>>("knownPaths")
if (vault == null || knownPaths == null) {
result.error("getUntrackedVaultPaths-args", "missing arguments", null)
return
}
val vaultDir = File(StorageUtils.getVaultRoot(context), vault)
val vaultItemPaths = vaultDir
.listFiles()
?.mapNotNull { file -> file?.path }
?: listOf()
val untrackedPaths = vaultItemPaths
.filterNot(knownPaths::contains)
.toList()
result.success(untrackedPaths)
}
private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(StorageUtils.getVaultRoot(context))
}
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
if (path == null) {
result.error("getFreeSpace-args", "missing arguments", null)
return
}
// `StorageStatsManager` `getFreeBytes()` is only available from API 26,
// and non-primary volume UUIDs cannot be used with it
val file = File(path)
try {
result.success(file.freeSpace)
} catch (e: SecurityException) {
result.error("getFreeSpace-security", "failed because of missing access", e.message)
}
}
private fun getGrantedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
}
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) {
result.error("getInaccessibleDirectories-args", "missing arguments", null)
return
}
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
}
private fun getRestrictedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(PermissionManager.getRestrictedDirectories(context))
}
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
if (path == null) {
result.error("revokeDirectoryAccess-args", "missing arguments", null)
return
}
val success = PermissionManager.revokeDirectoryAccess(context, path)
result.success(success)
}
private fun deleteEmptyDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) {
result.error("deleteEmptyDirectories-args", "missing arguments", null)
return
}
var deleted = 0
dirPaths.forEach {
try {
val dir = File(it)
if (dir.isDirectory && dir.listFiles()?.isEmpty() == true && dir.delete()) {
deleted++
}
} catch (_: SecurityException) {
// ignore
}
}
result.success(deleted)
}
private fun deleteTempDirectory(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(StorageUtils.deleteTempDirectory(context))
}
private fun deleteExternalCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
context.externalCacheDirs
.filter { it.exists() }
.forEach { it.deleteRecursively() }
result.success(true)
}
private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
}
private fun canInsertMedia(call: MethodCall, result: MethodChannel.Result) {
val directories = call.argument<List<FieldMap>>("directories")
if (directories == null) {
result.error("canInsertMedia-args", "missing arguments", null)
return
}
result.success(PermissionManager.canInsertByMediaStore(directories))
}
/**
* Lists FIT/GPX/TCX files recursively under a SAF tree URI.
*
* Input:
* treeUri: String
*
* Output:
* List<Map<String, Any?>>
* [
* {
* "uri": "content://...",
* "name": "activity.fit",
* "relativePath": "Garmin/2026",
* "size": 123456,
* "lastModified": 1714026913000
* }
* ]
*/
private fun listActivityFiles(call: MethodCall, result: MethodChannel.Result) {
val treeUriString = call.argument<String>("treeUri")
if (treeUriString == null) {
result.error("listActivityFiles-args", "missing treeUri", null)
return
}
val treeUri = Uri.parse(treeUriString)
val root = DocumentFileCompat.fromTreeUri(context, treeUri)
if (root == null || !root.exists() || !root.isDirectory) {
result.success(emptyList<Map<String, Any?>>())
return
}
val rows = ArrayList<Map<String, Any?>>()
fun scanDirectory(directory: DocumentFileCompat, relativePath: String) {
for (document in directory.listFiles()) {
val name = document.name ?: continue
if (document.isDirectory) {
val childRelativePath = if (relativePath.isEmpty()) {
name
} else {
"$relativePath/$name"
}
scanDirectory(document, childRelativePath)
} else if (document.isFile) {
val lower = name.lowercase(Locale.ROOT)
if (
lower.endsWith(".fit") ||
lower.endsWith(".gpx") ||
lower.endsWith(".tcx")
) {
rows.add(
hashMapOf(
"uri" to document.uri.toString(),
"name" to name,
"relativePath" to relativePath,
"size" to document.length(),
"lastModified" to document.lastModified(),
)
)
}
}
}
}
scanDirectory(root, "")
result.success(rows)
}
/**
* Reads an activity file from a SAF/content URI.
*
* Input:
* uri: String
*
* Output:
* ByteArray
*/
private fun readActivityFile(call: MethodCall, result: MethodChannel.Result) {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("readActivityFile-args", "missing uri", null)
return
}
try {
val uri = Uri.parse(uriString)
val bytes = context.contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: ByteArray(0)
result.success(bytes)
} catch (e: Exception) {
result.error(
"readActivityFile-read",
"failed to read activity file uri=$uriString",
e.message,
)
}
}
/**
* Ensures the Activity/main subdirectory exists under the selected
* Activity root SAF tree URI.
*
* Input:
* treeUri: String
*
* Output:
* Boolean
*/
private fun ensureActivityMainDirectory(call: MethodCall, result: MethodChannel.Result) {
val treeUriString = call.argument<String>("treeUri")
if (treeUriString == null) {
result.error("ensureActivityMainDirectory-args", "missing treeUri", null)
return
}
try {
val treeUri = Uri.parse(treeUriString)
val root = DocumentFileCompat.fromTreeUri(context, treeUri)
if (root == null || !root.exists() || !root.isDirectory) {
result.success(false)
return
}
val existingMain = root.listFiles().firstOrNull { document ->
document.isDirectory && document.name.equals("main", ignoreCase = true)
}
if (existingMain != null && existingMain.exists() && existingMain.isDirectory) {
result.success(true)
return
}
val created = root.createDirectory("main")
result.success(created != null && created.exists() && created.isDirectory)
} catch (e: Exception) {
result.error(
"ensureActivityMainDirectory-create",
"failed to create Activity/main for treeUri=$treeUriString",
e.message,
)
}
}
companion object {
const val CHANNEL = "deckers.thibault/aves/storage"
}
}

View file

@ -0,0 +1,388 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.getFolderSize
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.util.PathUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
class StorageHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
"getCacheDirectory" -> ioScope.launch { safe(call, result, ::getCacheDirectory) }
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) }
"getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) }
"deleteTempDirectory" -> ioScope.launch { safe(call, result, ::deleteTempDirectory) }
"deleteExternalCache" -> ioScope.launch { safe(call, result, ::deleteExternalCache) }
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
// Activity library SAF support
"listActivityFiles" -> ioScope.launch { safe(call, result, ::listActivityFiles) }
"readActivityFile" -> ioScope.launch { safe(call, result, ::readActivityFile) }
else -> result.notImplemented()
}
}
private fun getDataUsage(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var internalCache = getFolderSize(context.cacheDir)
internalCache += getFolderSize(context.codeCacheDir)
val externalCache = context.externalCacheDirs.sumOf(::getFolderSize)
val externalFilesDirs = context.getExternalFilesDirs(null)
val dataDir = context.dataDir
val database = getFolderSize(File(dataDir, "databases"))
val flutter = getFolderSize(File(PathUtils.getDataDirectory(context)))
val vaults = getFolderSize(File(StorageUtils.getVaultRoot(context)))
val trash = externalFilesDirs
.mapNotNull { StorageUtils.trashDirFor(context, it.path) }
.sumOf(::getFolderSize)
val internalData = getFolderSize(dataDir) - internalCache
val externalData = externalFilesDirs.sumOf(::getFolderSize)
val miscData = internalData + externalData - (database + flutter + vaults + trash)
result.success(
hashMapOf(
"database" to database,
"flutter" to flutter,
"vaults" to vaults,
"trash" to trash,
"miscData" to miscData,
"internalCache" to internalCache,
"externalCache" to externalCache,
)
)
}
private fun getStorageVolumes(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val volumes = ArrayList<Map<String, Any>>()
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {
sm.getStorageVolume(File(volumePath))?.let {
volumes.add(
hashMapOf(
"path" to volumePath,
"description" to it.getDescription(context),
"isPrimary" to it.isPrimary,
"isRemovable" to it.isRemovable,
"state" to it.state,
)
)
}
} catch (_: Exception) {
// ignore
}
}
}
result.success(volumes)
}
private fun getCacheDirectory(call: MethodCall, result: MethodChannel.Result) {
val external = call.argument<Boolean>("external")
if (external == null) {
result.error("getCacheDirectory-args", "missing arguments", null)
return
}
val dir = if (external) context.externalCacheDir else context.cacheDir
if (dir == null) {
result.error("getCacheDirectory-null", "context cache dir is null", null)
return
}
result.success(dir.path)
}
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
val knownPaths = call.argument<List<String>>("knownPaths")
if (knownPaths == null) {
result.error("getUntrackedTrashPaths-args", "missing arguments", null)
return
}
val trashDirs = context.getExternalFilesDirs(null)
.filterNotNull()
.mapNotNull { StorageUtils.trashDirFor(context, it.path) }
val trashItemPaths = trashDirs.flatMap { dir ->
dir.listFiles()
?.filterNotNull()
?.mapNotNull { file -> file.path }
?: listOf()
}
val untrackedPaths = trashItemPaths
.filterNot(knownPaths::contains)
.toList()
result.success(untrackedPaths)
}
private fun getUntrackedVaultPaths(call: MethodCall, result: MethodChannel.Result) {
val vault = call.argument<String>("vault")
val knownPaths = call.argument<List<String>>("knownPaths")
if (vault == null || knownPaths == null) {
result.error("getUntrackedVaultPaths-args", "missing arguments", null)
return
}
val vaultDir = File(StorageUtils.getVaultRoot(context), vault)
val vaultItemPaths = vaultDir
.listFiles()
?.mapNotNull { file -> file?.path }
?: listOf()
val untrackedPaths = vaultItemPaths
.filterNot(knownPaths::contains)
.toList()
result.success(untrackedPaths)
}
private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(StorageUtils.getVaultRoot(context))
}
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
if (path == null) {
result.error("getFreeSpace-args", "missing arguments", null)
return
}
// `StorageStatsManager` `getFreeBytes()` is only available from API 26,
// and non-primary volume UUIDs cannot be used with it
val file = File(path)
try {
result.success(file.freeSpace)
} catch (e: SecurityException) {
result.error("getFreeSpace-security", "failed because of missing access", e.message)
}
}
private fun getGrantedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
}
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) {
result.error("getInaccessibleDirectories-args", "missing arguments", null)
return
}
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
}
private fun getRestrictedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(PermissionManager.getRestrictedDirectories(context))
}
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
if (path == null) {
result.error("revokeDirectoryAccess-args", "missing arguments", null)
return
}
val success = PermissionManager.revokeDirectoryAccess(context, path)
result.success(success)
}
private fun deleteEmptyDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) {
result.error("deleteEmptyDirectories-args", "missing arguments", null)
return
}
var deleted = 0
dirPaths.forEach {
try {
val dir = File(it)
if (dir.isDirectory && dir.listFiles()?.isEmpty() == true && dir.delete()) {
deleted++
}
} catch (_: SecurityException) {
// ignore
}
}
result.success(deleted)
}
private fun deleteTempDirectory(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(StorageUtils.deleteTempDirectory(context))
}
private fun deleteExternalCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
context.externalCacheDirs
.filter { it.exists() }
.forEach { it.deleteRecursively() }
result.success(true)
}
private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
}
private fun canInsertMedia(call: MethodCall, result: MethodChannel.Result) {
val directories = call.argument<List<FieldMap>>("directories")
if (directories == null) {
result.error("canInsertMedia-args", "missing arguments", null)
return
}
result.success(PermissionManager.canInsertByMediaStore(directories))
}
/**
* Lists FIT/GPX/TCX files recursively under a SAF tree URI.
*
* Input:
* treeUri: String
*
* Output:
* List<Map<String, Any?>>
* [
* {
* "uri": "content://...",
* "name": "activity.fit",
* "relativePath": "Garmin/2026",
* "size": 123456,
* "lastModified": 1714026913000
* }
* ]
*/
private fun listActivityFiles(call: MethodCall, result: MethodChannel.Result) {
val treeUriString = call.argument<String>("treeUri")
if (treeUriString == null) {
result.error("listActivityFiles-args", "missing treeUri", null)
return
}
val treeUri = Uri.parse(treeUriString)
val root = DocumentFileCompat.fromTreeUri(context, treeUri)
if (root == null || !root.exists() || !root.isDirectory) {
result.success(emptyList<Map<String, Any?>>())
return
}
val rows = ArrayList<Map<String, Any?>>()
fun scanDirectory(directory: DocumentFileCompat, relativePath: String) {
for (document in directory.listFiles()) {
val name = document.name ?: continue
if (document.isDirectory) {
val childRelativePath = if (relativePath.isEmpty()) {
name
} else {
"$relativePath/$name"
}
scanDirectory(document, childRelativePath)
} else if (document.isFile) {
val lower = name.lowercase(Locale.ROOT)
if (
lower.endsWith(".fit") ||
lower.endsWith(".gpx") ||
lower.endsWith(".tcx")
) {
rows.add(
hashMapOf(
"uri" to document.uri.toString(),
"name" to name,
"relativePath" to relativePath,
"size" to document.length(),
"lastModified" to document.lastModified(),
)
)
}
}
}
}
scanDirectory(root, "")
result.success(rows)
}
/**
* Reads an activity file from a SAF/content URI.
*
* Input:
* uri: String
*
* Output:
* ByteArray
*/
private fun readActivityFile(call: MethodCall, result: MethodChannel.Result) {
val uriString = call.argument<String>("uri")
if (uriString == null) {
result.error("readActivityFile-args", "missing uri", null)
return
}
try {
val uri = Uri.parse(uriString)
val bytes = context.contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: ByteArray(0)
result.success(bytes)
} catch (e: Exception) {
result.error(
"readActivityFile-read",
"failed to read activity file uri=$uriString",
e.message,
)
}
}
companion object {
const val CHANNEL = "deckers.thibault/aves/storage"
}
}

View file

@ -0,0 +1,51 @@
package deckers.thibault.aves.channel.calls
import android.app.WallpaperManager
import android.app.WallpaperManager.FLAG_LOCK
import android.app.WallpaperManager.FLAG_SYSTEM
import android.content.ContextWrapper
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WallpaperHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"setWallpaper" -> ioScope.launch { safe(call, result, ::setWallpaper) }
else -> result.notImplemented()
}
}
private fun setWallpaper(call: MethodCall, result: MethodChannel.Result) {
val bytes = call.argument<ByteArray>("bytes")
val home = call.argument<Boolean>("home")
val lock = call.argument<Boolean>("lock")
if (bytes == null || home == null || lock == null) {
result.error("setWallpaper-args", "missing arguments", null)
return
}
val manager = WallpaperManager.getInstance(contextWrapper)
if (!manager.isWallpaperSupported || !manager.isSetWallpaperAllowed) {
result.error("setWallpaper-unsupported", "failed because setting wallpaper is not allowed", null)
return
}
bytes.inputStream().use { input ->
val flags = (if (home) FLAG_SYSTEM else 0) or (if (lock) FLAG_LOCK else 0)
manager.setStream(input, null, true, flags)
}
result.success(true)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/wallpaper"
}
}

View file

@ -0,0 +1,209 @@
package deckers.thibault.aves.channel.calls.window
import android.app.Activity
import android.content.ClipData
import android.content.Context
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Matrix.ScaleToFit
import android.graphics.Point
import android.graphics.RectF
import android.os.Build
import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.AppAdapterHandler.Companion.getShareableUri
import deckers.thibault.aves.utils.ContextUtils.devicePixelRatio
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getDisplayCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.nio.ByteBuffer
import kotlin.math.roundToInt
class ActivityWindowHandler(private val activity: Activity) : WindowHandler(activity) {
override fun isActivity(call: MethodCall, result: MethodChannel.Result) {
result.success(true)
}
private fun setWindowFlag(call: MethodCall, result: MethodChannel.Result, flag: Int) {
val on = call.argument<Boolean>("on")
if (on == null) {
result.error("keepOn-args", "missing arguments", null)
return
}
val window = activity.window
val old = (window.attributes.flags and flag) != 0
if (old != on) {
if (on) {
window.addFlags(flag)
} else {
window.clearFlags(flag)
}
}
result.success(null)
}
override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun secureScreen(call: MethodCall, result: MethodChannel.Result) {
setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_SECURE)
}
override fun isInMultiWindowMode(call: MethodCall, result: MethodChannel.Result) {
result.success(activity.isInMultiWindowMode)
}
override fun isInPictureInPictureMode(call: MethodCall, result: MethodChannel.Result) {
result.success(activity.isInPictureInPictureMode)
}
// display orientation in degrees
override fun getOrientation(call: MethodCall, result: MethodChannel.Result) {
val displayRotation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.display.rotation
} else {
val windowService = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("deprecation")
windowService.defaultDisplay.rotation
}
result.success(displayRotation * 90)
}
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
val orientation = call.argument<Int>("orientation")
if (orientation == null) {
result.error("requestOrientation-args", "missing arguments", null)
return
}
activity.requestedOrientation = orientation
result.success(true)
}
override fun isCutoutAware(call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
}
override fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result) {
val safeInsetsDpi = getCutoutInsetsDpi(activity)
result.success(
hashMapOf(
"left" to safeInsetsDpi.left,
"top" to safeInsetsDpi.top,
"right" to safeInsetsDpi.right,
"bottom" to safeInsetsDpi.bottom,
)
)
}
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.resources.configuration.isScreenWideColorGamut)
}
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.resources.configuration.isScreenHdr)
}
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
val wideColorGamut = call.argument<Boolean>("wideColorGamut")
val hdr = call.argument<Boolean>("hdr")
if (wideColorGamut == null || hdr == null) {
result.error("setColorMode-args", "missing arguments", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.window.colorMode = if (hdr) {
ActivityInfo.COLOR_MODE_HDR
} else if (wideColorGamut) {
ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
} else {
ActivityInfo.COLOR_MODE_DEFAULT
}
}
result.success(null)
}
override fun startGlobalDrag(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.toUri()
val label = call.argument<String>("label")
val shadowWidthDip = call.argument<Number>("shadowWidthDip")?.toFloat()
val shadowHeightDip = call.argument<Number>("shadowHeightDip")?.toFloat()
val shadowBytes = call.argument<ByteArray>("shadowBytes")
if (uri == null || shadowWidthDip == null || shadowHeightDip == null || shadowBytes == null) {
result.error("startGlobalDrag-args", "missing arguments", null)
return
}
val clip = ClipData.newUri(activity.contentResolver, label, getShareableUri(activity, uri))
val density = activity.devicePixelRatio()
val widthPx = (shadowWidthDip * density).roundToInt()
val heightPx = (shadowHeightDip * density).roundToInt()
val shadowBuilder: View.DragShadowBuilder = try {
val bitmap = createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
it.copyPixelsFromBuffer(ByteBuffer.wrap(shadowBytes))
}
val scaleToFit = Matrix()
val src = RectF(0f, 0f, bitmap.getWidth().toFloat(), bitmap.getHeight().toFloat())
val dst = RectF(0f, 0f, heightPx.toFloat(), heightPx.toFloat())
scaleToFit.setRectToRect(src, dst, ScaleToFit.CENTER)
object : View.DragShadowBuilder() {
override fun onProvideShadowMetrics(outShadowSize: Point, outShadowTouchPoint: Point) {
outShadowSize.set(widthPx, heightPx)
outShadowTouchPoint.set(outShadowSize.x / 2, outShadowSize.y / 2)
}
override fun onDrawShadow(canvas: Canvas) {
canvas.drawBitmap(bitmap, scaleToFit, null)
}
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e)
View.DragShadowBuilder()
}
activity.window.decorView.startDragAndDrop(
clip,
shadowBuilder,
null,
View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ
)
}
companion object {
private val LOG_TAG = LogUtils.createTag<ActivityWindowHandler>()
fun getCutoutInsetsDpi(activity: Activity): RectF {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val cutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
activity.getDisplayCompat()?.cutout
} else {
activity.window.decorView.rootWindowInsets.displayCutout
}
if (cutout != null) {
val density = activity.devicePixelRatio()
return RectF(
cutout.safeInsetLeft / density,
cutout.safeInsetTop / density,
cutout.safeInsetRight / density,
cutout.safeInsetBottom / density
)
}
}
return RectF()
}
}
}

View file

@ -0,0 +1,59 @@
package deckers.thibault.aves.channel.calls.window
import android.app.Service
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class ServiceWindowHandler(service: Service) : WindowHandler(service) {
override fun isActivity(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
result.success(null)
}
override fun secureScreen(call: MethodCall, result: MethodChannel.Result) {
result.success(null)
}
override fun isInMultiWindowMode(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun isInPictureInPictureMode(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun getOrientation(call: MethodCall, result: MethodChannel.Result) {
result.success(0)
}
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun isCutoutAware(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result) {
result.success(HashMap<String, Any>())
}
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
result.success(null)
}
override fun startGlobalDrag(call: MethodCall, result: MethodChannel.Result) {
result.success(null)
}
}

View file

@ -0,0 +1,72 @@
package deckers.thibault.aves.channel.calls.window
import android.content.ContextWrapper
import android.provider.Settings
import android.util.Log
import deckers.thibault.aves.channel.calls.Coresult
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
abstract class WindowHandler(private val contextWrapper: ContextWrapper) : MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isActivity" -> Coresult.safe(call, result, ::isActivity)
"keepScreenOn" -> Coresult.safe(call, result, ::keepScreenOn)
"secureScreen" -> Coresult.safe(call, result, ::secureScreen)
"isInMultiWindowMode" -> Coresult.safe(call, result, ::isInMultiWindowMode)
"isInPictureInPictureMode" -> Coresult.safe(call, result, ::isInPictureInPictureMode)
"isRotationLocked" -> Coresult.safe(call, result, ::isRotationLocked)
"getOrientation" -> Coresult.safe(call, result, ::getOrientation)
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
"supportsWideGamut" -> Coresult.safe(call, result, ::supportsWideGamut)
"supportsHdr" -> Coresult.safe(call, result, ::supportsHdr)
"setColorMode" -> Coresult.safe(call, result, ::setColorMode)
"startGlobalDrag" -> Coresult.safe(call, result, ::startGlobalDrag)
else -> result.notImplemented()
}
}
abstract fun isActivity(call: MethodCall, result: MethodChannel.Result)
abstract fun keepScreenOn(call: MethodCall, result: MethodChannel.Result)
abstract fun secureScreen(call: MethodCall, result: MethodChannel.Result)
abstract fun isInMultiWindowMode(call: MethodCall, result: MethodChannel.Result)
abstract fun isInPictureInPictureMode(call: MethodCall, result: MethodChannel.Result)
private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var locked = false
try {
locked = Settings.System.getInt(contextWrapper.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
result.success(locked)
}
abstract fun getOrientation(call: MethodCall, result: MethodChannel.Result)
abstract fun requestOrientation(call: MethodCall, result: MethodChannel.Result)
abstract fun isCutoutAware(call: MethodCall, result: MethodChannel.Result)
abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result)
abstract fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result)
abstract fun supportsHdr(call: MethodCall, result: MethodChannel.Result)
abstract fun setColorMode(call: MethodCall, result: MethodChannel.Result)
abstract fun startGlobalDrag(call: MethodCall, result: MethodChannel.Result)
companion object {
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
const val CHANNEL = "deckers.thibault/aves/window"
}
}

View file

@ -0,0 +1,108 @@
package deckers.thibault.aves.channel.streams
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.utils.MemoryUtils
import io.flutter.plugin.common.EventChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import java.io.InputStream
abstract class BaseStreamHandler : EventChannel.StreamHandler {
val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// cannot use `lateinit` because we cannot guarantee
// its initialization in `onListen` at the right time
// e.g. when resuming the app after the activity got destroyed
private var eventSink: EventChannel.EventSink? = null
private var handler: Handler? = null
override fun onListen(arguments: Any?, eventSink: EventChannel.EventSink) {
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
onCall(arguments)
}
override fun onCancel(arguments: Any?) {
// nothing
}
open fun success(event: Any?) {
handler?.post {
try {
eventSink?.success(event)
} catch (e: Exception) {
Log.w(logTag, "failed to use event sink", e)
}
}
}
open fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
handler?.post {
try {
eventSink?.error(errorCode, errorMessage, errorDetails)
} catch (e: Exception) {
Log.w(logTag, "failed to use event sink", e)
}
}
}
open fun endOfStream() {
handler?.post {
try {
eventSink?.endOfStream()
} catch (e: Exception) {
Log.w(logTag, "failed to use event sink", e)
}
}
}
fun streamBytes(inputStream: InputStream): Boolean {
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (inputStream.read(buffer).also { len = it } != -1) {
// cannot decode image on Flutter side when using `buffer` directly
if (MemoryUtils.canAllocate(len)) {
success(buffer.copyOf(len))
} else {
error("streamBytes-memory", "not enough memory to allocate $len bytes", null)
return false
}
}
return true
}
fun safe(function: () -> Unit, closeStream: Boolean = true) {
try {
function()
} catch (e: Exception) {
error("safe-exception", e.message, e.stackTraceToString())
}
if (closeStream) {
endOfStream()
}
}
suspend fun safeSuspend(function: suspend () -> Unit, closeStream: Boolean = true) {
try {
function()
} catch (e: Exception) {
error("safeSuspend-exception", e.message, e.stackTraceToString())
}
if (closeStream) {
endOfStream()
}
}
abstract val logTag: String
open fun onCall(args: Any?) {
// nothing by default
}
companion object {
const val BUFFER_SIZE = 1 shl 18 // 256kB
}
}

View file

@ -0,0 +1,421 @@
package deckers.thibault.aves.channel.streams.darttoplatform
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.channel.calls.AppAdapterHandler
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import kotlinx.coroutines.launch
// starting activity to get a result (e.g. storage access via native dialog)
// breaks the regular `MethodChannel` so we use a stream channel instead
class ActivityResultStreamHandler(
private val activity: Activity,
arguments: Any?,
) : BaseStreamHandler() {
private var op: String? = null
private lateinit var args: Map<*, *>
init {
if (arguments is Map<*, *>) {
op = arguments["op"] as String?
args = arguments
}
}
override val logTag = LOG_TAG
override fun onCall(args: Any?) {
// do not automatically close stream when launching activity,
// as it will be closed when getting that activity result
val closeStream = false
when (op) {
"requestDirectoryAccess" -> ioScope.launch { safe(::requestDirectoryAccess, closeStream) }
"pickActivityDirectory" -> ioScope.launch { safe(::pickActivityDirectory, closeStream) }
"requestMediaFileAccess" -> ioScope.launch { safe(::requestMediaFileAccess, closeStream) }
"createFile" -> ioScope.launch { safe(::createFile, closeStream) }
"openFile" -> ioScope.launch { safe(::openFile, closeStream) }
"copyFile" -> ioScope.launch { safe(::copyFile, closeStream) }
"edit" -> safe(::edit, closeStream)
"pickCollectionFilters" -> safe(::pickCollectionFilters, closeStream)
else -> endOfStream()
}
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
super.error(errorCode, errorMessage, errorDetails)
endOfStream()
}
private fun requestDirectoryAccess() {
val path = args["path"] as String?
if (path == null) {
error("requestDirectoryAccess-args", "missing arguments", null)
return
}
PermissionManager.requestDirectoryAccess(
activity,
ensureTrailingSeparator(path),
{
success(true)
endOfStream()
},
{
success(false)
endOfStream()
},
)
}
/**
* Opens Android Storage Access Framework directory picker and returns
* both:
* - the selected tree URI
* - the resolved storage path, when possible
*
* Example result sent to Flutter:
*
* {
* "treeUri": "content://com.android.externalstorage.documents/tree/primary%3AActivity",
* "path": "/storage/emulated/0/Activity/"
* }
*
* This allows Flutter to verify that the selected directory is exactly
* `/storage/emulated/0/Activity`.
*/
private fun pickActivityDirectory() {
fun onGranted(uri: Uri) {
val path = StorageUtils.convertTreeDocumentUriToDirPath(activity, uri)
success(
hashMapOf(
"treeUri" to uri.toString(),
"path" to path,
)
)
endOfStream()
}
fun onDenied() {
success(null)
endOfStream()
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION,
)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.DOCUMENT_TREE_ACCESS_REQUEST,
::onGranted,
::onDenied,
)
}
private fun requestMediaFileAccess() {
val uris = (args["uris"] as List<*>?)?.mapNotNull {
if (it is String) it.toUri() else null
}
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull {
it as? String
}
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
error("requestMediaFileAccess-args", "missing arguments", null)
return
}
if (uris.any { !StorageUtils.isMediaStoreContentUri(it) }) {
error(
"requestMediaFileAccess-nonmediastore",
"request is only valid for Media Store content URIs, uris=$uris",
null,
)
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
error(
"requestMediaFileAccess-unsupported",
"media file bulk access is not allowed before Android 11",
null,
)
return
}
try {
val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes)
success(granted)
endOfStream()
} catch (e: Exception) {
val byFromMediaStore = uris.groupBy { uri ->
uri.toString().startsWith("content://media/")
}
error(
"requestMediaFileAccess-request",
"failed to request access to ${uris.size} uris" +
" (${byFromMediaStore[true]?.size ?: 0} from media store" +
", ${byFromMediaStore[false]?.size ?: 0} others=${byFromMediaStore[false]}" +
")",
e.message,
)
}
}
private fun safeStartActivityForStorageAccessResult(
intent: Intent,
requestCode: Int,
onGranted: (uri: Uri) -> Unit,
onDenied: () -> Unit,
) {
if (intent.resolveActivity(activity.packageManager) != null) {
MainActivity.pendingStorageAccessResultHandlers[requestCode] =
PendingStorageAccessResultHandler(null, onGranted, onDenied)
if (!safeStartActivityForResult(intent, requestCode)) {
MainActivity.notifyError("failed to start activity for intent=$intent extras=${intent.extras}")
onDenied()
}
} else {
MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}")
onDenied()
}
}
private fun createFile() {
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray?
if (name == null || mimeType == null || bytes == null) {
error("createFile-args", "missing arguments", null)
return
}
fun onGranted(uri: Uri) {
ioScope.launch {
try {
// truncate is necessary when overwriting a longer file
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
output.write(bytes)
}
success(true)
endOfStream()
} catch (e: Exception) {
error("createFile-write", "failed to write file at uri=$uri", e.message)
}
}
}
fun onDenied() {
success(null)
endOfStream()
}
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, name)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.CREATE_FILE_REQUEST,
::onGranted,
::onDenied,
)
}
private fun openFile() {
val mimeType = args["mimeType"] as String? // optional
fun onGranted(uri: Uri) {
ioScope.launch {
try {
activity.contentResolver.openInputStream(uri)?.use(::streamBytes)
endOfStream()
} catch (e: Exception) {
error("openFile-read", "failed to read file at uri=$uri", e.message)
}
}
}
fun onDenied() {
success(ByteArray(0))
endOfStream()
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
setTypeAndNormalize(mimeType ?: MimeTypes.ANY)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.OPEN_FILE_REQUEST,
::onGranted,
::onDenied,
)
}
private fun copyFile() {
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val sourceUri = (args["sourceUri"] as String?)?.toUri()
if (name == null || mimeType == null || sourceUri == null) {
error("copyFile-args", "missing arguments", null)
return
}
fun onGranted(uri: Uri) {
ioScope.launch {
try {
StorageUtils.openInputStream(activity, sourceUri)?.use { input ->
// truncate is necessary when overwriting a longer file
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (input.read(buffer).also { len = it } != -1) {
output.write(buffer, 0, len)
}
}
}
success(true)
endOfStream()
} catch (e: Exception) {
error(
"copyFile-write",
"failed to copy file from sourceUri=$sourceUri to uri=$uri",
e.message,
)
}
}
}
fun onDenied() {
success(null)
endOfStream()
}
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, name)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.CREATE_FILE_REQUEST,
::onGranted,
::onDenied,
)
}
private fun edit() {
val uri = args["uri"] as String?
val mimeType = args["mimeType"] as String? // optional
if (uri == null) {
error("edit-args", "missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(AppAdapterHandler.getShareableUri(activity, uri.toUri()), mimeType)
if (intent.resolveActivity(activity.packageManager) == null) {
error(
"edit-resolve",
"cannot resolve activity for this intent for uri=$uri mimeType=$mimeType",
null,
)
return
}
MainActivity.pendingEditIntentHandler = { fields ->
success(fields)
endOfStream()
}
if (!safeStartActivityForResult(intent, MainActivity.EDIT_REQUEST)) {
error(
"edit-start",
"cannot start activity for this intent for uri=$uri mimeType=$mimeType",
null,
)
}
}
private fun pickCollectionFilters() {
val initialFilters = (args["initialFilters"] as? List<*>)
?.mapNotNull { it as? String }
?: listOf()
val intent = Intent(
MainActivity.INTENT_ACTION_PICK_COLLECTION_FILTERS,
null,
activity,
MainActivity::class.java,
)
.putExtra(MainActivity.EXTRA_KEY_FILTERS_ARRAY, initialFilters.toTypedArray())
.putExtra(
MainActivity.EXTRA_KEY_FILTERS_STRING,
initialFilters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR),
)
MainActivity.pendingCollectionFilterPickHandler = { filters ->
success(filters)
endOfStream()
}
activity.startActivityForResult(intent, MainActivity.PICK_COLLECTION_FILTERS_REQUEST)
}
private fun safeStartActivityForResult(intent: Intent, requestCode: Int): Boolean {
return try {
activity.startActivityForResult(intent, requestCode)
true
} catch (_: SecurityException) {
if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
safeStartActivityForResult(intent, requestCode)
} else {
false
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<ActivityResultStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/activity_result_stream"
}
}

View file

@ -0,0 +1,407 @@
package deckers.thibault.aves.channel.streams.darttoplatform
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.channel.calls.AppAdapterHandler
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import kotlinx.coroutines.launch
// starting activity to get a result (e.g. storage access via native dialog)
// breaks the regular `MethodChannel` so we use a stream channel instead
class ActivityResultStreamHandler(
private val activity: Activity,
arguments: Any?,
) : BaseStreamHandler() {
private var op: String? = null
private lateinit var args: Map<*, *>
init {
if (arguments is Map<*, *>) {
op = arguments["op"] as String?
args = arguments
}
}
override val logTag = LOG_TAG
override fun onCall(args: Any?) {
// do not automatically close stream when launching activity,
// as it will be closed when getting that activity result
val closeStream = false
when (op) {
"requestDirectoryAccess" -> ioScope.launch { safe(::requestDirectoryAccess, closeStream) }
"pickActivityDirectory" -> ioScope.launch { safe(::pickActivityDirectory, closeStream) }
"requestMediaFileAccess" -> ioScope.launch { safe(::requestMediaFileAccess, closeStream) }
"createFile" -> ioScope.launch { safe(::createFile, closeStream) }
"openFile" -> ioScope.launch { safe(::openFile, closeStream) }
"copyFile" -> ioScope.launch { safe(::copyFile, closeStream) }
"edit" -> safe(::edit, closeStream)
"pickCollectionFilters" -> safe(::pickCollectionFilters, closeStream)
else -> endOfStream()
}
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
super.error(errorCode, errorMessage, errorDetails)
endOfStream()
}
private fun requestDirectoryAccess() {
val path = args["path"] as String?
if (path == null) {
error("requestDirectoryAccess-args", "missing arguments", null)
return
}
PermissionManager.requestDirectoryAccess(
activity,
ensureTrailingSeparator(path),
{
success(true)
endOfStream()
},
{
success(false)
endOfStream()
},
)
}
/**
* Opens Android Storage Access Framework directory picker and returns
* the selected tree URI to Flutter.
*
* This is different from `requestDirectoryAccess`, which only returns
* true/false. For activity scanning we need the actual `content://...`
* tree URI, because reading `/storage/emulated/0/...` directly is not
* reliable with Scoped Storage.
*/
private fun pickActivityDirectory() {
fun onGranted(uri: Uri) {
success(uri.toString())
endOfStream()
}
fun onDenied() {
success(null)
endOfStream()
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION,
)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.DOCUMENT_TREE_ACCESS_REQUEST,
::onGranted,
::onDenied,
)
}
private fun requestMediaFileAccess() {
val uris = (args["uris"] as List<*>?)?.mapNotNull {
if (it is String) it.toUri() else null
}
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull {
it as? String
}
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
error("requestMediaFileAccess-args", "missing arguments", null)
return
}
if (uris.any { !StorageUtils.isMediaStoreContentUri(it) }) {
error(
"requestMediaFileAccess-nonmediastore",
"request is only valid for Media Store content URIs, uris=$uris",
null,
)
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
error(
"requestMediaFileAccess-unsupported",
"media file bulk access is not allowed before Android 11",
null,
)
return
}
try {
val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes)
success(granted)
endOfStream()
} catch (e: Exception) {
val byFromMediaStore = uris.groupBy { uri ->
uri.toString().startsWith("content://media/")
}
error(
"requestMediaFileAccess-request",
"failed to request access to ${uris.size} uris" +
" (${byFromMediaStore[true]?.size ?: 0} from media store" +
", ${byFromMediaStore[false]?.size ?: 0} others=${byFromMediaStore[false]}" +
")",
e.message,
)
}
}
private fun safeStartActivityForStorageAccessResult(
intent: Intent,
requestCode: Int,
onGranted: (uri: Uri) -> Unit,
onDenied: () -> Unit,
) {
if (intent.resolveActivity(activity.packageManager) != null) {
MainActivity.pendingStorageAccessResultHandlers[requestCode] =
PendingStorageAccessResultHandler(null, onGranted, onDenied)
if (!safeStartActivityForResult(intent, requestCode)) {
MainActivity.notifyError("failed to start activity for intent=$intent extras=${intent.extras}")
onDenied()
}
} else {
MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}")
onDenied()
}
}
private fun createFile() {
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray?
if (name == null || mimeType == null || bytes == null) {
error("createFile-args", "missing arguments", null)
return
}
fun onGranted(uri: Uri) {
ioScope.launch {
try {
// truncate is necessary when overwriting a longer file
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
output.write(bytes)
}
success(true)
endOfStream()
} catch (e: Exception) {
error("createFile-write", "failed to write file at uri=$uri", e.message)
}
}
}
fun onDenied() {
success(null)
endOfStream()
}
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, name)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.CREATE_FILE_REQUEST,
::onGranted,
::onDenied,
)
}
private fun openFile() {
val mimeType = args["mimeType"] as String? // optional
fun onGranted(uri: Uri) {
ioScope.launch {
try {
activity.contentResolver.openInputStream(uri)?.use(::streamBytes)
endOfStream()
} catch (e: Exception) {
error("openFile-read", "failed to read file at uri=$uri", e.message)
}
}
}
fun onDenied() {
success(ByteArray(0))
endOfStream()
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
setTypeAndNormalize(mimeType ?: MimeTypes.ANY)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.OPEN_FILE_REQUEST,
::onGranted,
::onDenied,
)
}
private fun copyFile() {
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val sourceUri = (args["sourceUri"] as String?)?.toUri()
if (name == null || mimeType == null || sourceUri == null) {
error("copyFile-args", "missing arguments", null)
return
}
fun onGranted(uri: Uri) {
ioScope.launch {
try {
StorageUtils.openInputStream(activity, sourceUri)?.use { input ->
// truncate is necessary when overwriting a longer file
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (input.read(buffer).also { len = it } != -1) {
output.write(buffer, 0, len)
}
}
}
success(true)
endOfStream()
} catch (e: Exception) {
error(
"copyFile-write",
"failed to copy file from sourceUri=$sourceUri to uri=$uri",
e.message,
)
}
}
}
fun onDenied() {
success(null)
endOfStream()
}
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, name)
}
safeStartActivityForStorageAccessResult(
intent,
MainActivity.CREATE_FILE_REQUEST,
::onGranted,
::onDenied,
)
}
private fun edit() {
val uri = args["uri"] as String?
val mimeType = args["mimeType"] as String? // optional
if (uri == null) {
error("edit-args", "missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(AppAdapterHandler.getShareableUri(activity, uri.toUri()), mimeType)
if (intent.resolveActivity(activity.packageManager) == null) {
error(
"edit-resolve",
"cannot resolve activity for this intent for uri=$uri mimeType=$mimeType",
null,
)
return
}
MainActivity.pendingEditIntentHandler = { fields ->
success(fields)
endOfStream()
}
if (!safeStartActivityForResult(intent, MainActivity.EDIT_REQUEST)) {
error(
"edit-start",
"cannot start activity for this intent for uri=$uri mimeType=$mimeType",
null,
)
}
}
private fun pickCollectionFilters() {
val initialFilters = (args["initialFilters"] as? List<*>)
?.mapNotNull { it as? String }
?: listOf()
val intent = Intent(
MainActivity.INTENT_ACTION_PICK_COLLECTION_FILTERS,
null,
activity,
MainActivity::class.java,
)
.putExtra(MainActivity.EXTRA_KEY_FILTERS_ARRAY, initialFilters.toTypedArray())
.putExtra(
MainActivity.EXTRA_KEY_FILTERS_STRING,
initialFilters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR),
)
MainActivity.pendingCollectionFilterPickHandler = { filters ->
success(filters)
endOfStream()
}
activity.startActivityForResult(intent, MainActivity.PICK_COLLECTION_FILTERS_REQUEST)
}
private fun safeStartActivityForResult(intent: Intent, requestCode: Int): Boolean {
return try {
activity.startActivityForResult(intent, requestCode)
true
} catch (_: SecurityException) {
if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
safeStartActivityForResult(intent, requestCode)
} else {
false
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<ActivityResultStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/activity_result_stream"
}
}

View file

@ -0,0 +1,277 @@
package deckers.thibault.aves.channel.streams.darttoplatform
import android.content.Context
import android.graphics.Rect
import android.net.Uri
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.decoding.RegionFetcher
import deckers.thibault.aves.decoding.SvgRegionFetcher
import deckers.thibault.aves.decoding.ThumbnailFetcher
import deckers.thibault.aves.decoding.TiffRegionFetcher
import deckers.thibault.aves.glide.AvesAppGlideModule
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.ContextUtils.devicePixelRatio
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.handleEncodedBytesInFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.util.Date
import kotlin.math.roundToInt
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : BaseStreamHandler(), ByteSink {
private var op: String? = null
private var decoded: Boolean = false
private val regionFetcher = RegionFetcher(context)
private val density = context.devicePixelRatio()
init {
if (arguments is Map<*, *>) {
op = arguments["op"] as String?
decoded = arguments["decoded"] as Boolean
}
}
override val logTag = LOG_TAG
override fun onCall(args: Any?) {
when (op) {
"getFullImage" -> ioScope.launch { safeSuspend(::streamFullImage) }
"getRegion" -> ioScope.launch { safeSuspend(::streamRegion) }
"getThumbnail" -> ioScope.launch { safeSuspend(::streamThumbnail) }
else -> endOfStream()
}
}
// Supported image formats:
// - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private suspend fun streamFullImage() {
if (arguments !is Map<*, *>) {
return
}
val uri = (arguments["uri"] as String?)?.toUri()
val pageId = arguments["pageId"] as Int?
val mimeType = arguments["mimeType"] as String?
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean
if (mimeType == null || uri == null) {
error("streamImage-args", "missing arguments", null)
return
}
if (!decoded && handleEncodedBytesInFlutter(mimeType)) {
// the image can be decoded by Flutter codecs,
// and there is no need for processing on the platform side
// so we stream it without decoding it
streamOriginalBytesWithTrailer(uri, mimeType)
} else if (isVideo(mimeType)) {
streamVideoByGlide(
uri = uri,
mimeType = mimeType,
sizeBytes = sizeBytes,
decoded = decoded,
)
} else {
// even if the image could be decoded by Flutter codecs,
// it needs to be processed on the platform side
// so we decode, process, optionally reencode, then stream it
streamImageByGlide(
uri = uri,
pageId = pageId,
mimeType = mimeType,
sizeBytes = sizeBytes,
rotationDegrees = rotationDegrees,
isFlipped = isFlipped,
decoded = decoded,
)
}
}
private fun streamOriginalBytesWithTrailer(uri: Uri, mimeType: String) {
try {
val sent = StorageUtils.openInputStream(context, uri)?.use(::streamBytes)
if (sent ?: false) {
success(BitmapUtils.FORMAT_BYTE_ENCODED_AS_BYTES)
}
} catch (e: Exception) {
error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.stackTraceToString())
}
}
private suspend fun streamImageByGlide(
uri: Uri,
pageId: Int?,
mimeType: String,
sizeBytes: Long?,
rotationDegrees: Int,
isFlipped: Boolean,
decoded: Boolean,
) {
val target = Glide.with(context)
.asBitmap()
.apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
.submit()
try {
var bitmap = withContext(Dispatchers.IO) { target.get() }
if (needRotationAfterGlide(mimeType, pageId)) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
if (bitmap != null) {
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
val bytes = BitmapUtils.getBytes(bitmap, recycle = false, decoded = decoded, mimeType)
streamBytes(ByteArrayInputStream(bytes))
} else {
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
}
} catch (e: Exception) {
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.stackTraceToString())
} finally {
Glide.with(context).clear(target)
}
}
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?, decoded: Boolean) {
val target = Glide.with(context)
.asBitmap()
.apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
.submit()
try {
val bitmap = withContext(Dispatchers.IO) { target.get() }
if (bitmap != null) {
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
val bytes = BitmapUtils.getBytes(bitmap, recycle = false, decoded = decoded, mimeType)
streamBytes(ByteArrayInputStream(bytes))
} else {
error("streamImage-video-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
}
} catch (e: Exception) {
error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.stackTraceToString())
} finally {
Glide.with(context).clear(target)
}
}
private suspend fun streamRegion() {
if (arguments !is Map<*, *>) {
return
}
val uri = (arguments["uri"] as String?)?.toUri()
val pageId = arguments["pageId"] as Int?
val mimeType = arguments["mimeType"] as String?
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
val sampleSize = arguments["sampleSize"] as Int?
val x = arguments["regionX"] as Int?
val y = arguments["regionY"] as Int?
val width = arguments["regionWidth"] as Int?
val height = arguments["regionHeight"] as Int?
val imageWidth = arguments["imageWidth"] as Int?
val imageHeight = arguments["imageHeight"] as Int?
if (uri == null || mimeType == null || sampleSize == null || x == null || y == null || width == null || height == null || imageWidth == null || imageHeight == null) {
error("getRegion-args", "missing arguments", null)
return
}
val regionRect = Rect(x, y, x + width, y + height)
when (mimeType) {
MimeTypes.SVG -> SvgRegionFetcher(context).fetch(
uri = uri,
decoded = decoded,
sizeBytes = sizeBytes,
scale = sampleSize,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
result = this,
)
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
uri = uri,
page = pageId ?: 0,
decoded = decoded,
sampleSize = sampleSize,
regionRect = regionRect,
result = this,
)
else -> regionFetcher.fetch(
uri = uri,
pageId = pageId,
decoded = decoded,
mimeType = mimeType,
sampleSize = sampleSize,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
result = this,
)
}
}
private suspend fun streamThumbnail() {
if (arguments !is Map<*, *>) {
return
}
val uri = arguments[EntryFields.URI] as String?
val pageId = arguments["pageId"] as Int?
val mimeType = arguments[EntryFields.MIME_TYPE] as String?
val dateModifiedMillis = (arguments[EntryFields.DATE_MODIFIED_MILLIS] as Number?)?.toLong()
val rotationDegrees = arguments[EntryFields.ROTATION_DEGREES] as Int?
val isFlipped = arguments[EntryFields.IS_FLIPPED] as Boolean?
val widthDip = (arguments["widthDip"] as Number?)?.toDouble()
val heightDip = (arguments["heightDip"] as Number?)?.toDouble()
val defaultSizeDip = (arguments["defaultSizeDip"] as Number?)?.toDouble()
val quality = arguments["quality"] as Int?
if (uri == null || mimeType == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
error("getThumbnail-args", "missing arguments", null)
return
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
ThumbnailFetcher(
context = context,
uri = uri,
pageId = pageId,
decoded = decoded,
mimeType = mimeType,
dateModifiedMillis = dateModifiedMillis ?: (Date().time),
rotationDegrees = rotationDegrees,
isFlipped = isFlipped,
width = (widthDip * density).roundToInt(),
height = (heightDip * density).roundToInt(),
defaultSize = (defaultSizeDip * density).roundToInt(),
quality = quality,
result = this,
).fetch()
}
companion object {
private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
}
}
interface ByteSink {
fun streamBytes(inputStream: InputStream): Boolean
fun error(errorCode: String, errorMessage: String?, errorDetails: Any?)
}

View file

@ -0,0 +1,221 @@
package deckers.thibault.aves.channel.streams.darttoplatform
import android.app.Activity
import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import kotlinx.coroutines.launch
class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : BaseStreamHandler() {
private var op: String? = null
private var opId: String? = null
private val entryMapList = ArrayList<FieldMap>()
init {
if (arguments is Map<*, *>) {
op = arguments["op"] as String?
opId = arguments["id"] as String?
@Suppress("unchecked_cast")
val rawEntries = arguments["entries"] as List<FieldMap>?
if (rawEntries != null) {
entryMapList.addAll(rawEntries)
}
}
}
override val logTag = LOG_TAG
override fun onCall(args: Any?) {
when (op) {
"delete" -> ioScope.launch { safe(::delete) }
"convert" -> ioScope.launch { safeSuspend(::convert) }
"move" -> ioScope.launch { safeSuspend(::move) }
"rename" -> ioScope.launch { safeSuspend(::rename) }
else -> endOfStream()
}
}
override fun endOfStream() {
cancelledOps.remove(opId)
super.endOfStream()
}
private fun isCancelledOp() = cancelledOps.contains(opId)
private fun delete() {
val entries = entryMapList.map(::AvesEntry)
for (entry in entries) {
val mimeType = entry.mimeType
val trashed = entry.trashed
val uri = entry.uri
val path = if (trashed) entry.trashPath else entry.path
val result: FieldMap = hashMapOf(
"uri" to uri.toString(),
)
if (isCancelledOp()) {
result["skipped"] = true
} else {
result["success"] = false
getProvider(activity, uri)?.let { provider ->
try {
provider.delete(activity, uri, path, mimeType)
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
}
}
}
success(result)
}
endOfStream()
}
private suspend fun convert() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String?
val quality = (arguments["quality"] as Number?)?.toInt()
val lengthUnit = arguments["lengthUnit"] as String?
val width = (arguments["width"] as Number?)?.toInt()
val height = (arguments["height"] as Number?)?.toInt()
val writeMetadata = arguments["writeMetadata"] as Boolean?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (destinationDir == null || mimeType == null || quality == null || lengthUnit == null || width == null || height == null || writeMetadata == null || nameConflictStrategy == null) {
error("convert-args", "missing arguments", null)
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.toUri()?.let { getProvider(activity, it) }
if (provider == null) {
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.convertMultiple(
activity = activity,
imageExportMimeType = mimeType,
targetDir = destinationDir,
entries = entries,
quality = quality,
lengthUnit = lengthUnit,
width = width,
height = height,
writeMetadata = writeMetadata,
nameConflictStrategy = nameConflictStrategy,
callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("convert-failure", "failed to convert entries", throwable)
},
)
endOfStream()
}
private suspend fun move() {
if (arguments !is Map<*, *>) {
endOfStream()
return
}
val copy = arguments["copy"] as Boolean?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
val rawEntryMap = arguments["entriesByDestination"] as Map<*, *>?
if (copy == null || nameConflictStrategy == null || rawEntryMap.isNullOrEmpty()) {
error("move-args", "missing arguments", null)
return
}
val entriesByTargetDir = HashMap<String, List<AvesEntry>>()
rawEntryMap.forEach {
var destinationDir = it.key as String
if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) {
destinationDir = ensureTrailingSeparator(destinationDir)
}
@Suppress("unchecked_cast")
val rawEntries = it.value as List<FieldMap>
entriesByTargetDir[destinationDir] = rawEntries.map(::AvesEntry)
}
// always use Media Store (as we move from or to it)
val provider = MediaStoreImageProvider()
provider.moveMultiple(
activity = activity,
copy = copy,
nameConflictStrategy = nameConflictStrategy,
entriesByTargetDir = entriesByTargetDir,
isCancelledOp = ::isCancelledOp,
callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
},
)
endOfStream()
}
private suspend fun rename() {
if (arguments !is Map<*, *>) {
endOfStream()
return
}
val rawEntryMap = arguments["entriesToNewName"] as Map<*, *>?
if (rawEntryMap.isNullOrEmpty()) {
error("rename-args", "missing arguments", null)
return
}
val entriesToNewName = HashMap<AvesEntry, String>()
rawEntryMap.forEach {
@Suppress("unchecked_cast")
val rawEntry = it.key as FieldMap
val newName = it.value as String
entriesToNewName[AvesEntry(rawEntry)] = newName
}
val byProvider = entriesToNewName.entries.groupBy { kv -> getProvider(activity, kv.key.uri) }
for ((provider, entryList) in byProvider) {
if (provider == null) {
error("rename-provider", "failed to find provider for entry=${entryList.firstOrNull()}", null)
return
}
val entryMap = mapOf(*entryList.map { Pair(it.key, it.value) }.toTypedArray())
provider.renameMultiple(
activity = activity,
entriesToNewName = entryMap,
isCancelledOp = ::isCancelledOp,
callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
},
)
}
endOfStream()
}
companion object {
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_op_stream"
}
}

View file

@ -0,0 +1,36 @@
package deckers.thibault.aves.channel.streams.darttoplatform
import android.content.Context
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils
import kotlinx.coroutines.launch
class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : BaseStreamHandler() {
// knownEntries: map of contentId -> dateModifiedMillis
private var knownEntries: Map<Long?, Long?>? = null
private var directory: String? = null
init {
if (arguments is Map<*, *>) {
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to (it.value as Number?)?.toLong() }?.toMap()
directory = arguments["directory"] as String?
}
}
override val logTag = LOG_TAG
override fun onCall(args: Any?) {
ioScope.launch { safe(::fetchAll) }
}
private fun fetchAll() {
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory) { success(it) }
endOfStream()
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaStoreStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_store_stream"
}
}

View file

@ -0,0 +1,237 @@
package deckers.thibault.aves.channel.streams.platformtodart
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.FileObserver
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.EventChannel
import java.io.File
import java.util.concurrent.ConcurrentHashMap
class ActivityDirectoryChangeStreamHandler(
private val context: Context,
) : EventChannel.StreamHandler {
private var eventSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val observers = ConcurrentHashMap<String, FileObserver>()
private var downloadsObserver: ContentObserver? = null
private val activityRoot: File
get() = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"Activity",
)
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
start()
}
override fun onCancel(arguments: Any?) {
stop()
eventSink = null
}
fun dispose() {
stop()
eventSink = null
}
private fun start() {
Log.i(LOG_TAG, "start activity directory watcher root=${activityRoot.path}")
startFileObservers()
startDownloadsObserver()
}
private fun stop() {
Log.i(LOG_TAG, "stop activity directory watcher")
stopFileObservers()
stopDownloadsObserver()
}
private fun startFileObservers() {
val root = activityRoot
if (!root.exists() || !root.isDirectory) {
Log.i(LOG_TAG, "activity root does not exist, skip FileObserver root=${root.path}")
return
}
watchDirectoryRecursive(root)
}
private fun stopFileObservers() {
observers.values.forEach { observer ->
try {
observer.stopWatching()
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to stop FileObserver", e)
}
}
observers.clear()
}
private fun watchDirectoryRecursive(directory: File) {
if (!directory.exists() || !directory.isDirectory) return
watchDirectory(directory)
directory.listFiles()
?.filter { it.isDirectory }
?.forEach { child ->
watchDirectoryRecursive(child)
}
}
@Suppress("DEPRECATION")
private fun watchDirectory(directory: File) {
val path = directory.absolutePath
if (observers.containsKey(path)) return
val observer = object : FileObserver(path, FILE_OBSERVER_MASK) {
override fun onEvent(event: Int, relativePath: String?) {
val cleanEvent = event and FileObserver.ALL_EVENTS
val changedPath = if (relativePath != null) {
File(path, relativePath).absolutePath
} else {
path
}
if (!isInterestingFileObserverEvent(cleanEvent)) return
Log.d(LOG_TAG, "activity FileObserver event=$cleanEvent path=$changedPath")
// If a new directory appears, start watching it too.
if (
cleanEvent == FileObserver.CREATE ||
cleanEvent == FileObserver.MOVED_TO
) {
val changedFile = File(changedPath)
if (changedFile.exists() && changedFile.isDirectory) {
watchDirectoryRecursive(changedFile)
}
}
notifyChanged(
source = "file_observer",
path = changedPath,
)
}
}
try {
observer.startWatching()
observers[path] = observer
Log.i(LOG_TAG, "watch activity directory path=$path")
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to watch activity directory path=$path", e)
}
}
private fun isInterestingFileObserverEvent(event: Int): Boolean {
return event == FileObserver.CREATE ||
event == FileObserver.DELETE ||
event == FileObserver.MOVED_TO ||
event == FileObserver.MOVED_FROM ||
event == FileObserver.MODIFY ||
event == FileObserver.CLOSE_WRITE ||
event == FileObserver.DELETE_SELF ||
event == FileObserver.MOVE_SELF
}
private fun startDownloadsObserver() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.i(LOG_TAG, "MediaStore.Downloads unavailable before Android Q")
return
}
if (downloadsObserver != null) return
val uri = MediaStore.Downloads.EXTERNAL_CONTENT_URI
downloadsObserver = object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean) {
notifyChanged(
source = "mediastore_downloads",
path = null,
)
}
override fun onChange(selfChange: Boolean, uri: Uri?) {
notifyChanged(
source = "mediastore_downloads",
path = uri?.toString(),
)
}
}
try {
context.contentResolver.registerContentObserver(
uri,
true,
downloadsObserver!!,
)
Log.i(LOG_TAG, "registered MediaStore.Downloads observer uri=$uri")
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to register MediaStore.Downloads observer", e)
downloadsObserver = null
}
}
private fun stopDownloadsObserver() {
val observer = downloadsObserver ?: return
try {
context.contentResolver.unregisterContentObserver(observer)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to unregister MediaStore.Downloads observer", e)
}
downloadsObserver = null
}
private fun notifyChanged(
source: String,
path: String?,
) {
val fields = hashMapOf<String, Any?>(
"event" to "changed",
"source" to source,
"path" to path,
"root" to activityRoot.path,
"timeMillis" to System.currentTimeMillis(),
)
mainHandler.post {
eventSink?.success(fields)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<ActivityDirectoryChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/activity_directory_change"
private const val FILE_OBSERVER_MASK =
FileObserver.CREATE or
FileObserver.DELETE or
FileObserver.MOVED_TO or
FileObserver.MOVED_FROM or
FileObserver.MODIFY or
FileObserver.CLOSE_WRITE or
FileObserver.DELETE_SELF or
FileObserver.MOVE_SELF
}
}

View file

@ -0,0 +1,15 @@
package deckers.thibault.aves.channel.streams.platformtodart
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
class AnalysisStreamHandler : BaseStreamHandler() {
fun notifyCompletion() = success(true)
override val logTag = LOG_TAG
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/analysis_events"
}
}

View file

@ -0,0 +1,15 @@
package deckers.thibault.aves.channel.streams.platformtodart
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
class ErrorStreamHandler : BaseStreamHandler() {
fun notifyError(error: String) = success(error)
override val logTag = LOG_TAG
companion object {
private val LOG_TAG = LogUtils.createTag<ErrorStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/error"
}
}

View file

@ -0,0 +1,15 @@
package deckers.thibault.aves.channel.streams.platformtodart
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
class IntentStreamHandler : BaseStreamHandler() {
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) = success(intentData)
override val logTag = LOG_TAG
companion object {
private val LOG_TAG = LogUtils.createTag<IntentStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/new_intent_stream"
}
}

View file

@ -0,0 +1,61 @@
package deckers.thibault.aves.channel.streams.platformtodart
import android.support.v4.media.session.MediaSessionCompat
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
class MediaCommandStreamHandler : BaseStreamHandler() {
val callback = object : MediaSessionCompat.Callback() {
override fun onPlay() {
super.onPlay()
success(hashMapOf(KEY_COMMAND to COMMAND_PLAY))
}
override fun onPause() {
super.onPause()
success(hashMapOf(KEY_COMMAND to COMMAND_PAUSE))
}
override fun onSkipToNext() {
super.onSkipToNext()
success(hashMapOf(KEY_COMMAND to COMMAND_SKIP_TO_NEXT))
}
override fun onSkipToPrevious() {
super.onSkipToPrevious()
success(hashMapOf(KEY_COMMAND to COMMAND_SKIP_TO_PREVIOUS))
}
override fun onStop() {
super.onStop()
success(hashMapOf(KEY_COMMAND to COMMAND_STOP))
}
override fun onSeekTo(pos: Long) {
super.onSeekTo(pos)
success(
hashMapOf(
KEY_COMMAND to COMMAND_SEEK,
KEY_POSITION to pos,
)
)
}
}
override val logTag = LOG_TAG
companion object {
private val LOG_TAG = LogUtils.createTag<MediaCommandStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_command"
const val KEY_COMMAND = "command"
const val KEY_POSITION = "position"
const val COMMAND_PLAY = "play"
const val COMMAND_PAUSE = "pause"
const val COMMAND_SKIP_TO_NEXT = "skip_to_next"
const val COMMAND_SKIP_TO_PREVIOUS = "skip_to_previous"
const val COMMAND_STOP = "stop"
const val COMMAND_SEEK = "seek"
}
}

View file

@ -0,0 +1,49 @@
package deckers.thibault.aves.channel.streams.platformtodart
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
class MediaStoreChangeStreamHandler(private val context: Context) : BaseStreamHandler() {
private val contentObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
this.onChange(selfChange, null)
}
override fun onChange(selfChange: Boolean, uri: Uri?) {
// warning: querying the content resolver right after a change
// sometimes yields obsolete results
success(uri?.toString())
}
}
init {
Log.i(LOG_TAG, "start listening to Media Store")
try {
context.contentResolver.apply {
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
}
} catch (e: SecurityException) {
// Trying to register an observer may yield a security exception with this message:
// "Failed to find provider media for user 0; expected to find a valid ContentProvider for this authority"
Log.w(LOG_TAG, "failed to register content observer", e)
}
}
fun dispose() {
Log.i(LOG_TAG, "stop listening to Media Store")
context.contentResolver.unregisterContentObserver(contentObserver)
}
override val logTag = LOG_TAG
companion object {
private val LOG_TAG = LogUtils.createTag<MediaStoreChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_store_change"
}
}

View file

@ -0,0 +1,82 @@
package deckers.thibault.aves.channel.streams.platformtodart
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.provider.Settings
import android.util.Log
import android.view.ViewConfiguration
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
class SettingsChangeStreamHandler(private val context: Context) : BaseStreamHandler() {
private val contentObserver = object : ContentObserver(null) {
private var accelerometerRotation: Int = 0
private var transitionAnimationScale: Float = 1f
private var longPressTimeoutMillis: Int = 0
init {
update()
}
override fun onChange(selfChange: Boolean) {
this.onChange(selfChange, null)
}
override fun onChange(selfChange: Boolean, uri: Uri?) {
if (update()) {
success(
hashMapOf(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
KEY_LONG_PRESS_TIMEOUT_MILLIS to longPressTimeoutMillis,
)
)
}
}
private fun update(): Boolean {
var changed = false
try {
val newAccelerometerRotation = Settings.System.getInt(context.contentResolver, Settings.System.ACCELEROMETER_ROTATION)
if (accelerometerRotation != newAccelerometerRotation) {
accelerometerRotation = newAccelerometerRotation
changed = true
}
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
if (transitionAnimationScale != newTransitionAnimationScale) {
transitionAnimationScale = newTransitionAnimationScale
changed = true
}
val newLongPressTimeout = ViewConfiguration.getLongPressTimeout()
if (longPressTimeoutMillis != newLongPressTimeout) {
longPressTimeoutMillis = newLongPressTimeout
changed = true
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
return changed
}
}
init {
Log.i(LOG_TAG, "start listening to system settings")
context.contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, contentObserver)
}
fun dispose() {
Log.i(LOG_TAG, "stop listening to system settings")
context.contentResolver.unregisterContentObserver(contentObserver)
}
override val logTag = LOG_TAG
companion object {
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/settings_change"
// cf `Settings.Secure.LONG_PRESS_TIMEOUT`
const val KEY_LONG_PRESS_TIMEOUT_MILLIS = "long_press_timeout"
}
}

View file

@ -0,0 +1,19 @@
package deckers.thibault.aves.channel.streams.platformtodart
import deckers.thibault.aves.channel.streams.BaseStreamHandler
import deckers.thibault.aves.utils.LogUtils
class WindowChangeStreamHandler : BaseStreamHandler() {
fun notifyCutoutInsetsChange() = success(CODE_CUTOUT_INSETS)
fun notifyWindowModeChange() = success(CODE_WINDOW_MODE)
override val logTag = LOG_TAG
companion object {
private val LOG_TAG = LogUtils.createTag<ErrorStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/window_change"
private const val CODE_CUTOUT_INSETS = "cutout_insets"
private const val CODE_WINDOW_MODE = "window_mode"
}
}

View file

@ -0,0 +1,227 @@
package deckers.thibault.aves.decoding
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.ColorSpace
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.graphics.createBitmap
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.streams.darttoplatform.ByteSink
import deckers.thibault.aves.glide.AvesAppGlideModule
import deckers.thibault.aves.glide.MultiPageImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.describe
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MathUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.max
import kotlin.math.roundToInt
// As of Android 14 (API 34), `BitmapRegionDecoder` documentation states
// that "only the JPEG, PNG, WebP and HEIF formats are supported"
// but in practice it successfully decodes some others.
class RegionFetcher internal constructor(
private val context: Context,
) {
suspend fun fetch(
uri: Uri,
pageId: Int?,
decoded: Boolean,
mimeType: String,
sampleSize: Int,
regionRect: Rect,
imageWidth: Int,
imageHeight: Int,
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
result: ByteSink,
) {
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
// use export for requested page
val exportUri = exportUris.getOrPut(requestKey) { createTemporaryExport(uri, mimeType, pageId) }
fetch(
uri = exportUri,
pageId = null,
decoded = decoded,
mimeType = EXPORT_MIME_TYPE,
sampleSize = sampleSize,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
requestKey = requestKey,
result = result,
)
return
}
try {
val decoder = getOrCreateDecoder(context, uri, requestKey)
if (decoder == null) {
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
return
}
// with raw images, the known image size may not match the decoded image size
// so we scale the requested region accordingly
var effectiveRect = regionRect
var effectiveSampleSize = sampleSize
if (imageWidth != decoder.width || imageHeight != decoder.height) {
val xf = decoder.width.toDouble() / imageWidth
val yf = decoder.height.toDouble() / imageHeight
effectiveRect = Rect(
(regionRect.left * xf).roundToInt(),
(regionRect.top * yf).roundToInt(),
(regionRect.right * xf).roundToInt(),
(regionRect.bottom * yf).roundToInt(),
)
val factor = MathUtils.highestPowerOf2((1 / max(xf, yf)).roundToInt())
if (factor > 1) {
effectiveSampleSize = max(1, effectiveSampleSize / factor)
}
}
val options = BitmapFactory.Options().apply {
inSampleSize = effectiveSampleSize
// Specifying preferred config and color space avoids the need for conversion afterwards,
// but may prevent decoding (e.g. from RGBA_1010102 to ARGB_8888 on some devices).
inPreferredConfig = PREFERRED_CONFIG
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
}
}
val pixelCount = effectiveRect.width() * effectiveRect.height() / effectiveSampleSize
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), options.inPreferredConfig)
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
// decoding a region that large would yield an OOM when creating the bitmap
result.error("fetch-large-region", "Region too large for uri=$uri regionRect=$regionRect", null)
return
}
var bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap == null) {
// retry without specifying config or color space,
// falling back to custom byte conversion afterwards
options.inPreferredConfig = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && options.inPreferredColorSpace != null) {
options.inPreferredColorSpace = null
}
bitmap = decoder.decodeRegion(effectiveRect, options)
}
val bytes = BitmapUtils.getBytes(bitmap, recycle = true, decoded = decoded, mimeType)
if (bytes == null) {
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
} else {
result.streamBytes(ByteArrayInputStream(bytes))
}
} catch (e: Exception) {
if (EXPORT_MIME_TYPE != mimeType) {
// retry with export on failure,
// as some formats are not fully supported by `BitmapRegionDecoder`
val exportUri = exportUris.getOrPut(requestKey) { createTemporaryExport(uri, mimeType, pageId) }
fetch(
uri = exportUri,
pageId = null,
decoded = decoded,
mimeType = EXPORT_MIME_TYPE,
sampleSize = sampleSize,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
requestKey = requestKey,
result = result,
)
return
}
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
}
}
private suspend fun createTemporaryExport(uri: Uri, mimeType: String, pageId: Int?): Uri {
val exportFormat = EXPORT_FORMAT
Log.d(LOG_TAG, "create export for uri=$uri mimeType=$mimeType pageId=$pageId exportFormat=$exportFormat")
val target = Glide.with(context)
.asBitmap()
.apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.submit()
try {
val bitmap = target.get()
val tempFile = StorageUtils.createTempFile(context).apply {
outputStream().use { output ->
val encodedExport = bitmap.compress(exportFormat, 100, output)
if (!encodedExport) {
Log.w(LOG_TAG, "failed export via encoded bytes for uri=$uri mimeType=$mimeType pageId=$pageId exportFormat=$exportFormat, with bitmap=${bitmap.describe()}")
val decodedBytes = BitmapUtils.getBytes(bitmap, recycle = false, decoded = true, mimeType)
if (decodedBytes != null) {
val exportBitmap = createBitmap(bitmap.width, bitmap.height, PREFERRED_CONFIG)
exportBitmap.copyPixelsFromBuffer(ByteBuffer.wrap(decodedBytes))
val decodedExport = exportBitmap.compress(exportFormat, 100, output)
if (!decodedExport) {
Log.w(LOG_TAG, "failed to compress exportBitmap=${bitmap.describe()}")
}
}
}
}
}
return Uri.fromFile(tempFile)
} finally {
Glide.with(context).clear(target)
}
}
private data class DecoderRef(
val requestKey: Pair<Uri, Int?>,
val decoder: BitmapRegionDecoder,
)
companion object {
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
private const val DECODER_POOL_SIZE = 3
private const val EXPORT_MIME_TYPE = MimeTypes.JPEG
private val EXPORT_FORMAT = Bitmap.CompressFormat.JPEG
private val decoderPool = ArrayList<DecoderRef>()
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
private val poolLock = ReentrantLock()
private fun getOrCreateDecoder(context: Context, uri: Uri, requestKey: Pair<Uri, Int?>): BitmapRegionDecoder? {
poolLock.withLock {
var decoderRef = decoderPool.firstOrNull { it.requestKey == requestKey }
if (decoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
BitmapRegionDecoderCompat.newInstance(input)
}
if (newDecoder == null) {
return null
}
decoderRef = DecoderRef(requestKey, newDecoder)
} else {
decoderPool.remove(decoderRef)
}
decoderPool.add(0, decoderRef)
while (decoderPool.size > DECODER_POOL_SIZE) {
decoderPool.removeAt(decoderPool.size - 1)
}
return decoderRef.decoder
}
}
}
}

View file

@ -0,0 +1,145 @@
package deckers.thibault.aves.decoding
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.RectF
import android.net.Uri
import androidx.core.graphics.createBitmap
import com.caverock.androidsvg.PreserveAspectRatio
import com.caverock.androidsvg.RenderOptions
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import com.caverock.androidsvg.utils.SVGAndroidRenderer
import deckers.thibault.aves.channel.streams.darttoplatform.ByteSink
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
import deckers.thibault.aves.metadata.SvgHelper.IMAGE_BASE64_SIZE_DANGER_THRESHOLD
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.io.ByteArrayInputStream
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.ceil
class SvgRegionFetcher internal constructor(
private val context: Context,
) {
suspend fun fetch(
uri: Uri,
decoded: Boolean,
sizeBytes: Long?,
scale: Int,
regionRect: Rect,
imageWidth: Int,
imageHeight: Int,
result: ByteSink,
) {
if (!MemoryUtils.canAllocate(sizeBytes)) {
// opening an SVG that large would yield an OOM during parsing from `com.caverock.androidsvg.SVGParser`
result.error("fetch-read-large-file", "SVG too large at $sizeBytes bytes, for uri=$uri regionRect=$regionRect", null)
return
}
try {
val svg = getOrCreateDecoder(context, uri)
if (svg == null) {
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
return
}
// we scale the requested region accordingly to the viewbox size
val viewBox = svg.documentViewBox
val svgWidth = viewBox.width()
val svgHeight = viewBox.height()
val xf = imageWidth / scale / ceil(svgWidth)
val yf = imageHeight / scale / ceil(svgHeight)
// some SVG paths do not respect the rendering viewbox and do not reach its edges
// so we render to a slightly larger bitmap, using a slightly larger viewbox,
// and crop that bitmap to the target region size
val bleedX = xf.toInt()
val bleedY = yf.toInt()
val effectiveRect = RectF(
(regionRect.left - bleedX) / xf,
(regionRect.top - bleedY) / yf,
(regionRect.right + bleedX) / xf,
(regionRect.bottom + bleedY) / yf,
)
effectiveRect.offset(viewBox.left, viewBox.top)
val renderOptions = RenderOptions()
renderOptions.viewBox(effectiveRect.left, effectiveRect.top, effectiveRect.width(), effectiveRect.height())
renderOptions.preserveAspectRatio(PreserveAspectRatio.FULLSCREEN_START)
val targetBitmapWidth = regionRect.width()
val targetBitmapHeight = regionRect.height()
val canvasWidth = targetBitmapWidth + bleedX * 2
val canvasHeight = targetBitmapHeight + bleedY * 2
val config = PREFERRED_CONFIG
val pixelCount = canvasWidth * canvasHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), config)
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
// decoding a region that large would yield an OOM when creating the bitmap
result.error("fetch-large-region", "SVG region too large for uri=$uri regionRect=$regionRect", null)
return
}
var bitmap = createBitmap(canvasWidth, canvasHeight, config)
val canvas = Canvas(bitmap)
SVGAndroidRenderer.setImageBase64StringMaxSize(IMAGE_BASE64_SIZE_DANGER_THRESHOLD)
svg.renderToCanvas(canvas, renderOptions)
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
val bytes = BitmapUtils.getBytes(bitmap, recycle = true, decoded = decoded, MimeTypes.SVG)
if (bytes == null) {
result.error("fetch-null", "failed to decode SVG for uri=$uri regionRect=$regionRect", null)
} else {
result.streamBytes(ByteArrayInputStream(bytes))
}
} catch (e: SVGParseException) {
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", e.message)
} catch (e: Exception) {
result.error("fetch-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
}
}
private data class DecoderRef(
val uri: Uri,
val decoder: SVG,
)
companion object {
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
private const val DECODER_POOL_SIZE = 3
private val decoderPool = ArrayList<DecoderRef>()
private val poolLock = ReentrantLock()
private fun getOrCreateDecoder(context: Context, uri: Uri): SVG? {
poolLock.withLock {
var decoderRef = decoderPool.firstOrNull { it.uri == uri }
if (decoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
SVG.getFromInputStream(SVGParserBufferedInputStream(input))
}
if (newDecoder == null) {
return null
}
newDecoder.normalizeSize()
decoderRef = DecoderRef(uri, newDecoder)
} else {
decoderPool.remove(decoderRef)
}
decoderPool.add(0, decoderRef)
while (decoderPool.size > DECODER_POOL_SIZE) {
decoderPool.removeAt(decoderPool.size - 1)
}
return decoderRef.decoder
}
}
}
}

View file

@ -0,0 +1,182 @@
package deckers.thibault.aves.decoding
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.core.graphics.scale
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.channel.streams.darttoplatform.ByteSink
import deckers.thibault.aves.glide.AvesAppGlideModule
import deckers.thibault.aves.glide.MultiPageImage
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.SVG
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.ByteArrayInputStream
import kotlin.math.min
import kotlin.math.roundToInt
class ThumbnailFetcher internal constructor(
private val context: Context,
uri: String,
private val pageId: Int?,
private val decoded: Boolean,
private val mimeType: String,
private val dateModifiedMillis: Long,
private val rotationDegrees: Int,
private val isFlipped: Boolean,
width: Int?,
height: Int?,
private val defaultSize: Int,
private val quality: Int,
private val result: ByteSink,
) {
private val uri: Uri = uri.toUri()
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
private val svgFetch = mimeType == SVG
private val tiffFetch = mimeType == MimeTypes.TIFF
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
private val customFetch = svgFetch || tiffFetch || multiPageFetch
suspend fun fetch() {
var bitmap: Bitmap? = null
var exception: Exception? = null
try {
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
// Fetch low quality thumbnails when size is not specified.
// As of Android 11, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation, but not flipped,
// so we skip this step for flipped entries.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bitmap = getByResolver()
} else if (StorageUtils.isMediaStoreContentUri(uri)) {
bitmap = getByMediaStore()
}
}
} catch (e: Exception) {
exception = e
}
// fallback if the native methods failed or for higher quality thumbnails
if (bitmap == null) {
try {
bitmap = getByGlide()
} catch (e: Exception) {
exception = e
}
}
if (bitmap != null) {
if (bitmap.width > width && bitmap.height > height) {
// rescale when the resulting bitmap is larger than requested
val scalingFactor: Double = min(bitmap.width.toDouble() / width, bitmap.height.toDouble() / height)
val dstWidth = (bitmap.width / scalingFactor).roundToInt()
val dstHeight = (bitmap.height / scalingFactor).roundToInt()
Log.d(
LOG_TAG, "rescale thumbnail for mimeType=$mimeType uri=$uri width=$width height=$height" +
", with bitmap byteCount=${bitmap.byteCount} size=${bitmap.width}x${bitmap.height}" +
", to target=${dstWidth}x${dstHeight}"
)
bitmap = bitmap.scale(dstWidth, dstHeight)
}
if (bitmap.byteCount > BITMAP_SIZE_DANGER_THRESHOLD) {
result.error(
"getThumbnail-large", "thumbnail bitmap dangerously large" +
" for mimeType=$mimeType uri=$uri pageId=$pageId width=$width height=$height" +
", with bitmap byteCount=${bitmap.byteCount} size=${bitmap.width}x${bitmap.height} config=${bitmap.config?.name}", null
)
return
}
}
// do not recycle bitmaps fetched from `ContentResolver` or Glide as their lifecycle is unknown
val bytes = BitmapUtils.getBytes(bitmap, recycle = false, decoded = decoded, mimeType)
if (bytes == null) {
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split(Regex("\n"), 2).first()
}
result.error("getThumbnail-null", "failed to get thumbnail for mimeType=$mimeType uri=$uri", errorDetails)
} else {
result.streamBytes(ByteArrayInputStream(bytes))
}
}
@RequiresApi(api = Build.VERSION_CODES.Q)
private fun getByResolver(): Bitmap? {
val resolver = context.contentResolver
var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null)
if (needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
return bitmap
}
private fun getByMediaStore(): Bitmap? {
val contentId = uri.tryParseId() ?: return null
val resolver = context.contentResolver
return if (isVideo(mimeType)) {
@Suppress("deprecation")
MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null)
} else {
@Suppress("deprecation")
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
// from Android 10 (API 29), returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
bitmap
}
}
private fun getByGlide(): Bitmap? {
// add signature to ignore cache for images which got modified but kept the same URI
var options = RequestOptions()
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedMillis-$rotationDegrees-$isFlipped-$width-$pageId"))
.override(width, height)
if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
}
val target = Glide.with(context)
.asBitmap()
.apply(options)
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.submit(width, height)
return try {
var bitmap = target.get()
if (needRotationAfterGlide(mimeType, pageId)) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
bitmap
} finally {
Glide.with(context).clear(target)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<ThumbnailFetcher>()
private const val BITMAP_SIZE_DANGER_THRESHOLD = 20 * (1 shl 20) // MB
}
}

View file

@ -0,0 +1,51 @@
package deckers.thibault.aves.decoding
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
import android.net.Uri
import deckers.thibault.aves.channel.streams.darttoplatform.ByteSink
import deckers.thibault.aves.glide.TiffFetcher
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.MimeTypes
import org.beyka.tiffbitmapfactory.DecodeArea
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.ByteArrayInputStream
class TiffRegionFetcher internal constructor(
private val context: Context,
) {
suspend fun fetch(
uri: Uri,
page: Int,
decoded: Boolean,
sampleSize: Int,
regionRect: Rect,
result: ByteSink,
) {
try {
val pfd = context.contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
result.error("fetch-fd", "failed to get file descriptor for uri=$uri", null)
return
}
pfd.use {
val fd = pfd.detachFd()
val options = TiffFetcher.buildOptions().apply {
inDirectoryNumber = page
inSampleSize = sampleSize
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
}
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
val bytes = BitmapUtils.getBytes(bitmap, recycle = true, decoded = decoded, MimeTypes.TIFF)
if (bytes == null) {
result.error("fetch-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
} else {
result.streamBytes(ByteArrayInputStream(bytes))
}
}
} catch (e: Exception) {
result.error("fetch-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message)
}
}
}

View file

@ -0,0 +1,91 @@
package deckers.thibault.aves.glide
import android.content.Context
import android.net.Uri
import android.text.format.Formatter
import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
import com.bumptech.glide.load.engine.cache.DiskCache
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
@GlideModule
class AvesAppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
// hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR)
// sizing
val memorySizeCalculator = MemorySizeCalculator.Builder(context).build()
builder.setMemorySizeCalculator(memorySizeCalculator)
val size: Int = memorySizeCalculator.bitmapPoolSize
if (size > 0) {
builder.setBitmapPool(LruBitmapPool(size.toLong()))
} else {
builder.setBitmapPool(BitmapPoolAdapter())
}
builder.setArrayPool(LruArrayPool(memorySizeCalculator.arrayPoolSizeInBytes))
builder.setMemoryCache(LruResourceCache(memorySizeCalculator.memoryCacheSize.toLong()))
val diskCacheSize = DiskCache.Factory.DEFAULT_DISK_CACHE_SIZE
val internalCacheDiskCacheFactory = InternalCacheDiskCacheFactory(context, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR, diskCacheSize.toLong())
builder.setDiskCache(internalCacheDiskCacheFactory)
fun toMb(bytes: Int) = Formatter.formatFileSize(context, bytes.toLong())
Log.d(
LOG_TAG, "Glide disk cache size=${toMb(diskCacheSize)}" +
", memory cache size=${toMb(memorySizeCalculator.memoryCacheSize)}" +
", bitmap pool size=${toMb(memorySizeCalculator.bitmapPoolSize)}" +
", array pool size=${toMb(memorySizeCalculator.arrayPoolSizeInBytes)}"
)
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383
registry.imageHeaderParsers.removeIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
}
override fun isManifestParsingEnabled(): Boolean = false
companion object {
private val LOG_TAG = LogUtils.createTag<AvesAppGlideModule>()
// request a fresh image with the highest quality format
val uncachedFullImageOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
fun getModel(context: Context, uri: Uri, mimeType: String, pageId: Int?, sizeBytes: Long? = null): Any {
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
MultiPageImage(context, uri, mimeType, pageId)
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(context, uri, pageId)
} else if (mimeType == MimeTypes.SVG) {
SvgImage(context, uri)
} else if (isVideo(mimeType)) {
VideoThumbnail(context, uri)
} else {
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
}
}
}
}

View file

@ -0,0 +1,89 @@
package deckers.thibault.aves.glide
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.data.DataFetcher.DataCallback
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.MultiTrackMedia
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isIsoBMFFImage
@GlideModule
class MultiPageImageGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(MultiPageImage::class.java, Bitmap::class.java, MultiPageThumbnailLoader.Factory())
}
}
class MultiPageImage(val context: Context, val uri: Uri, val mimeType: String, val pageId: Int?) {
override fun toString(): String = "MultiPageImage#${hashCode()}{uri=$uri mimeType=$mimeType pageId=$pageId}"
companion object {
fun isSupported(mimeType: String) = isIsoBMFFImage(mimeType) || mimeType == MimeTypes.JPEG
}
}
internal class MultiPageThumbnailLoader : ModelLoader<MultiPageImage, Bitmap> {
override fun buildLoadData(model: MultiPageImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), MultiPageImageFetcher(model, width, height))
}
override fun handles(model: MultiPageImage): Boolean = true
internal class Factory : ModelLoaderFactory<MultiPageImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiPageImage, Bitmap> = MultiPageThumbnailLoader()
override fun teardown() {}
}
}
internal class MultiPageImageFetcher(val model: MultiPageImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
callback.onLoadFailed(Exception("unsupported Android version"))
return
}
val context = model.context
val uri = model.uri
val mimeType = model.mimeType
var bitmap: Bitmap? = null
if (isIsoBMFFImage(mimeType)) {
val trackIndex = model.pageId
bitmap = MultiTrackMedia.getImage(context, uri, trackIndex)
} else if (mimeType == MimeTypes.JPEG) {
val pageIndex = model.pageId ?: 0
bitmap = MultiPage.getJpegMpfBitmap(context, uri, pageIndex)
}
if (bitmap == null) {
callback.onLoadFailed(Exception("null bitmap"))
} else {
callback.onDataReady(bitmap)
}
}
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

@ -0,0 +1,102 @@
package deckers.thibault.aves.glide
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import androidx.core.graphics.createBitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import com.caverock.androidsvg.utils.SVGAndroidRenderer
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
import deckers.thibault.aves.metadata.SvgHelper.IMAGE_BASE64_SIZE_DANGER_THRESHOLD
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.StorageUtils
import kotlin.math.ceil
@GlideModule
class SvgGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(SvgImage::class.java, Bitmap::class.java, SvgLoader.Factory())
}
}
class SvgImage(val context: Context, val uri: Uri)
internal class SvgLoader : ModelLoader<SvgImage, Bitmap> {
override fun buildLoadData(model: SvgImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), SvgFetcher(model, width, height))
}
override fun handles(model: SvgImage): Boolean = true
internal class Factory : ModelLoaderFactory<SvgImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgImage, Bitmap> = SvgLoader()
override fun teardown() {}
}
}
internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
val context = model.context
val uri = model.uri
val bitmap: Bitmap? = StorageUtils.openInputStream(context, uri)?.use { input ->
try {
SVG.getFromInputStream(SVGParserBufferedInputStream(input))?.let { svg ->
svg.normalizeSize()
val viewBox = svg.documentViewBox
val svgWidth = viewBox.width()
val svgHeight = viewBox.height()
val bitmapWidth: Int
val bitmapHeight: Int
if (width / height.toFloat() > svgWidth / svgHeight) {
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
bitmapHeight = height
} else {
bitmapWidth = width
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
}
val bitmap = createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
SVGAndroidRenderer.setImageBase64StringMaxSize(IMAGE_BASE64_SIZE_DANGER_THRESHOLD)
svg.renderToCanvas(canvas)
bitmap
}
} catch (ex: SVGParseException) {
callback.onLoadFailed(ex)
return
}
}
if (bitmap == null) {
callback.onLoadFailed(Exception("failed to load SVG for uri=$uri"))
} else {
callback.onDataReady(bitmap)
}
}
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

@ -0,0 +1,127 @@
package deckers.thibault.aves.glide
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.graphics.scale
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.data.DataFetcher.DataCallback
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
@GlideModule
class TiffGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(TiffImage::class.java, Bitmap::class.java, TiffLoader.Factory())
}
}
class TiffImage(val context: Context, val uri: Uri, val page: Int?)
internal class TiffLoader : ModelLoader<TiffImage, Bitmap> {
override fun buildLoadData(model: TiffImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), TiffFetcher(model, width, height))
}
override fun handles(model: TiffImage): Boolean = true
internal class Factory : ModelLoaderFactory<TiffImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<TiffImage, Bitmap> = TiffLoader()
override fun teardown() {}
}
}
internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
val context = model.context
val uri = model.uri
val page = model.page ?: 0
var sampleSize = 1
val customSize = width > 0 && height > 0
if (customSize) {
// determine sample size
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
callback.onLoadFailed(Exception("null file descriptor"))
return
}
val options = buildOptions().apply {
inJustDecodeBounds = true
inDirectoryNumber = page
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageWidth > width || imageHeight > height) {
while (imageHeight / (sampleSize * 2) >= height && imageWidth / (sampleSize * 2) >= width) {
sampleSize *= 2
}
}
}
// decode
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
callback.onLoadFailed(Exception("null file descriptor"))
return
}
val options = buildOptions().apply {
inDirectoryNumber = page
inSampleSize = sampleSize
}
try {
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
// calling `TiffBitmapFactory.closeFd(fd)` after decoding yields a segmentation fault
if (bitmap == null) {
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
} else if (customSize) {
val dstWidth: Int
val dstHeight: Int
val aspectRatio = bitmap.width.toFloat() / bitmap.height
if (aspectRatio > 1) {
dstWidth = (height * aspectRatio).toInt()
dstHeight = height
} else {
dstWidth = width
dstHeight = (width / aspectRatio).toInt()
}
callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
} else {
callback.onDataReady(bitmap)
}
} catch (e: Exception) {
callback.onLoadFailed(e)
}
}
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
companion object {
fun buildOptions(): TiffBitmapFactory.Options {
return TiffBitmapFactory.Options().apply {
inThrowException = true
inUseOrientationTag = false
}
}
}
}

View file

@ -0,0 +1,192 @@
package deckers.thibault.aves.glide
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.data.DataFetcher.DataCallback
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.IOException
import kotlin.math.min
import kotlin.math.roundToInt
@GlideModule
class VideoThumbnailGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(VideoThumbnail::class.java, Bitmap::class.java, VideoThumbnailLoader.Factory())
}
}
class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
}
override fun handles(model: VideoThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()
override fun teardown() {}
}
}
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
ioScope.launch {
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever == null) {
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
} else {
try {
var bitmap: Bitmap? = null
retriever.embeddedPicture?.let { bytes ->
try {
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
} catch (_: IOException) {
// ignore
}
}
if (bitmap == null) {
// there is no consistent strategy across devices to match
// the thumbnails returned by the content resolver / Media Store
// so we derive one in an arbitrary way
var timeMillis: Long? = null
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
if (durationMillis != null) {
timeMillis = if (durationMillis < 15000) 0 else min(durationMillis / 2, 15000)
}
val timeMicros = if (timeMillis != null) timeMillis * 1000 else -1
val option = MediaMetadataRetriever.OPTION_CLOSEST_SYNC
var videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull()
var videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull()
if (videoWidth == null || videoHeight == null) {
throw Exception("failed to get video dimensions")
}
val rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
val isRotated = rotationDegrees % 180 == 90
if (isRotated) {
videoWidth = videoHeight.also { videoHeight = videoWidth }
}
var dstWidth = 0
var dstHeight = 0
if (width > 0 && height > 0) {
// cover fit
val targetAspectRatio = width / height.toFloat()
val videoAspectRatio = videoWidth / videoHeight
if (targetAspectRatio > videoAspectRatio) {
dstHeight = (width / videoAspectRatio).roundToInt()
dstWidth = (dstHeight * videoAspectRatio).roundToInt()
} else {
dstWidth = (height * videoAspectRatio).roundToInt()
dstHeight = (dstWidth / videoAspectRatio).roundToInt()
}
}
if (dstWidth == 0 || dstWidth > videoWidth || dstHeight == 0 || dstHeight > videoHeight) {
dstWidth = videoWidth.toInt()
dstHeight = videoHeight.toInt()
}
// the returned frame is already rotated according to the video metadata
fun getFrameAtTime(timeMicros: Long): Bitmap? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
val pixelCount = dstWidth * dstHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight, getBitmapParams())
} else {
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
}
} else {
val pixelCount = videoWidth * videoHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
}
retriever.getFrameAtTime(timeMicros, option)
}
}
bitmap = getFrameAtTime(timeMicros)
if (bitmap == null && timeMicros > 0) {
// retry to get the first frame instead of an arbitrary one
bitmap = getFrameAtTime(0)
}
}
if (bitmap == null) {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
} else {
callback.onDataReady(bitmap)
}
} catch (e: Exception) {
callback.onLoadFailed(e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.P)
private fun getBitmapParams(): MediaMetadataRetriever.BitmapParams {
val params = MediaMetadataRetriever.BitmapParams()
params.preferredConfig = this.getPreferredConfig()
return params
}
private fun getPreferredConfig(): Bitmap.Config {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
// for wide-gamut and HDR content which does not require alpha blending
Bitmap.Config.RGBA_1010102
} else {
Bitmap.Config.ARGB_8888
}
}
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

@ -0,0 +1,229 @@
package deckers.thibault.aves.metadata
// DNG v1.6.0.0
// cf https://helpx.adobe.com/content/dam/help/en/photoshop/pdf/dng_spec_1_6_0_0.pdf
object DngTags {
private const val DNG_VERSION = 0xC612
private const val DNG_BACKWARD_VERSION = 0xC613
private const val UNIQUE_CAMERA_MODEL = 0xC614
private const val LOCALIZED_CAMERA_MODEL = 0xC615
private const val CFA_PLANE_COLOR = 0xC616
private const val CFA_LAYOUT = 0xC617
private const val LINEARIZATION_TABLE = 0xC618
private const val BLACK_LEVEL_REPEAT_DIM = 0xC619
private const val BLACK_LEVEL = 0xC61A
private const val BLACK_LEVEL_DELTA_H = 0xC61B
private const val BLACK_LEVEL_DELTA_V = 0xC61C
private const val WHITE_LEVEL = 0xC61D
private const val DEFAULT_SCALE = 0xC61E
private const val DEFAULT_CROP_ORIGIN = 0xC61F
private const val DEFAULT_CROP_SIZE = 0xC620
private const val COLOR_MATRIX_1 = 0xC621
private const val COLOR_MATRIX_2 = 0xC622
private const val CAMERA_CALIBRATION_1 = 0xC623
private const val CAMERA_CALIBRATION_2 = 0xC624
private const val REDUCTION_MATRIX_1 = 0xC625
private const val REDUCTION_MATRIX_2 = 0xC626
private const val ANALOG_BALANCE = 0xC627
private const val AS_SHOT_NEUTRAL = 0xC628
private const val AS_SHOT_WHITE_XY = 0xC629
private const val BASELINE_EXPOSURE = 0xC62A
private const val BASELINE_NOISE = 0xC62B
private const val BASELINE_SHARPNESS = 0xC62C
private const val BAYER_GREEN_SPLIT = 0xC62D
private const val LINEAR_RESPONSE_LIMIT = 0xC62E
private const val CAMERA_SERIAL_NUMBER = 0xC62F
private const val LENS_INFO = 0xC630
private const val CHROMA_BLUR_RADIUS = 0xC631
private const val ANTI_ALIAS_STRENGTH = 0xC632
private const val SHADOW_SCALE = 0xC633
private const val DNG_PRIVATE_DATA = 0xC634
private const val MAKER_NOTE_SAFETY = 0xC635
private const val CALIBRATION_ILLUMINANT_1 = 0xC65A
private const val CALIBRATION_ILLUMINANT_2 = 0xC65B
private const val BEST_QUALITY_SCALE = 0xC65C
private const val RAW_DATA_UNIQUE_ID = 0xC65D
private const val ORIGINAL_RAW_FILE_NAME = 0xC68B
private const val ORIGINAL_RAW_FILE_DATA = 0xC68C
private const val ACTIVE_AREA = 0xC68D
private const val MASKED_AREAS = 0xC68E
private const val AS_SHOT_ICC_PROFILE = 0xC68F
private const val AS_SHOT_PRE_PROFILE_MATRIX = 0xC690
private const val CURRENT_ICC_PROFILE = 0xC691
private const val CURRENT_PRE_PROFILE_MATRIX = 0xC692
private const val COLORIMETRIC_REFERENCE = 0xC6BF
private const val CAMERA_CALIBRATION_SIGNATURE = 0xC6F3
private const val PROFILE_CALIBRATION_SIGNATURE = 0xC6F4
private const val EXTRA_CAMERA_PROFILES = 0xC6F5
private const val AS_SHOT_PROFILE_NAME = 0xC6F6
private const val NOISE_REDUCTION_APPLIED = 0xC6F7
private const val PROFILE_NAME = 0xC6F8
private const val PROFILE_HUE_SAT_MAP_DIMS = 0xC6F9
private const val PROFILE_HUE_SAT_MAP_DATA_1 = 0xC6FA
private const val PROFILE_HUE_SAT_MAP_DATA_2 = 0xC6FB
private const val PROFILE_TONE_CURVE = 0xC6FC
private const val PROFILE_EMBED_POLICY = 0xC6FD
private const val PROFILE_COPYRIGHT = 0xC6FE
private const val FORWARD_MATRIX_1 = 0xC714
private const val FORWARD_MATRIX_2 = 0xC715
private const val PREVIEW_APPLICATION_NAME = 0xC716
private const val PREVIEW_APPLICATION_VERSION = 0xC717
private const val PREVIEW_SETTINGS_NAME = 0xC718
private const val PREVIEW_SETTINGS_DIGEST = 0xC719
private const val PREVIEW_COLOR_SPACE = 0xC71A
private const val PREVIEW_DATE_TIME = 0xC71B
private const val RAW_IMAGE_DIGEST = 0xC71C
private const val ORIGINAL_RAW_FILE_DIGEST = 0xC71D
private const val SUB_TILE_BLOCK_SIZE = 0xC71E
private const val ROW_INTERLEAVE_FACTOR = 0xC71F
private const val PROFILE_LOOK_TABLE_DIMS = 0xC725
private const val PROFILE_LOOK_TABLE_DATA = 0xC726
private const val OPCODE_LIST_1 = 0xC740
private const val OPCODE_LIST_2 = 0xC741
private const val OPCODE_LIST_3 = 0xC74E
private const val NOISE_PROFILE = 0xC761
private const val ORIGINAL_DEFAULT_FINAL_SIZE = 0xC791
private const val ORIGINAL_BEST_QUALITY_FINAL_SIZE = 0xC792
private const val ORIGINAL_DEFAULT_CROP_SIZE = 0xC793
private const val PROFILE_HUE_SAT_MAP_ENCODING = 0xC7A3
private const val PROFILE_LOOK_TABLE_ENCODING = 0xC7A4
private const val BASELINE_EXPOSURE_OFFSET = 0xC7A5
private const val DEFAULT_BLACK_RENDER = 0xC7A6
private const val NEW_RAW_IMAGE_DIGEST = 0xC7A7
private const val RAW_TO_PREVIEW_GAIN = 0xC7A8
private const val DEFAULT_USER_CROP = 0xC7B5
private const val DEPTH_FORMAT = 0xC7E9
private const val DEPTH_NEAR = 0xC7EA
private const val DEPTH_FAR = 0xC7EB
private const val DEPTH_UNITS = 0xC7EC
private const val DEPTH_MEASURE_TYPE = 0xC7ED
private const val ENHANCE_PARAMS = 0xC7EE
private const val PROFILE_GAIN_TABLE_MAP = 0xCD2D
private const val SEMANTIC_NAME = 0xCD2E
private const val SEMANTIC_INSTANCE_ID = 0xCD30
private const val CALIBRATION_ILLUMINANT_3 = 0xCD31
private const val CAMERA_CALIBRATION_3 = 0xCD32
private const val COLOR_MATRIX_3 = 0xCD33
private const val FORWARD_MATRIX_3 = 0xCD34
private const val ILLUMINANT_DATA_1 = 0xCD35
private const val ILLUMINANT_DATA_2 = 0xCD36
private const val ILLUMINANT_DATA_3 = 0xCD37
private const val MASK_SUB_AREA = 0xCD38
private const val PROFILE_HUE_SAT_MAP_DATA_3 = 0xCD39
private const val REDUCTION_MATRIX_3 = 0xCD3A
private const val RGB_TABLES = 0xCD3F
val tagNameMap = hashMapOf(
DNG_VERSION to "DNG Version",
DNG_BACKWARD_VERSION to "DNG Backward Version",
UNIQUE_CAMERA_MODEL to "Unique Camera Model",
LOCALIZED_CAMERA_MODEL to "Localized Camera Model",
CFA_PLANE_COLOR to "CFA Plane Color",
CFA_LAYOUT to "CFA Layout",
LINEARIZATION_TABLE to "Linearization Table",
BLACK_LEVEL_REPEAT_DIM to "Black Level Repeat Dim",
BLACK_LEVEL to "Black Level",
BLACK_LEVEL_DELTA_H to "Black Level Delta H",
BLACK_LEVEL_DELTA_V to "Black Level Delta V",
WHITE_LEVEL to "White Level",
DEFAULT_SCALE to "Default Scale",
DEFAULT_CROP_ORIGIN to "Default Crop Origin",
DEFAULT_CROP_SIZE to "Default Crop Size",
COLOR_MATRIX_1 to "Color Matrix 1",
COLOR_MATRIX_2 to "Color Matrix 2",
CAMERA_CALIBRATION_1 to "Camera Calibration 1",
CAMERA_CALIBRATION_2 to "Camera Calibration 2",
REDUCTION_MATRIX_1 to "Reduction Matrix 1",
REDUCTION_MATRIX_2 to "Reduction Matrix 2",
ANALOG_BALANCE to "Analog Balance",
AS_SHOT_NEUTRAL to "As Shot Neutral",
AS_SHOT_WHITE_XY to "As Shot White XY",
BASELINE_EXPOSURE to "Baseline Exposure",
BASELINE_NOISE to "Baseline Noise",
BASELINE_SHARPNESS to "Baseline Sharpness",
BAYER_GREEN_SPLIT to "Bayer Green Split",
LINEAR_RESPONSE_LIMIT to "Linear Response Limit",
CAMERA_SERIAL_NUMBER to "Camera Serial Number",
LENS_INFO to "Lens Info",
CHROMA_BLUR_RADIUS to "Chroma Blur Radius",
ANTI_ALIAS_STRENGTH to "Anti Alias Strength",
SHADOW_SCALE to "Shadow Scale",
DNG_PRIVATE_DATA to "DNG Private Data",
MAKER_NOTE_SAFETY to "Maker Note Safety",
CALIBRATION_ILLUMINANT_1 to "Calibration Illuminant 1",
CALIBRATION_ILLUMINANT_2 to "Calibration Illuminant 2",
BEST_QUALITY_SCALE to "Best Quality Scale",
RAW_DATA_UNIQUE_ID to "Raw Data Unique ID",
ORIGINAL_RAW_FILE_NAME to "Original Raw File Name",
ORIGINAL_RAW_FILE_DATA to "Original Raw File Data",
ACTIVE_AREA to "Active Area",
MASKED_AREAS to "Masked Areas",
AS_SHOT_ICC_PROFILE to "As Shot ICC Profile",
AS_SHOT_PRE_PROFILE_MATRIX to "As Shot Pre Profile Matrix",
CURRENT_ICC_PROFILE to "Current ICC Profile",
CURRENT_PRE_PROFILE_MATRIX to "Current Pre Profile Matrix",
COLORIMETRIC_REFERENCE to "Colorimetric Reference",
CAMERA_CALIBRATION_SIGNATURE to "Camera Calibration Signature",
PROFILE_CALIBRATION_SIGNATURE to "Profile Calibration Signature",
EXTRA_CAMERA_PROFILES to "Extra Camera Profiles",
AS_SHOT_PROFILE_NAME to "As Shot Profile Name",
NOISE_REDUCTION_APPLIED to "Noise Reduction Applied",
PROFILE_NAME to "Profile Name",
PROFILE_HUE_SAT_MAP_DIMS to "Profile Hue Sat Map Dims",
PROFILE_HUE_SAT_MAP_DATA_1 to "Profile Hue Sat Map Data 1",
PROFILE_HUE_SAT_MAP_DATA_2 to "Profile Hue Sat Map Data 2",
PROFILE_TONE_CURVE to "Profile Tone Curve",
PROFILE_EMBED_POLICY to "Profile Embed Policy",
PROFILE_COPYRIGHT to "Profile Copyright",
FORWARD_MATRIX_1 to "Forward Matrix 1",
FORWARD_MATRIX_2 to "Forward Matrix 2",
PREVIEW_APPLICATION_NAME to "Preview Application Name",
PREVIEW_APPLICATION_VERSION to "Preview Application Version",
PREVIEW_SETTINGS_NAME to "Preview Settings Name",
PREVIEW_SETTINGS_DIGEST to "Preview Settings Digest",
PREVIEW_COLOR_SPACE to "Preview Color Space",
PREVIEW_DATE_TIME to "Preview Date Time",
RAW_IMAGE_DIGEST to "Raw Image Digest",
ORIGINAL_RAW_FILE_DIGEST to "Original Raw File Digest",
SUB_TILE_BLOCK_SIZE to "Sub Tile Block Size",
ROW_INTERLEAVE_FACTOR to "Row Interleave Factor",
PROFILE_LOOK_TABLE_DIMS to "Profile Look Table Dims",
PROFILE_LOOK_TABLE_DATA to "Profile Look Table Data",
OPCODE_LIST_1 to "Opcode List 1",
OPCODE_LIST_2 to "Opcode List 2",
OPCODE_LIST_3 to "Opcode List 3",
NOISE_PROFILE to "Noise Profile",
ORIGINAL_DEFAULT_FINAL_SIZE to "Original Default Final Size",
ORIGINAL_BEST_QUALITY_FINAL_SIZE to "Original Best Quality Final Size",
ORIGINAL_DEFAULT_CROP_SIZE to "Original Default Crop Size",
PROFILE_HUE_SAT_MAP_ENCODING to "Profile Hue Sat Map Encoding",
PROFILE_LOOK_TABLE_ENCODING to "Profile Look Table Encoding",
BASELINE_EXPOSURE_OFFSET to "Baseline Exposure Offset",
DEFAULT_BLACK_RENDER to "Default Black Render",
NEW_RAW_IMAGE_DIGEST to "New Raw Image Digest",
RAW_TO_PREVIEW_GAIN to "Raw To Preview Gain",
DEFAULT_USER_CROP to "Default User Crop",
DEPTH_FORMAT to "Depth Format",
DEPTH_NEAR to "Depth Near",
DEPTH_FAR to "Depth Far",
DEPTH_UNITS to "Depth Units",
DEPTH_MEASURE_TYPE to "Depth Measure Type",
ENHANCE_PARAMS to "Enhance Params",
PROFILE_GAIN_TABLE_MAP to "Profile Gain Table Map",
SEMANTIC_NAME to "Semantic Name",
SEMANTIC_INSTANCE_ID to "Semantic Instance ID",
CALIBRATION_ILLUMINANT_3 to "Calibration Illuminant 3",
CAMERA_CALIBRATION_3 to "Camera Calibration 3",
COLOR_MATRIX_3 to "Color Matrix 3",
FORWARD_MATRIX_3 to "Forward Matrix 3",
ILLUMINANT_DATA_1 to "Illuminant Data 1",
ILLUMINANT_DATA_2 to "Illuminant Data 2",
ILLUMINANT_DATA_3 to "Illuminant Data 3",
MASK_SUB_AREA to "Mask Sub Area",
PROFILE_HUE_SAT_MAP_DATA_3 to "Profile Hue Sat Map Data 3",
REDUCTION_MATRIX_3 to "Reduction Matrix 3",
RGB_TABLES to "RGB Tables",
)
val tags = tagNameMap.keys
}

View file

@ -0,0 +1,420 @@
package deckers.thibault.aves.metadata
import android.util.Log
import com.drew.lang.Rational
import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifThumbnailDirectory
import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.exif.PanasonicRawIFD0Directory
import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import deckers.thibault.aves.utils.LogUtils
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToLong
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
val GPS_DATE_FORMAT = SimpleDateFormat("yyyy:MM:dd", Locale.ROOT)
val GPS_TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.ROOT)
private const val PRECISION_ERROR_TOLERANCE = 1e-10
// ExifInterface always states it has the following attributes
// and returns "0" instead of "null" when they are actually missing
private val neverNullTags = listOf(
ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_LIGHT_SOURCE,
ExifInterface.TAG_ORIENTATION,
)
private fun isNeverNull(tag: String): Boolean = neverNullTags.contains(tag)
private val baseTags: Map<String, TagMapper?> = mapOf(
ExifInterface.TAG_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_ARTIST to TagMapper(ExifDirectoryBase.TAG_ARTIST, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_BITS_PER_SAMPLE to TagMapper(ExifDirectoryBase.TAG_BITS_PER_SAMPLE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_BODY_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_BODY_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_BRIGHTNESS_VALUE to TagMapper(ExifDirectoryBase.TAG_BRIGHTNESS_VALUE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_CAMERA_OWNER_NAME to TagMapper(ExifDirectoryBase.TAG_CAMERA_OWNER_NAME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_CFA_PATTERN to TagMapper(ExifDirectoryBase.TAG_CFA_PATTERN, DirType.EXIF_IFD0, TagFormat.BYTE), // spec format: UNDEFINED, e.g. [Red,Green][Green,Blue]
ExifInterface.TAG_COLOR_SPACE to TagMapper(ExifDirectoryBase.TAG_COLOR_SPACE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_COMPONENTS_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_COMPONENTS_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.BYTE), // spec format: UNDEFINED, e.g. [Y,Cb,Cr]
ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_COMPRESSED_AVERAGE_BITS_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_COMPRESSION to TagMapper(ExifDirectoryBase.TAG_COMPRESSION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_CONTRAST to TagMapper(ExifDirectoryBase.TAG_CONTRAST, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_COPYRIGHT to TagMapper(ExifDirectoryBase.TAG_COPYRIGHT, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_CUSTOM_RENDERED to TagMapper(ExifDirectoryBase.TAG_CUSTOM_RENDERED, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_DATETIME to TagMapper(ExifDirectoryBase.TAG_DATETIME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DATETIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_DATETIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DATETIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_DATETIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_DEVICE_SETTING_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_DIGITAL_ZOOM_RATIO to TagMapper(ExifDirectoryBase.TAG_DIGITAL_ZOOM_RATIO, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXIF_VERSION to TagMapper(ExifDirectoryBase.TAG_EXIF_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_EXPOSURE_BIAS_VALUE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_BIAS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXPOSURE_MODE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_EXPOSURE_PROGRAM to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_PROGRAM, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_EXPOSURE_TIME to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_TIME, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FILE_SOURCE to TagMapper(ExifDirectoryBase.TAG_FILE_SOURCE, DirType.EXIF_IFD0, TagFormat.SHORT), // spec format: UNDEFINED
ExifInterface.TAG_FLASH to TagMapper(ExifDirectoryBase.TAG_FLASH, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FLASHPIX_VERSION to TagMapper(ExifDirectoryBase.TAG_FLASHPIX_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_FLASH_ENERGY to TagMapper(ExifDirectoryBase.TAG_FLASH_ENERGY, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_LENGTH to TagMapper(ExifDirectoryBase.TAG_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM to TagMapper(ExifDirectoryBase.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_F_NUMBER to TagMapper(ExifDirectoryBase.TAG_FNUMBER, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_GAIN_CONTROL to TagMapper(ExifDirectoryBase.TAG_GAIN_CONTROL, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_GAMMA to TagMapper(ExifDirectoryBase.TAG_GAMMA, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_IMAGE_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_IMAGE_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_IMAGE_UNIQUE_ID to TagMapper(ExifDirectoryBase.TAG_IMAGE_UNIQUE_ID, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_INTEROPERABILITY_INDEX to TagMapper(ExifDirectoryBase.TAG_INTEROP_INDEX, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_ISO_SPEED to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_YYY, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_ZZZ, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_LENS_MAKE to TagMapper(ExifDirectoryBase.TAG_LENS_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LENS_MODEL to TagMapper(ExifDirectoryBase.TAG_LENS_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LENS_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_LENS_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LENS_SPECIFICATION to TagMapper(ExifDirectoryBase.TAG_LENS_SPECIFICATION, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_LIGHT_SOURCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_MAKE to TagMapper(ExifDirectoryBase.TAG_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_MAKER_NOTE to TagMapper(ExifDirectoryBase.TAG_MAKERNOTE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_MAX_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_MAX_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_METERING_MODE to TagMapper(ExifDirectoryBase.TAG_METERING_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_MODEL to TagMapper(ExifDirectoryBase.TAG_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_NEW_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_NEW_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_OECF to TagMapper(ExifDirectoryBase.TAG_OPTO_ELECTRIC_CONVERSION_FUNCTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_OFFSET_TIME to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_OFFSET_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_OFFSET_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_ORIENTATION to TagMapper(ExifDirectoryBase.TAG_ORIENTATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_ISO_EQUIVALENT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION to TagMapper(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PIXEL_X_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_PIXEL_Y_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_PLANAR_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_PLANAR_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PRIMARY_CHROMATICITIES to TagMapper(ExifDirectoryBase.TAG_PRIMARY_CHROMATICITIES, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_RECOMMENDED_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_REFERENCE_BLACK_WHITE to TagMapper(ExifDirectoryBase.TAG_REFERENCE_BLACK_WHITE, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_RELATED_SOUND_FILE to TagMapper(ExifDirectoryBase.TAG_RELATED_SOUND_FILE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_ROWS_PER_STRIP to TagMapper(ExifDirectoryBase.TAG_ROWS_PER_STRIP, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SAMPLES_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_SAMPLES_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SATURATION to TagMapper(ExifDirectoryBase.TAG_SATURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SCENE_CAPTURE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_CAPTURE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SCENE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT), // spec format: UNDEFINED
ExifInterface.TAG_SENSING_METHOD to TagMapper(ExifDirectoryBase.TAG_SENSING_METHOD, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SENSITIVITY_TYPE to TagMapper(ExifDirectoryBase.TAG_SENSITIVITY_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SHARPNESS to TagMapper(ExifDirectoryBase.TAG_SHARPNESS, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SHUTTER_SPEED_VALUE to TagMapper(ExifDirectoryBase.TAG_SHUTTER_SPEED, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_SOFTWARE to TagMapper(ExifDirectoryBase.TAG_SOFTWARE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE to TagMapper(ExifDirectoryBase.TAG_SPATIAL_FREQ_RESPONSE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_SPECTRAL_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_SPECTRAL_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_STANDARD_OUTPUT_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_STRIP_BYTE_COUNTS to TagMapper(ExifDirectoryBase.TAG_STRIP_BYTE_COUNTS, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_STRIP_OFFSETS to TagMapper(ExifDirectoryBase.TAG_STRIP_OFFSETS, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBJECT_AREA to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION_TIFF_EP, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBJECT_DISTANCE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_SUBJECT_DISTANCE_RANGE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE_RANGE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBJECT_LOCATION to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBSEC_TIME to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0101
ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0100
ExifInterface.TAG_TRANSFER_FUNCTION to TagMapper(ExifDirectoryBase.TAG_TRANSFER_FUNCTION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_USER_COMMENT to TagMapper(ExifDirectoryBase.TAG_USER_COMMENT, DirType.EXIF_IFD0, TagFormat.COMMENT),
ExifInterface.TAG_WHITE_BALANCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_WHITE_POINT to TagMapper(ExifDirectoryBase.TAG_WHITE_POINT, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_Y_CB_CR_COEFFICIENTS to TagMapper(ExifDirectoryBase.TAG_YCBCR_COEFFICIENTS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_Y_CB_CR_POSITIONING to TagMapper(ExifDirectoryBase.TAG_YCBCR_POSITIONING, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING to TagMapper(ExifDirectoryBase.TAG_YCBCR_SUBSAMPLING, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
)
private val thumbnailTags: Map<String, TagMapper?> = mapOf(
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0201
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0202
)
private val gpsTags: Map<String, TagMapper?> = mapOf(
ExifInterface.TAG_GPS_ALTITUDE to TagMapper(GpsDirectory.TAG_ALTITUDE, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_ALTITUDE_REF to TagMapper(GpsDirectory.TAG_ALTITUDE_REF, DirType.GPS, TagFormat.BYTE),
ExifInterface.TAG_GPS_AREA_INFORMATION to TagMapper(GpsDirectory.TAG_AREA_INFORMATION, DirType.GPS, TagFormat.COMMENT),
ExifInterface.TAG_GPS_DATESTAMP to TagMapper(GpsDirectory.TAG_DATE_STAMP, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_BEARING to TagMapper(GpsDirectory.TAG_DEST_BEARING, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_DEST_BEARING_REF to TagMapper(GpsDirectory.TAG_DEST_BEARING_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_DISTANCE to TagMapper(GpsDirectory.TAG_DEST_DISTANCE, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_DEST_DISTANCE_REF to TagMapper(GpsDirectory.TAG_DEST_DISTANCE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_LATITUDE to TagMapper(GpsDirectory.TAG_DEST_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_DEST_LATITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_LONGITUDE to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DIFFERENTIAL to TagMapper(GpsDirectory.TAG_DIFFERENTIAL, DirType.GPS, TagFormat.SHORT),
ExifInterface.TAG_GPS_DOP to TagMapper(GpsDirectory.TAG_DOP, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_H_POSITIONING_ERROR to TagMapper(GpsDirectory.TAG_H_POSITIONING_ERROR, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_IMG_DIRECTION to TagMapper(GpsDirectory.TAG_IMG_DIRECTION, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_IMG_DIRECTION_REF to TagMapper(GpsDirectory.TAG_IMG_DIRECTION_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_LATITUDE to TagMapper(GpsDirectory.TAG_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_LATITUDE_REF to TagMapper(GpsDirectory.TAG_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_LONGITUDE to TagMapper(GpsDirectory.TAG_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_MAP_DATUM to TagMapper(GpsDirectory.TAG_MAP_DATUM, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_MEASURE_MODE to TagMapper(GpsDirectory.TAG_MEASURE_MODE, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_PROCESSING_METHOD to TagMapper(GpsDirectory.TAG_PROCESSING_METHOD, DirType.GPS, TagFormat.COMMENT),
ExifInterface.TAG_GPS_SATELLITES to TagMapper(GpsDirectory.TAG_SATELLITES, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_SPEED to TagMapper(GpsDirectory.TAG_SPEED, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_SPEED_REF to TagMapper(GpsDirectory.TAG_SPEED_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_STATUS to TagMapper(GpsDirectory.TAG_STATUS, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_TIMESTAMP to TagMapper(GpsDirectory.TAG_TIME_STAMP, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_TRACK to TagMapper(GpsDirectory.TAG_TRACK, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_TRACK_REF to TagMapper(GpsDirectory.TAG_TRACK_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_VERSION_ID to TagMapper(GpsDirectory.TAG_VERSION_ID, DirType.GPS, TagFormat.BYTE),
)
private val xmpTags: Map<String, TagMapper?> = mapOf(
ExifInterface.TAG_XMP to null, // IFD_TIFF_TAGS 0x02BC
)
private val rawTags: Map<String, TagMapper?> = mapOf(
// DNG
ExifInterface.TAG_DEFAULT_CROP_SIZE to null, // IFD_EXIF_TAGS 0xC620
ExifInterface.TAG_DNG_VERSION to null, // IFD_EXIF_TAGS 0xC612
// ORF
ExifInterface.TAG_ORF_ASPECT_FRAME to TagMapper(OlympusImageProcessingMakernoteDirectory.TagAspectFrame, DirType.OIPM, TagFormat.LONG), // ORF_IMAGE_PROCESSING_TAGS 0x1113
ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageLength, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0102
ExifInterface.TAG_ORF_PREVIEW_IMAGE_START to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageStart, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0101
ExifInterface.TAG_ORF_THUMBNAIL_IMAGE to TagMapper(OlympusMakernoteDirectory.TAG_THUMBNAIL_IMAGE, DirType.OM, TagFormat.UNDEFINED), // ORF_MAKER_NOTE_TAGS 0x0100
// RW2
ExifInterface.TAG_RW2_ISO to TagMapper(PanasonicRawIFD0Directory.TagIso, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0017
ExifInterface.TAG_RW2_JPG_FROM_RAW to TagMapper(PanasonicRawIFD0Directory.TagJpgFromRaw, DirType.PRIFD0, TagFormat.UNDEFINED), // IFD_TIFF_TAGS 0x002E
ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorBottomBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0006
ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorLeftBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0005
ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorRightBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0007
ExifInterface.TAG_RW2_SENSOR_TOP_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorTopBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0004
)
// list of known ExifInterface tags (as of androidx.exifinterface:exifinterface:1.3.0)
// mapped to metadata-extractor tags (as of v2.14.0)
val allTags: Map<String, TagMapper?> = hashMapOf<String, TagMapper?>(
).apply {
putAll(baseTags)
putAll(thumbnailTags)
putAll(gpsTags)
putAll(xmpTags)
putAll(rawTags)
}
fun describeAll(exif: ExifInterface): Map<String, Map<String, String>> {
// initialize metadata-extractor directories that we will fill
// by tags converted from the ExifInterface attributes
// so that we can rely on metadata-extractor descriptions
val dirs = DirType.entries.associateWith { it.createDirectory() }
// exclude Exif directory when it only includes image size
val isUselessExif = fun(it: Map<String, String>): Boolean {
return it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width")
}
return HashMap<String, Map<String, String>>().apply {
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())
put("Exif Thumbnail", describeDir(exif, dirs, thumbnailTags))
put(Metadata.DIR_GPS, describeDir(exif, dirs, gpsTags))
put(Metadata.DIR_XMP, describeDir(exif, dirs, xmpTags))
put("Exif Raw", describeDir(exif, dirs, rawTags))
}.filterValues { it.isNotEmpty() }
}
private fun describeDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>): Map<String, String> {
val dirMap = HashMap<String, String>()
fillMetadataExtractorDir(exif, metadataExtractorDirs, tags)
for ((exifInterfaceTag, mapper) in tags) {
if (exif.hasAttribute(exifInterfaceTag)) {
val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && !(value == "0" && isNeverNull(exifInterfaceTag))) {
if (mapper != null) {
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
val type = mapper.type
val tagName = dir.getTagName(type)
val description: String? = dir.getDescription(type)
if (description != null) {
dirMap[tagName] = description
} else {
Log.w(LOG_TAG, "failed to get description for tag=$exifInterfaceTag value=$value")
dirMap[tagName] = value
}
} else {
dirMap[exifInterfaceTag] = value
}
}
}
}
return dirMap
}
private fun fillMetadataExtractorDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>) {
for ((exifInterfaceTag, mapper) in tags) {
if (exif.hasAttribute(exifInterfaceTag) && mapper != null) {
val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
val obj: Any? = when (mapper.format) {
TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value
TagFormat.BYTE -> exif.getAttributeBytes(exifInterfaceTag)
TagFormat.SHORT -> value.toShortOrNull()
TagFormat.LONG -> value.toLongOrNull()
TagFormat.RATIONAL -> toRational(value)
TagFormat.RATIONAL_ARRAY -> toRationalArray(value)
null -> null
}
if (obj != null) {
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
dir.setObject(mapper.type, obj)
}
}
}
}
}
private fun toRational(s: String?): Rational? {
s ?: return null
// e.g. "12345/100" to Rational(12345, 100)
val parts = s.split("/")
if (parts.size == 2) {
val numerator = parts[0].toLongOrNull() ?: return null
val denominator = parts[1].toLongOrNull() ?: return null
return Rational(numerator, denominator)
}
var d = s.toDoubleOrNull() ?: return null
if (d == 0.0) return Rational(0, 1)
// e.g. "0.02564102564102564" to Rational(1, 39)
if (d < 1) {
val numerator = 1L
val f = numerator / d
val denominator = f.roundToLong()
if (abs(f - denominator) < PRECISION_ERROR_TOLERANCE) {
return Rational(numerator, denominator)
}
}
// e.g. "123.45" to Rational(12345, 100)
var denominator: Long = 1
while (d != floor(d)) {
denominator *= 10
d *= 10
if (denominator > 10000000000) {
// let's not get irrational
return null
}
}
val numerator: Long = d.roundToLong()
return Rational(numerator, denominator)
}
private fun toRationalArray(s: String?): Array<Rational>? {
s ?: return null
val list = s.split(",").mapNotNull { toRational(it) }
if (list.isEmpty()) return null
return list.toTypedArray()
}
// extensions
fun ExifInterface.getSafeInt(tag: String, acceptZero: Boolean = true, save: (value: Int) -> Unit) {
if (this.hasAttribute(tag)) {
val value = this.getAttributeInt(tag, 0)
if (acceptZero || value != 0) {
save(value)
}
}
}
fun ExifInterface.getSafeDouble(tag: String, save: (value: Double) -> Unit) {
if (this.hasAttribute(tag)) {
val value = this.getAttributeDouble(tag, Double.NaN)
if (!value.isNaN()) {
save(value)
}
}
}
fun ExifInterface.getSafeRational(tag: String, save: (value: Rational) -> Unit) {
if (this.hasAttribute(tag)) {
val value = toRational(this.getAttribute(tag))
if (value != null) {
save(value)
}
}
}
fun ExifInterface.getSafeDateMillis(tag: String, subSecTag: String?, save: (value: Long) -> Unit) {
if (this.hasAttribute(tag)) {
val dateString = this.getAttribute(tag)
if (dateString != null) {
try {
DATETIME_FORMAT.parse(dateString)?.let { date ->
var dateMillis = date.time
if (subSecTag != null && this.hasAttribute(subSecTag)) {
dateMillis += Metadata.parseSubSecond(this.getAttribute(subSecTag))
}
save(dateMillis)
}
} catch (e: ParseException) {
Log.w(LOG_TAG, "failed to parse date=$dateString", e)
}
}
}
}
}
enum class DirType {
EXIF_IFD0 {
override fun createDirectory() = ExifIFD0Directory()
},
EXIF_THUMBNAIL {
override fun createDirectory() = ExifThumbnailDirectory(0)
},
GPS {
override fun createDirectory() = GpsDirectory()
},
OIPM {
override fun createDirectory() = OlympusImageProcessingMakernoteDirectory()
},
OCSM {
override fun createDirectory() = OlympusCameraSettingsMakernoteDirectory()
},
OM {
override fun createDirectory() = OlympusMakernoteDirectory()
},
PRIFD0 {
override fun createDirectory() = PanasonicRawIFD0Directory()
};
abstract fun createDirectory(): Directory
}
enum class TagFormat {
ASCII, COMMENT, BYTE, SHORT, LONG, RATIONAL, RATIONAL_ARRAY, UNDEFINED
}
data class TagMapper(val type: Int, val dirType: DirType, val format: TagFormat?)

View file

@ -0,0 +1,61 @@
package deckers.thibault.aves.metadata
/*
Exif tags missing from `metadata-extractor`
Photoshop
https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFFphotoshop.pdf
*/
object ExifTags {
private const val PROCESSING_SOFTWARE = 0x000b
private const val X_POSITION = 0x011e
private const val Y_POSITION = 0x011f
private const val T4_OPTIONS = 0x0124
private const val T6_OPTIONS = 0x0125
private const val COLOR_MAP = 0x0140
private const val EXTRA_SAMPLES = 0x0152
private const val SAMPLE_FORMAT = 0x0153
private const val SMIN_SAMPLE_VALUE = 0x0154
private const val SMAX_SAMPLE_VALUE = 0x0155
private const val SONY_RAW_FILE_TYPE = 0x7000
private const val SONY_TONE_CURVE = 0x7010
private const val MATTEING = 0x80e3
// sensing method (0x9217) redundant with sensing method (0xA217)
private const val SENSING_METHOD = 0x9217
private const val IMAGE_SOURCE_DATA = 0x935c
private const val GDAL_METADATA = 0xa480
private const val GDAL_NO_DATA = 0xa481
private val tagNameMap = hashMapOf(
PROCESSING_SOFTWARE to "Processing Software",
X_POSITION to "X Position",
Y_POSITION to "Y Position",
T4_OPTIONS to "T4 Options",
T6_OPTIONS to "T6 Options",
COLOR_MAP to "Color Map",
EXTRA_SAMPLES to "Extra Samples",
SAMPLE_FORMAT to "Sample Format",
SMIN_SAMPLE_VALUE to "S Min Sample Value",
SMAX_SAMPLE_VALUE to "S Max Sample Value",
SONY_RAW_FILE_TYPE to "Sony Raw File Type",
SONY_TONE_CURVE to "Sony Tone Curve",
MATTEING to "Matteing",
SENSING_METHOD to "Sensing Method (0x9217)",
IMAGE_SOURCE_DATA to "Image Source Data",
GDAL_METADATA to "GDAL Metadata",
GDAL_NO_DATA to "GDAL No Data",
).apply {
putAll(DngTags.tagNameMap)
putAll(ExifGeoTiffTags.tagNameMap)
}
fun isDngTag(tag: Int) = DngTags.tags.contains(tag)
fun isGeoTiffTag(tag: Int) = ExifGeoTiffTags.tags.contains(tag)
fun getTagName(tag: Int): String? {
return tagNameMap[tag]
}
}

View file

@ -0,0 +1,116 @@
package deckers.thibault.aves.metadata
object GeoTiffKeys {
// not a standard tag
const val GEOTIFF_VERSION = 0
private const val MODEL_TYPE = 0x0400
private const val RASTER_TYPE = 0x0401
private const val CITATION = 0x0402
private const val GEOG_TYPE = 0x0800
private const val GEOG_CITATION = 0x0801
private const val GEOG_GEODETIC_DATUM = 0x0802
private const val GEOG_LINEAR_UNITS = 0x0804
private const val GEOG_ANGULAR_UNITS = 0x0806
private const val GEOG_ELLIPSOID = 0x0808
private const val GEOG_SEMI_MAJOR_AXIS = 0x0809
private const val GEOG_SEMI_MINOR_AXIS = 0x080a
private const val GEOG_INV_FLATTENING = 0x080b
private const val PROJ_CS_TYPE = 0x0c00
private const val PROJ_CS_CITATION = 0x0c01
private const val PROJECTION = 0x0c02
private const val PROJ_COORD_TRANS = 0x0c03
private const val PROJ_LINEAR_UNITS = 0x0c04
private const val PROJ_STD_PARALLEL_1 = 0x0c06
private const val PROJ_STD_PARALLEL_2 = 0x0c07
private const val PROJ_NAT_ORIGIN_LONG = 0x0c08
private const val PROJ_NAT_ORIGIN_LAT = 0x0c09
private const val PROJ_FALSE_EASTING = 0x0c0a
private const val PROJ_FALSE_NORTHING = 0x0c0b
private const val PROJ_SCALE_AT_NAT_ORIGIN = 0x0c14
private const val PROJ_AZIMUTH_ANGLE = 0x0c16
private const val VERTICAL_UNITS = 0x1003
private val tagNameMap = hashMapOf(
GEOTIFF_VERSION to "GeoTIFF Version",
MODEL_TYPE to "Model Type",
RASTER_TYPE to "Raster Type",
CITATION to "Citation",
GEOG_TYPE to "Geographic Type",
GEOG_CITATION to "Geographic Citation",
GEOG_GEODETIC_DATUM to "Geographic Geodetic Datum",
GEOG_LINEAR_UNITS to "Geographic Linear Units",
GEOG_ANGULAR_UNITS to "Geographic Angular Units",
GEOG_ELLIPSOID to "Geographic Ellipsoid",
GEOG_SEMI_MAJOR_AXIS to "Semi-major axis",
GEOG_SEMI_MINOR_AXIS to "Semi-minor axis",
GEOG_INV_FLATTENING to "Inv. Flattening",
PROJ_CS_TYPE to "Projected Coordinate System Type",
PROJ_CS_CITATION to "Projected Coordinate System Citation",
PROJECTION to "Projection",
PROJ_COORD_TRANS to "Projected Coordinate Transform",
PROJ_LINEAR_UNITS to "Projection Linear Units",
PROJ_STD_PARALLEL_1 to "Projection Standard Parallel 1",
PROJ_STD_PARALLEL_2 to "Projection Standard Parallel 2",
PROJ_NAT_ORIGIN_LONG to "Projection Natural Origin Longitude",
PROJ_NAT_ORIGIN_LAT to "Projection Natural Origin Latitude",
PROJ_FALSE_EASTING to "Projection False Easting",
PROJ_FALSE_NORTHING to "Projection False Northing",
PROJ_SCALE_AT_NAT_ORIGIN to "Projection Scale at Natural Origin",
PROJ_AZIMUTH_ANGLE to "Projection Azimuth Angle",
VERTICAL_UNITS to "Vertical Units",
)
fun getTagName(tag: Int): String? {
return tagNameMap[tag]
}
}
object ExifGeoTiffTags {
// ModelPixelScaleTag (optional)
// Tag = 33550 (830E.H)
// Type = DOUBLE
// Count = 3
const val TAG_MODEL_PIXEL_SCALE = 0x830e
// ModelTiePointTag (conditional)
// Tag = 33922 (8482.H)
// Type = DOUBLE
// Count = 6*K, K = number of tie points
const val TAG_MODEL_TIE_POINT = 0x8482
// ModelTransformationTag (conditional)
// Tag = 34264 (85D8.H)
// Type = DOUBLE
// Count = 16
const val TAG_MODEL_TRANSFORMATION = 0x85d8
// GeoKeyDirectoryTag (mandatory)
// Tag = 34735 (87AF.H)
// Type = UNSIGNED SHORT
// Count = variable, >= 4
const val TAG_GEO_KEY_DIRECTORY = 0x87af
// GeoDoubleParamsTag (optional)
// Tag = 34736 (87BO.H)
// Type = DOUBLE
// Count = variable
const val TAG_GEO_DOUBLE_PARAMS = 0x87b0
// GeoAsciiParamsTag (optional)
// Tag = 34737 (87B1.H)
// Type = ASCII
// Count = variable
const val TAG_GEO_ASCII_PARAMS = 0x87b1
val tagNameMap = hashMapOf(
TAG_GEO_ASCII_PARAMS to "Geo Ascii Params",
TAG_GEO_DOUBLE_PARAMS to "Geo Double Params",
TAG_GEO_KEY_DIRECTORY to "Geo Key Directory",
TAG_MODEL_PIXEL_SCALE to "Model Pixel Scale",
TAG_MODEL_TIE_POINT to "Model Tie Points",
TAG_MODEL_TRANSFORMATION to "Model Transformation",
)
val tags = tagNameMap.keys
}

Some files were not shown because too many files have changed in this diff Show more