fix(tv-picker): native Android TV image picker for branded/slideshow + bump 1.0.10+11

This commit is contained in:
dwindown
2026-04-05 16:01:46 +07:00
parent 98b8437e87
commit ba407b1848
5 changed files with 404 additions and 35 deletions

View File

@@ -1,5 +1,217 @@
package com.jamshalat.jamshalat_masjid_screen package com.jamshalat.jamshalat_masjid_screen
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream
import kotlin.random.Random
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
private companion object {
const val CHANNEL_NAME = "jamshalat/tv_media_picker"
const val METHOD_PICK_IMAGES = "pickImages"
const val METHOD_LIST_HANDLERS = "listPickers"
const val REQUEST_PICK_IMAGES = 49011
}
private var pendingResult: MethodChannel.Result? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL_NAME)
.setMethodCallHandler { call, result ->
when (call.method) {
METHOD_PICK_IMAGES -> handlePickImages(call, result)
METHOD_LIST_HANDLERS -> result.success(listPickers())
else -> result.notImplemented()
}
}
}
private fun handlePickImages(call: MethodCall, result: MethodChannel.Result) {
if (pendingResult != null) {
result.error("BUSY", "Media picker request is already running.", null)
return
}
val allowMultiple = call.argument<Boolean>("allowMultiple") ?: false
val intent = createPickIntent(allowMultiple)
if (intent == null) {
result.error(
"NO_PICKER",
"No compatible file manager/document picker found.",
listPickers(),
)
return
}
pendingResult = result
startActivityForResult(intent, REQUEST_PICK_IMAGES)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != REQUEST_PICK_IMAGES) return
val result = pendingResult ?: return
pendingResult = null
if (resultCode != Activity.RESULT_OK) {
result.success(emptyList<String>())
return
}
try {
val uris = mutableListOf<Uri>()
data?.data?.let { uris.add(it) }
data?.clipData?.let { clip ->
for (index in 0 until clip.itemCount) {
clip.getItemAt(index).uri?.let { uris.add(it) }
}
}
val uniqueUris = uris.distinctBy { it.toString() }
if (uniqueUris.isEmpty()) {
result.success(emptyList<String>())
return
}
val copiedPaths = mutableListOf<String>()
for (uri in uniqueUris) {
copyUriToCache(uri)?.let { copiedPaths.add(it) }
}
result.success(copiedPaths)
} catch (error: Exception) {
result.error("PICK_FAILED", error.message, null)
}
}
private fun createPickIntent(allowMultiple: Boolean): Intent? {
val openDocumentIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "image/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
putExtra(Intent.EXTRA_LOCAL_ONLY, true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
)
}
}
if (openDocumentIntent.resolveActivity(packageManager) != null) {
return openDocumentIntent
}
val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "image/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
}
if (getContentIntent.resolveActivity(packageManager) != null) {
return Intent.createChooser(getContentIntent, "Pilih gambar")
}
return null
}
private fun listPickers(): List<Map<String, String>> {
val result = LinkedHashMap<String, Map<String, String>>()
val intents = listOf(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "image/*"
},
Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "image/*"
},
)
for (intent in intents) {
val resolved = packageManager.queryIntentActivities(intent, 0)
for (info in resolved) {
val packageName = info.activityInfo.packageName ?: continue
if (result.containsKey(packageName)) continue
val label = info.loadLabel(packageManager)?.toString()?.trim().orEmpty()
result[packageName] = mapOf(
"packageName" to packageName,
"label" to if (label.isEmpty()) packageName else label,
)
}
}
return result.values.toList()
}
private fun copyUriToCache(uri: Uri): String? {
if (uri.scheme == "content") {
try {
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (_: SecurityException) {
// Non-persistable provider, ignore.
} catch (_: UnsupportedOperationException) {
// Provider doesn't support persisted grants.
}
}
val inputStream = contentResolver.openInputStream(uri) ?: return null
inputStream.use { input ->
val ext = resolveExtension(uri)
val folder = File(cacheDir, "picked_images")
if (!folder.exists()) {
folder.mkdirs()
}
val fileName = "img_${System.currentTimeMillis()}_${Random.nextInt(1000, 9999)}.$ext"
val outputFile = File(folder, fileName)
FileOutputStream(outputFile).use { output ->
input.copyTo(output)
}
return outputFile.absolutePath
}
}
private fun resolveExtension(uri: Uri): String {
val mimeType = contentResolver.getType(uri)
val extFromMime = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
if (!extFromMime.isNullOrBlank()) {
return extFromMime
}
val displayName = queryDisplayName(uri)
if (!displayName.isNullOrBlank()) {
val dot = displayName.lastIndexOf('.')
if (dot in 1 until displayName.length - 1) {
return displayName.substring(dot + 1)
}
}
return "jpg"
}
private fun queryDisplayName(uri: Uri): String? {
contentResolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getString(0)
}
}
return null
}
}

View File

@@ -0,0 +1,82 @@
import 'dart:io';
import 'package:flutter/services.dart';
class TvPickerHandler {
final String packageName;
final String label;
const TvPickerHandler({
required this.packageName,
required this.label,
});
}
class TvMediaPickerUnavailable implements Exception {
final String message;
final List<TvPickerHandler> handlers;
const TvMediaPickerUnavailable({
required this.message,
required this.handlers,
});
}
class TvMediaPickerService {
TvMediaPickerService._();
static final TvMediaPickerService instance = TvMediaPickerService._();
static const MethodChannel _channel =
MethodChannel('jamshalat/tv_media_picker');
Future<List<String>> pickImages({
required bool allowMultiple,
}) async {
if (!Platform.isAndroid) return const [];
try {
final raw = await _channel.invokeMethod<List<dynamic>>(
'pickImages',
{'allowMultiple': allowMultiple},
);
if (raw == null) return const [];
return raw
.map((item) => item?.toString() ?? '')
.where((path) => path.isNotEmpty)
.toList(growable: false);
} on PlatformException catch (error) {
if (error.code == 'NO_PICKER') {
throw TvMediaPickerUnavailable(
message: error.message ??
'Tidak ada aplikasi pemilih file yang kompatibel di perangkat.',
handlers: _parseHandlers(error.details),
);
}
rethrow;
}
}
Future<List<TvPickerHandler>> listPickers() async {
if (!Platform.isAndroid) return const [];
final raw = await _channel.invokeMethod<List<dynamic>>('listPickers');
return _parseHandlers(raw);
}
List<TvPickerHandler> _parseHandlers(dynamic raw) {
if (raw is! List) return const [];
final handlers = <TvPickerHandler>[];
for (final item in raw) {
if (item is! Map) continue;
final packageName = item['packageName']?.toString() ?? '';
final label = item['label']?.toString() ?? '';
if (packageName.isEmpty) continue;
handlers.add(
TvPickerHandler(
packageName: packageName,
label: label.isEmpty ? packageName : label,
),
);
}
return handlers;
}
}

View File

@@ -1,16 +1,19 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart'; import 'package:hugeicons/hugeicons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/sacred_tokens.dart'; import '../../core/sacred_tokens.dart';
import '../../providers.dart'; import '../../providers.dart';
import '../../data/services/sync_service.dart'; import '../../data/services/sync_service.dart';
import '../../data/services/myquran_service.dart'; import '../../data/services/myquran_service.dart';
import '../../data/services/sound_service.dart'; import '../../data/services/sound_service.dart';
import '../../data/services/tv_media_picker_service.dart';
import '../../data/services/update_service.dart'; import '../../data/services/update_service.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'dart:io'; import 'dart:io';
@@ -344,20 +347,31 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
); );
} }
Future<void> _pickBrandedImage() async {
try {
final pickedPaths = await _pickImagePaths(allowMultiple: false);
if (pickedPaths.isEmpty) return;
setState(() => _brandedBgImage = pickedPaths.first);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
} catch (e) {
if (!mounted) return;
_showStatusBadge(
'Gagal membuka pemilih file. Pastikan file manager tersedia di perangkat.',
isError: true,
);
}
}
Future<void> _pickSlideshowImages() async { Future<void> _pickSlideshowImages() async {
try { try {
final res = await FilePicker.platform.pickFiles( final pickedPaths = await _pickImagePaths(allowMultiple: true);
type: FileType.image, if (pickedPaths.isEmpty) return;
allowMultiple: true,
);
if (res == null) return;
var hasNewImage = false; var hasNewImage = false;
setState(() { setState(() {
for (final path in res.paths) { for (final path in pickedPaths) {
if (path != null && if (File(path).existsSync() && !_slideshowImages.contains(path)) {
File(path).existsSync() &&
!_slideshowImages.contains(path)) {
_slideshowImages.add(path); _slideshowImages.add(path);
hasNewImage = true; hasNewImage = true;
} }
@@ -378,6 +392,88 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
} }
} }
Future<List<String>> _pickImagePaths({
required bool allowMultiple,
}) async {
if (Platform.isAndroid) {
try {
return await TvMediaPickerService.instance.pickImages(
allowMultiple: allowMultiple,
);
} on TvMediaPickerUnavailable catch (error) {
if (!mounted) return const [];
final detected = error.handlers.map((handler) => handler.label).join(', ');
final supported = [
'File Commander',
'X-plore',
'Cx File Explorer',
'Files by Google',
].join(', ');
final message = detected.isNotEmpty
? 'Pemilih TV tidak tersedia. Aplikasi terdeteksi: $detected. Gunakan salah satu yang mendukung pemilih dokumen: $supported.'
: 'Tidak ada pemilih file Android TV yang kompatibel. Instal salah satu: $supported.';
_showStatusBadge(message, isError: true);
return const [];
} on MissingPluginException {
// Fallback below if native Android channel is not available.
}
}
final picked = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: allowMultiple,
withData: true,
withReadStream: true,
);
if (picked == null) return const [];
final resolvedPaths = <String>[];
for (final file in picked.files) {
if (file.path != null && File(file.path!).existsSync()) {
resolvedPaths.add(file.path!);
continue;
}
final persisted = await _persistPickedImageFile(file);
if (persisted != null) {
resolvedPaths.add(persisted);
}
}
return resolvedPaths;
}
Future<String?> _persistPickedImageFile(PlatformFile file) async {
final hasBytes = file.bytes != null;
final stream = file.readStream;
if (!hasBytes && stream == null) return null;
final supportDir = await getApplicationSupportDirectory();
final mediaDir = Directory('${supportDir.path}/picked_images');
await mediaDir.create(recursive: true);
final ext = _extractImageExtension(file.name);
final target = File(
'${mediaDir.path}/img_${DateTime.now().millisecondsSinceEpoch}_${1000 + Random().nextInt(9000)}.$ext',
);
if (file.bytes != null) {
await target.writeAsBytes(file.bytes!, flush: true);
return target.path;
}
final sink = target.openWrite();
await stream!.pipe(sink);
await sink.close();
return target.path;
}
String _extractImageExtension(String name) {
final dot = name.lastIndexOf('.');
if (dot > 0 && dot < name.length - 1) {
return name.substring(dot + 1).toLowerCase();
}
return 'jpg';
}
Future<void> _savePengumuman({ Future<void> _savePengumuman({
String message = 'Pengaturan pengumuman otomatis tersimpan', String message = 'Pengaturan pengumuman otomatis tersimpan',
}) async { }) async {
@@ -1986,28 +2082,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_buildTampilanActionButton( _buildTampilanActionButton(
rowIndex: pickBrandedBgRow, rowIndex: pickBrandedBgRow,
s: s, s: s,
onActivate: () async { onActivate: _pickBrandedImage,
final res = await FilePicker.platform.pickFiles(type: FileType.image);
final selectedPath = res?.files.single.path;
if (selectedPath != null && File(selectedPath).existsSync()) {
setState(() => _brandedBgImage = selectedPath);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
}
},
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () async { onPressed: _pickBrandedImage,
final res = await FilePicker.platform.pickFiles(type: FileType.image);
final selectedPath = res?.files.single.path;
if (selectedPath != null &&
File(selectedPath).existsSync()) {
setState(() => _brandedBgImage = selectedPath);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
}
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s), icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s),
label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)), label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)),
style: _tvElevatedActionStyle( style: _tvElevatedActionStyle(

View File

@@ -48,10 +48,8 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
Future<void> runFetch() async { Future<void> runFetch() async {
if (!ref.read(settingsProvider).useUnsplashBackground) return; if (!ref.read(settingsProvider).useUnsplashBackground) return;
final requestId = ++_fetchNonce; final requestId = ++_fetchNonce;
final randomPage = 1 + _rng.nextInt(10);
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds( final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(
keyword, keyword,
page: randomPage,
); );
if (!mounted || requestId != _fetchNonce) return; if (!mounted || requestId != _fetchNonce) return;
if (urls.isNotEmpty) { if (urls.isNotEmpty) {

View File

@@ -1,7 +1,7 @@
name: jamshalat_masjid_screen name: jamshalat_masjid_screen
description: Smart Digital Prayer Clock for Android TV Box description: Smart Digital Prayer Clock for Android TV Box
publish_to: 'none' publish_to: 'none'
version: 1.0.9+10 version: 1.0.10+11
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'