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'