diff --git a/android/app/src/main/kotlin/com/jamshalat/jamshalat_masjid_screen/MainActivity.kt b/android/app/src/main/kotlin/com/jamshalat/jamshalat_masjid_screen/MainActivity.kt index 3f30ffd..79b4543 100644 --- a/android/app/src/main/kotlin/com/jamshalat/jamshalat_masjid_screen/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jamshalat/jamshalat_masjid_screen/MainActivity.kt @@ -1,5 +1,217 @@ 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.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("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()) + return + } + + try { + val uris = mutableListOf() + 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()) + return + } + + val copiedPaths = mutableListOf() + 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> { + val result = LinkedHashMap>() + 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 + } +} diff --git a/lib/data/services/tv_media_picker_service.dart b/lib/data/services/tv_media_picker_service.dart new file mode 100644 index 0000000..47ccb30 --- /dev/null +++ b/lib/data/services/tv_media_picker_service.dart @@ -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 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> pickImages({ + required bool allowMultiple, + }) async { + if (!Platform.isAndroid) return const []; + + try { + final raw = await _channel.invokeMethod>( + '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> listPickers() async { + if (!Platform.isAndroid) return const []; + final raw = await _channel.invokeMethod>('listPickers'); + return _parseHandlers(raw); + } + + List _parseHandlers(dynamic raw) { + if (raw is! List) return const []; + final handlers = []; + 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; + } +} diff --git a/lib/features/admin/admin_screen.dart b/lib/features/admin/admin_screen.dart index 0097db1..965b43e 100644 --- a/lib/features/admin/admin_screen.dart +++ b/lib/features/admin/admin_screen.dart @@ -1,16 +1,19 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hugeicons/hugeicons.dart'; import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; import '../../core/sacred_tokens.dart'; import '../../providers.dart'; import '../../data/services/sync_service.dart'; import '../../data/services/myquran_service.dart'; import '../../data/services/sound_service.dart'; +import '../../data/services/tv_media_picker_service.dart'; import '../../data/services/update_service.dart'; import 'package:file_picker/file_picker.dart'; import 'dart:io'; @@ -344,20 +347,31 @@ class _AdminScreenState extends ConsumerState { ); } + Future _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 _pickSlideshowImages() async { try { - final res = await FilePicker.platform.pickFiles( - type: FileType.image, - allowMultiple: true, - ); - if (res == null) return; - + final pickedPaths = await _pickImagePaths(allowMultiple: true); + if (pickedPaths.isEmpty) return; var hasNewImage = false; setState(() { - for (final path in res.paths) { - if (path != null && - File(path).existsSync() && - !_slideshowImages.contains(path)) { + for (final path in pickedPaths) { + if (File(path).existsSync() && !_slideshowImages.contains(path)) { _slideshowImages.add(path); hasNewImage = true; } @@ -378,6 +392,88 @@ class _AdminScreenState extends ConsumerState { } } + Future> _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 = []; + 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 _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 _savePengumuman({ String message = 'Pengaturan pengumuman otomatis tersimpan', }) async { @@ -1986,28 +2082,9 @@ class _AdminScreenState extends ConsumerState { _buildTampilanActionButton( rowIndex: pickBrandedBgRow, s: s, - onActivate: () async { - 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', - ); - } - }, + onActivate: _pickBrandedImage, child: ElevatedButton.icon( - onPressed: () async { - 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', - ); - } - }, + onPressed: _pickBrandedImage, icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s), label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)), style: _tvElevatedActionStyle( diff --git a/lib/features/home/unsplash_background.dart b/lib/features/home/unsplash_background.dart index fb992c4..90e1654 100644 --- a/lib/features/home/unsplash_background.dart +++ b/lib/features/home/unsplash_background.dart @@ -48,10 +48,8 @@ class _UnsplashBackgroundState extends ConsumerState { Future runFetch() async { if (!ref.read(settingsProvider).useUnsplashBackground) return; final requestId = ++_fetchNonce; - final randomPage = 1 + _rng.nextInt(10); final urls = await UnsplashService.instance.fetchLandscapeBackgrounds( keyword, - page: randomPage, ); if (!mounted || requestId != _fetchNonce) return; if (urls.isNotEmpty) { diff --git a/pubspec.yaml b/pubspec.yaml index 3762756..3a91d7a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: jamshalat_masjid_screen description: Smart Digital Prayer Clock for Android TV Box publish_to: 'none' -version: 1.0.9+10 +version: 1.0.10+11 environment: sdk: '>=3.0.0 <4.0.0'