Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ lib/
│ ├── network/ # ApiClient + ApiTier + ApiPaths, region failover,
│ │ # SSE, ETag cache, meteor delta decode
│ ├── notifications/ # FCM/APNs + awesome channels, tap routing seam
│ ├── platform/ # native-first device_info / compass / battery / tier
│ ├── platform/ # native-first device_info / compass / battery / tier /
│ │ # gallery image saver
│ ├── realtime/ # polling spine (channel/state/clock/staleness/SSE)
│ ├── settings/ # typed Prefs facade + persisted/ephemeral controllers
│ ├── storage/ # SQLite stores (ETag cache, network usage)
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
keeps running on aggressive battery managers (safety-app use). -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>

<!-- Saving the report image (ImageSaverChannel). Android 9 and below write
into the public Pictures directory, which needs this grant; Android 10+
goes through MediaStore and needs nothing, so the declaration stops at
28 rather than asking for storage the app no longer uses. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>

<!-- Bluetooth (meshtastic_flutter / flutter_blue_plus): connect to LoRa
mesh radios over BLE. The maxSdkVersion=30 pair is the legacy API;
CONNECT/SCAN cover Android 12+ (runtime permissions, requested by the
Expand Down
178 changes: 178 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/ImageSaverChannel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.exptech.dpip

import android.Manifest
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.IOException

/**
* Saves encoded image bytes into the device's photo library.
*
* The counterpart of iOS's `ImageSaverPlugin`, and the same channel name and
* protocol, so the Dart side is one implementation and the platform is the only
* difference. The bytes are the ones the app already fetched (the CWA report
* image), so nothing here downloads anything.
*
* Two paths, because the storage model changed in Android 10:
*
* - **API 29+** writes through MediaStore with `RELATIVE_PATH`, which needs no
* storage permission at all and lets the gallery index the file itself. The
* row is inserted as `IS_PENDING` and published only once the bytes are
* written, so a camera roll can never pick up a half-written image.
* - **API 26–28** writes into the public Pictures directory, which does need
* `WRITE_EXTERNAL_STORAGE` (declared `maxSdkVersion="28"`). Losing that grant
* is reported as `permission_denied` rather than guessed at here: Dart asks
* for the permission and retries once.
*
* Nothing is overwritten. A name that already exists gets a ` (n)` suffix, the
* way a desktop file manager would — saving the same report twice is a normal
* thing to do, and silently replacing the first copy is not.
*/
class ImageSaverChannel(private val context: Context) :
MethodChannel.MethodCallHandler {

companion object {
const val NAME = "com.exptech.dpip/image_saver"

/** The album the saved pictures land in, shared with iOS's asset album. */
private const val ALBUM = "DPIP"
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"save" -> save(call, result)
"status" -> result.success(status())
else -> result.notImplemented()
}
}

/**
* Whether a save can be written right now.
*
* API 29+ needs no permission (`granted` unconditionally): MediaStore owns
* the write, and the manifest caps `WRITE_EXTERNAL_STORAGE` at 28 so asking
* on a newer device would be a prompt nobody can grant. Only the legacy
* public-directory path reports `denied`, which is what the Dart side turns
* into a runtime request.
*/
private fun status(): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
) {
"granted"
} else {
"denied"
}

private fun save(call: MethodCall, result: MethodChannel.Result) {
val bytes = call.argument<ByteArray>("bytes")
val fileName = call.argument<String>("fileName")
val mimeType = call.argument<String>("mimeType") ?: "image/png"
if (bytes == null || bytes.isEmpty() || fileName.isNullOrBlank()) {
result.error("invalid_args", "bytes and fileName are required", null)
return
}

try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveToMediaStore(bytes, fileName, mimeType)
} else {
saveToPicturesDirectory(bytes, fileName, mimeType)
}
result.success(null)
} catch (error: SecurityException) {
// The legacy grant, and only that: API 29+ needs no permission, so
// reaching this there means something else refused the write.
result.error("permission_denied", error.message, null)
} catch (error: Exception) {
result.error("save_failed", error.message, null)
}
}

/** API 29+: one MediaStore row, published after the bytes are written. */
private fun saveToMediaStore(bytes: ByteArray, fileName: String, mimeType: String) {
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/$ALBUM",
)
put(MediaStore.Images.Media.IS_PENDING, 1)
}

val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw IOException("MediaStore refused the image entry")

try {
val stream = resolver.openOutputStream(uri)
?: throw IOException("MediaStore could not open the image for writing")
stream.use { it.write(bytes) }
} catch (error: Exception) {
// A failed write must not leave a pending row behind: it would be
// invisible to the gallery and never cleaned up by it.
resolver.delete(uri, null, null)
throw error
}

resolver.update(
uri,
ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) },
null,
null,
)
}

/** API 26–28: the public Pictures directory, then tell the gallery about it. */
private fun saveToPicturesDirectory(bytes: ByteArray, fileName: String, mimeType: String) {
if (context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) !=
PackageManager.PERMISSION_GRANTED
) {
throw SecurityException("WRITE_EXTERNAL_STORAGE is not granted")
}
if (Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED) {
throw IOException("External storage is not mounted")
}

val album = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
ALBUM,
)
if (!album.exists() && !album.mkdirs()) {
throw IOException("Could not create the $ALBUM album")
}

val file = uniqueFile(album, fileName)
file.writeBytes(bytes)
// Writes outside MediaStore are invisible until something tells the
// gallery to index them; without this the picture exists but no Photos
// app lists it.
MediaScannerConnection.scanFile(context, arrayOf(file.absolutePath), arrayOf(mimeType), null)
}

/** [fileName] in [directory], or the first `name (n).ext` that is free. */
private fun uniqueFile(directory: File, fileName: String): File {
val candidate = File(directory, fileName)
if (!candidate.exists()) return candidate

val dot = fileName.lastIndexOf('.')
val base = if (dot > 0) fileName.substring(0, dot) else fileName
val extension = if (dot > 0) fileName.substring(dot) else ""
var index = 1
while (true) {
val next = File(directory, "$base ($index)$extension")
if (!next.exists()) return next
index++
}
}
}
5 changes: 5 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, NativeLogChannel.NAME)
.setMethodCallHandler(NativeLogChannel(applicationContext))

// Saving an image into the gallery. Same channel name and protocol as
// iOS's ImageSaverPlugin.
MethodChannel(messenger, ImageSaverChannel.NAME)
.setMethodCallHandler(ImageSaverChannel(applicationContext))

// The snapshot bridge for the home-screen Widget. Same channel name and
// protocol as iOS's WidgetSnapshotPlugin, so the Dart writer is one
// implementation and the platform is the only difference.
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
LR0S00000000000000000002 /* LocationReportSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000001 /* LocationReportSender.swift */; };
LR0S00000000000000000004 /* LocationReportSenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000003 /* LocationReportSenderTests.swift */; };
NL0G00000000000000000002 /* NativeLogPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = NL0G00000000000000000001 /* NativeLogPlugin.swift */; };
IMSV00000000000000000002 /* ImageSaverPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = IMSV00000000000000000001 /* ImageSaverPlugin.swift */; };
NL0G00000000000000000004 /* NativeLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NL0G00000000000000000003 /* NativeLogTests.swift */; };
NS1K00000000000000000002 /* NotificationSettingsLinkPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */; };
NSE000000000000000000002 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = NSE000000000000000000001 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
Expand Down Expand Up @@ -206,6 +207,7 @@
LR0S00000000000000000001 /* LocationReportSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSender.swift; sourceTree = "<group>"; };
LR0S00000000000000000003 /* LocationReportSenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSenderTests.swift; sourceTree = "<group>"; };
NL0G00000000000000000001 /* NativeLogPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLogPlugin.swift; sourceTree = "<group>"; };
IMSV00000000000000000001 /* ImageSaverPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaverPlugin.swift; sourceTree = "<group>"; };
NL0G00000000000000000003 /* NativeLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLogTests.swift; sourceTree = "<group>"; };
NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsLinkPlugin.swift; sourceTree = "<group>"; };
NSE000000000000000000001 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -501,6 +503,7 @@
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */,
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */,
NL0G00000000000000000001 /* NativeLogPlugin.swift */,
IMSV00000000000000000001 /* ImageSaverPlugin.swift */,
NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
Expand Down Expand Up @@ -869,6 +872,7 @@
AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */,
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */,
NL0G00000000000000000002 /* NativeLogPlugin.swift in Sources */,
IMSV00000000000000000002 /* ImageSaverPlugin.swift in Sources */,
NS1K00000000000000000002 /* NotificationSettingsLinkPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import UserNotifications
MapCachePlugin.register(with: registry.registrar(forPlugin: "MapCachePlugin")!)
StorageScanPlugin.register(with: registry.registrar(forPlugin: "StorageScanPlugin")!)
ScreenWakePlugin.register(with: registry.registrar(forPlugin: "ScreenWakePlugin")!)
ImageSaverPlugin.register(with: registry.registrar(forPlugin: "ImageSaverPlugin")!)
ApnsTokenPlugin.register(with: registry.registrar(forPlugin: "ApnsTokenPlugin")!)
BackgroundLocationPlugin.register(
with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!)
Expand Down
100 changes: 100 additions & 0 deletions ios/Runner/ImageSaverPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Flutter
import Photos
import UIKit

/// Saves encoded image bytes into the photo library — the counterpart of
/// Android's `ImageSaverChannel`, with the same channel name and protocol.
///
/// The bytes are the ones the app already fetched (the CWA report image), so
/// nothing here downloads anything. Add-only access is what a "save" needs:
/// the app never reads the library, so iOS shows the narrower grant and never
/// asks for full photo access.
///
/// The asset is created from data rather than from a temporary file: the
/// filename still reaches the library through `originalFilename`, and no
/// cleanup path has to be remembered if the write fails halfway.
public class ImageSaverPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.exptech.dpip/image_saver",
binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(ImageSaverPlugin(), channel: channel)
}

public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "save":
save(call.arguments as? [String: Any] ?? [:], result: result)
case "status":
result(statusName(PHPhotoLibrary.authorizationStatus(for: .addOnly)))
case "request":
// Asked once by the Dart side before a save; iOS never prompts twice, so
// a `denied` answer here is final and the UI offers Settings instead.
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
DispatchQueue.main.async { result(self.statusName(status)) }
}
default:
result(FlutterMethodNotImplemented)
}
}

private func statusName(_ status: PHAuthorizationStatus) -> String {
switch status {
case .authorized, .limited:
return "granted"
case .denied, .restricted:
return "denied"
default:
return "notDetermined"
}
}

private func save(_ args: [String: Any], result: @escaping FlutterResult) {
guard
let payload = args["bytes"] as? FlutterStandardTypedData,
let fileName = args["fileName"] as? String,
!fileName.isEmpty
else {
result(
FlutterError(
code: "invalid_args", message: "bytes and fileName are required", details: nil))
return
}
let data = payload.data

PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else {
// A refusal is not a failure to report as "try again": the only way
// forward is Settings, and the Dart side says exactly that.
DispatchQueue.main.async {
result(
FlutterError(
code: "permission_denied", message: "Photo library access was refused",
details: nil))
}
return
}

PHPhotoLibrary.shared().performChanges({
let request = PHAssetCreationRequest.forAsset()
let options = PHAssetResourceCreationOptions()
options.originalFilename = fileName
request.addResource(with: .photo, data: data, options: options)
}) { success, error in
// The completion handler runs off the main thread; Flutter's reply
// belongs to the platform thread.
DispatchQueue.main.async {
if success {
result(nil)
} else {
result(
FlutterError(
code: "save_failed",
message: error?.localizedDescription ?? "The image could not be saved",
details: nil))
}
}
}
}
}
}
2 changes: 2 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
<string>DPIP 需要在背景持續存取你的位置,即使關閉 App 也能為你所在地推送地震、天氣等災害警報。</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>DPIP 需要你的位置,以提供你所在地的地震、天氣等災害警報。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>DPIP 需要相簿權限,才能將地震報告圖儲存到你的照片。</string>
<key>NSUserActivityTypes</key>
<array>
<string>WeatherWidgetConfigurationIntent</string>
Expand Down
Loading
Loading