first commit
This commit is contained in:
commit
ba9986027e
2048 changed files with 478613 additions and 0 deletions
1
.flutter-plugins-dependencies
Normal file
1
.flutter-plugins-dependencies
Normal file
File diff suppressed because one or more lines are too long
3
.fvmrc
Normal file
3
.fvmrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"flutter": "3.35.7"
|
||||||
|
}
|
||||||
148
.gitignore
vendored
Normal file
148
.gitignore
vendored
Normal 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
4
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[submodule ".flutter"]
|
||||||
|
path = .flutter
|
||||||
|
url = https://github.com/flutter/flutter.git
|
||||||
|
branch = main
|
||||||
30
.metadata
Normal file
30
.metadata
Normal 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
18
.pre-commit-config.yaml
Normal 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
|
||||||
624
ARCHITETTURA_PLUGIN_google_cast_pratica.md
Normal file
624
ARCHITETTURA_PLUGIN_google_cast_pratica.md
Normal 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
1920
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
369
CHEATSHEET_google_cast_debug.md
Normal file
369
CHEATSHEET_google_cast_debug.md
Normal 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
29
DB_managing.md
Normal 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
0
Get
Normal file
29
LICENSE
Normal file
29
LICENSE
Normal 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
0
Process
Normal file
803
README.md
Normal file
803
README.md
Normal 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
73
READNE_AVES.DB.md
Normal 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
0
Run
Normal file
BIN
__pycache__/analyze_first_moof.cpython-311.pyc
Normal file
BIN
__pycache__/analyze_first_moof.cpython-311.pyc
Normal file
Binary file not shown.
371
a.dart
Normal file
371
a.dart
Normal 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
663
activity.md
Normal 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
333
activity1.md
Normal 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
237
activity2.md
Normal 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
577
activity3.md
Normal 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
37
activity_status.md
Normal 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
29
album.md
Normal 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
47
analysis_options.yaml
Normal 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
339
analyze_first_moof.py
Normal 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
16
android/.gitignore
vendored
Normal 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
|
||||||
82
android/app/agconnect-services.json
Normal file
82
android/app/agconnect-services.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
255
android/app/build.gradle.kts
Normal file
255
android/app/build.gradle.kts
Normal 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
6
android/app/proguard-rules.pro
vendored
Normal 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.**
|
||||||
12
android/app/src/debug/AndroidManifest.xml
Normal file
12
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
4
android/app/src/debug/res/values/colors.xml
Normal file
4
android/app/src/debug/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_flavour">#815AFA</color>
|
||||||
|
</resources>
|
||||||
4
android/app/src/debug/res/values/strings.xml
Normal file
4
android/app/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves [Debug]</string>
|
||||||
|
</resources>
|
||||||
4
android/app/src/libre/res/values/strings.xml
Normal file
4
android/app/src/libre/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves Libre</string>
|
||||||
|
</resources>
|
||||||
4
android/app/src/libreDebug/res/values/strings.xml
Normal file
4
android/app/src/libreDebug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves Libre [Debug]</string>
|
||||||
|
</resources>
|
||||||
4
android/app/src/libreProfile/res/values/strings.xml
Normal file
4
android/app/src/libreProfile/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves Libre [Profile]</string>
|
||||||
|
</resources>
|
||||||
164
android/app/src/main/AndroidManifest.xml
Normal file
164
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
162
android/app/src/main/AndroidManifest.xml.ok
Normal file
162
android/app/src/main/AndroidManifest.xml.ok
Normal 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>
|
||||||
173
android/app/src/main/AndroidManifest.xml.old
Normal file
173
android/app/src/main/AndroidManifest.xml.old
Normal 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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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?)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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?)
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in a new issue