diff --git a/android/app/build.gradle b/android/app/build.gradle index 1aed202..f8a0e3b 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -19,12 +19,12 @@ if (project.hasProperty('google-services.json')) { def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { - flutterVersionCode = '65' + flutterVersionCode = '67' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { - flutterVersionName = '2.0.27' + flutterVersionName = '2.0.28' } def keystoreProperties = new Properties() diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 408af73..da220b8 100755 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,14 +1,23 @@ - + - - + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/nh/ind/nhance/benefits/MainActivity.kt b/android/app/src/main/kotlin/nh/ind/nhance/benefits/MainActivity.kt index daccc4a..4435112 100755 --- a/android/app/src/main/kotlin/nh/ind/nhance/benefits/MainActivity.kt +++ b/android/app/src/main/kotlin/nh/ind/nhance/benefits/MainActivity.kt @@ -1,32 +1,103 @@ package nh.ind.nhance.benefits -import android.media.MediaScannerConnection -import io.flutter.embedding.engine.FlutterEngine +import android.content.ContentValues +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.IOException -class MainActivity: FlutterFragmentActivity() { - private val mediaScanChannel = "nhance/media_scan" +class MainActivity : FlutterFragmentActivity() { + private val downloadsChannel = "nhance/downloads" override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, mediaScanChannel) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, downloadsChannel) .setMethodCallHandler { call, result -> - if (call.method == "scanFile") { - val path = call.argument("path") - if (path.isNullOrBlank()) { - result.error("invalid_path", "Path is required", null) - return@setMethodCallHandler + when (call.method) { + "getSdkInt" -> result.success(Build.VERSION.SDK_INT) + "saveToDownloads" -> { + val fileName = call.argument("fileName") + val bytes = call.argument("bytes") + if (fileName.isNullOrBlank() || bytes == null) { + result.error("invalid_args", "fileName and bytes are required", null) + return@setMethodCallHandler + } + try { + result.success(saveToDownloads(fileName, bytes)) + } catch (e: Exception) { + result.error("save_failed", e.message, null) + } } - MediaScannerConnection.scanFile( - applicationContext, - arrayOf(path), - null - ) { _, _ -> } - result.success(true) - } else { - result.notImplemented() + "openFile" -> { + val path = call.argument("path") + if (path.isNullOrBlank()) { + result.error("invalid_path", "Path is required", null) + return@setMethodCallHandler + } + try { + openFile(path) + result.success(true) + } catch (e: Exception) { + result.error("open_failed", e.message, null) + } + } + else -> result.notImplemented() } } } + + private fun saveToDownloads(fileName: String, bytes: ByteArray): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = applicationContext.contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + ?: throw IOException("Unable to create download entry") + resolver.openOutputStream(uri)?.use { it.write(bytes) } + ?: throw IOException("Unable to write download") + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, contentValues, null, null) + return uri.toString() + } + + @Suppress("DEPRECATION") + val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!dir.exists()) { + dir.mkdirs() + } + val file = File(dir, fileName) + file.writeBytes(bytes) + return file.absolutePath + } + + private fun openFile(path: String) { + val uri: Uri = if (path.startsWith("content://")) { + Uri.parse(path) + } else { + val file = File(path) + FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileProvider.com.crazecoder.openfile", + file + ) + } + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/pdf") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + } } diff --git a/lib/config/environment.dart b/lib/config/environment.dart index 151210b..027fdeb 100755 --- a/lib/config/environment.dart +++ b/lib/config/environment.dart @@ -5,7 +5,7 @@ class Environment { static Flavor flavor = Flavor.dev; // overwritten by each main_*.dart /// Chatbot FAB + window. Set to `true` when ready to enable. - static const bool chatbotEnabled = false; + static const bool chatbotEnabled = true; static bool get isProd => flavor == Flavor.prod || flavor == Flavor.prod1; diff --git a/lib/pages/helpers/android_download_helper.dart b/lib/pages/helpers/android_download_helper.dart new file mode 100644 index 0000000..c01c1c4 --- /dev/null +++ b/lib/pages/helpers/android_download_helper.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +const MethodChannel _downloadsChannel = MethodChannel('nhance/downloads'); + +Future androidSdkInt() async { + if (!Platform.isAndroid) return null; + try { + final sdkInt = await _downloadsChannel.invokeMethod('getSdkInt'); + return sdkInt; + } catch (_) { + return null; + } +} + +Future savePdfToAndroidDownloads({ + required String fileName, + required List bytes, +}) async { + final savedPath = await _downloadsChannel.invokeMethod( + 'saveToDownloads', + { + 'fileName': fileName, + 'bytes': bytes, + }, + ); + if (savedPath == null || savedPath.isEmpty) { + throw const FileSystemException('Unable to save file to Downloads'); + } + return savedPath; +} + +Future openAndroidDownloadedFile(String path) async { + await _downloadsChannel.invokeMethod('openFile', {'path': path}); +} diff --git a/lib/pages/helpers/app_permission_service.dart b/lib/pages/helpers/app_permission_service.dart index 8e51340..cc7f2f6 100644 --- a/lib/pages/helpers/app_permission_service.dart +++ b/lib/pages/helpers/app_permission_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'android_download_helper.dart'; + class AppPermissionService { AppPermissionService._(); @@ -10,7 +12,10 @@ class AppPermissionService { if (kIsWeb || !Platform.isAndroid) return; await Permission.notification.request(); - // Keep app-open flow lightweight: no redirection to Android settings page. - await Permission.storage.request(); + + final sdkInt = await androidSdkInt(); + if (sdkInt != null && sdkInt < 29) { + await Permission.storage.request(); + } } } diff --git a/lib/pages/helpers/ecard_download_notification_service.dart b/lib/pages/helpers/ecard_download_notification_service.dart index a067455..f966b52 100644 --- a/lib/pages/helpers/ecard_download_notification_service.dart +++ b/lib/pages/helpers/ecard_download_notification_service.dart @@ -1,7 +1,11 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:open_filex/open_filex.dart'; +import 'android_download_helper.dart'; + class EcardDownloadNotificationService { EcardDownloadNotificationService._(); @@ -25,6 +29,10 @@ class EcardDownloadNotificationService { onDidReceiveNotificationResponse: (NotificationResponse response) async { final filePath = response.payload; if (filePath == null || filePath.isEmpty) return; + if (Platform.isAndroid) { + await openAndroidDownloadedFile(filePath); + return; + } await OpenFilex.open(filePath); }, ); diff --git a/lib/pages/helpers/ecard_download_service_io.dart b/lib/pages/helpers/ecard_download_service_io.dart index 31e469c..3dd5e53 100644 --- a/lib/pages/helpers/ecard_download_service_io.dart +++ b/lib/pages/helpers/ecard_download_service_io.dart @@ -1,29 +1,26 @@ import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart'; +import 'android_download_helper.dart'; import 'download_result.dart'; final Dio _dio = Dio(); -const MethodChannel _mediaScanChannel = MethodChannel('nhance/media_scan'); Future ensureStorageAccessImpl() async { if (Platform.isAndroid) { - // Request notification permission (best-effort for Android 13+). await Permission.notification.request(); - // Prefer broad access for writing to public Download directory. - if (await Permission.manageExternalStorage.isGranted) return true; - final manage = await Permission.manageExternalStorage.request(); - if (manage.isGranted) return true; - - // Fallback for older devices. - final storage = await Permission.storage.request(); - return storage.isGranted; + final sdkInt = await androidSdkInt(); + if (sdkInt != null && sdkInt < 29) { + final storage = await Permission.storage.request(); + return storage.isGranted; + } + return true; } return true; } @@ -49,21 +46,17 @@ Future downloadEcardImpl({ ); } - final Directory downloadDir = await _resolveDownloadDirectory(); - if (!await downloadDir.exists()) { - await downloadDir.create(recursive: true); - } - final safeFileName = _sanitizeFileName(fileName); - final filePath = '${downloadDir.path}/$safeFileName'; - final file = File(filePath); - if (await file.exists()) { - await file.delete(); + final tempDir = await getTemporaryDirectory(); + final tempPath = '${tempDir.path}/$safeFileName'; + final tempFile = File(tempPath); + if (await tempFile.exists()) { + await tempFile.delete(); } await _dio.download( url, - filePath, + tempPath, options: Options( headers: headers, responseType: ResponseType.bytes, @@ -71,18 +64,23 @@ Future downloadEcardImpl({ deleteOnError: true, ); - if (!await file.exists()) { + if (!await tempFile.exists()) { return DownloadResult.failure('Download failed'); } - final length = await file.length(); - if (length == 0) { - await file.delete(); + final bytes = await tempFile.readAsBytes(); + if (bytes.isEmpty) { + await tempFile.delete(); return DownloadResult.failure('Download failed'); } - await _scanFileForAndroidMediaIndex(filePath); + + final savedPath = await savePdfToAndroidDownloads( + fileName: safeFileName, + bytes: bytes, + ); + await tempFile.delete(); return DownloadResult.success( - savedPath: filePath, + savedPath: savedPath, message: 'File saved under Download/$safeFileName', ); } on FileSystemException { @@ -93,6 +91,8 @@ Future downloadEcardImpl({ ? 'Download failed (${e.response!.statusCode})' : 'Download failed'; return DownloadResult.failure(message); + } on PlatformException catch (e) { + return DownloadResult.failure(e.message ?? 'Download failed'); } catch (_) { return DownloadResult.failure('Download failed'); } @@ -153,23 +153,3 @@ String _sanitizeFileName(String fileName) { if (replaced.isEmpty) return 'ecard.pdf'; return replaced.toLowerCase().endsWith('.pdf') ? replaced : '$replaced.pdf'; } - -Future _resolveDownloadDirectory() async { - if (Platform.isAndroid) { - final dir = Directory('/storage/emulated/0/Download'); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dir; - } - return getApplicationDocumentsDirectory(); -} - -Future _scanFileForAndroidMediaIndex(String filePath) async { - if (!Platform.isAndroid) return; - try { - await _mediaScanChannel.invokeMethod('scanFile', {'path': filePath}); - } catch (_) { - // Non-fatal: file is already saved even if scan fails. - } -} diff --git a/pubspec.yaml b/pubspec.yaml index ceb574e..8ccd51b 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,8 +17,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. #version: 1.2.41+98 -version: 1.0.38+44 -#version: 2.0.27+65 +version: 1.0.39+45 +#version: 2.0.28+67 environment: sdk: '>=3.3.3 <4.0.0'