feat(train): routes out and in as GPX (ADR 0030, D45)
A route's page shares it as GPX 1.1 (Android's share sheet, else saved where the person chooses): its way a track with elevations, its waypoints and turns as named points. A GPX file comes in from the Routes tab or, on Android, opened with Train or shared to it; it is planned along the paths through its points or kept as drawn, then named and saved as a new route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a2997e87af
commit
5ff9b2e053
4 changed files with 1190 additions and 5 deletions
|
|
@ -70,6 +70,27 @@
|
|||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- A GPX file opened with Train or shared to it (ADR 0030, D45):
|
||||
by its type where the sender names one, by its name where
|
||||
a file manager says only that it is a file. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="application/gpx+xml"/>
|
||||
<data android:mimeType="application/gpx"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="content"/>
|
||||
<data android:scheme="file"/>
|
||||
<data android:host="*"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
<data android:pathPattern=".*\\.gpx"/>
|
||||
<data android:pathPattern=".*\\..*\\.gpx"/>
|
||||
</intent-filter>
|
||||
<!-- Health Connect's link to Train's rationale, before Android 14. -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
|
||||
|
|
@ -102,6 +123,16 @@
|
|||
android:name=".RecorderService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location|health|connectedDevice" />
|
||||
<!-- A route's GPX handed to the share sheet (ADR 0030, D45). -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/shared_files"/>
|
||||
</provider>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -27,14 +27,20 @@ class MainActivity : FlutterActivity() {
|
|||
/** The Polar Loop's side (ADR 0030, D34). */
|
||||
private val loop by lazy { LoopBridge(this) }
|
||||
|
||||
/** Routes' GPX files, in and out (ADR 0030, D45). */
|
||||
private val files by lazy { RouteFiles(this) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
health.onIntent(intent)
|
||||
// Not again where Android makes the activity anew with the same intent.
|
||||
if (savedInstanceState == null) files.onIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
health.onIntent(intent)
|
||||
files.onIntent(intent)
|
||||
}
|
||||
|
||||
@Deprecated("FlutterActivity has no result registry")
|
||||
|
|
@ -94,6 +100,8 @@ class MainActivity : FlutterActivity() {
|
|||
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
|
||||
EventChannel(messenger, "app.halkyon.train/loop/state").setStreamHandler(loop.state)
|
||||
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
|
||||
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
|
||||
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Routes' files (ADR 0030, D45): a GPX file opened with Train or shared to it,
|
||||
* handed to the app as it listens (now, or once it does), and a route shared
|
||||
* out through Android's share sheet.
|
||||
*/
|
||||
class RouteFiles(private val activity: Activity) : MethodChannel.MethodCallHandler {
|
||||
companion object {
|
||||
/** A route's file is far smaller; anything larger isn't read. */
|
||||
const val MAX_BYTES = 20L * 1024 * 1024
|
||||
}
|
||||
|
||||
private var sink: EventChannel.EventSink? = null
|
||||
private val waiting = mutableListOf<Map<String, Any>>()
|
||||
|
||||
val opened =
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
sink = events
|
||||
waiting.forEach(events::success)
|
||||
waiting.clear()
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
sink = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes in the file [intent] opens or shares, if any. */
|
||||
fun onIntent(intent: Intent?) {
|
||||
val uri =
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_VIEW -> intent.data
|
||||
Intent.ACTION_SEND ->
|
||||
@Suppress("DEPRECATION")
|
||||
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))
|
||||
else -> null
|
||||
} ?: return
|
||||
val file = read(uri) ?: return
|
||||
sink?.success(file) ?: waiting.add(file)
|
||||
}
|
||||
|
||||
private fun read(uri: Uri): Map<String, Any>? =
|
||||
try {
|
||||
val resolver = activity.contentResolver
|
||||
var name = uri.lastPathSegment ?: "route.gpx"
|
||||
var size = -1L
|
||||
resolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let {
|
||||
cursor.getString(it)?.let { shown -> name = shown }
|
||||
}
|
||||
cursor.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let {
|
||||
size = cursor.getLong(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (size > MAX_BYTES) {
|
||||
null
|
||||
} else {
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
val bytes = input.readUpTo(MAX_BYTES + 1)
|
||||
if (bytes.size > MAX_BYTES) null else mapOf("name" to name, "bytes" to bytes)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.method != "share") {
|
||||
result.notImplemented()
|
||||
return
|
||||
}
|
||||
val name = File(call.argument<String>("name")!!).name
|
||||
val bytes = call.argument<ByteArray>("bytes")!!
|
||||
// A folder of its own, emptied each time: only the file shared last stays.
|
||||
val folder = File(activity.cacheDir, "shared").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
val file = File(folder, name).apply { writeBytes(bytes) }
|
||||
val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.files", file)
|
||||
val send =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = call.argument<String>("mimeType") ?: "application/gpx+xml"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
activity.startActivity(Intent.createChooser(send, null))
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Up to [limit] bytes of the stream. */
|
||||
private fun java.io.InputStream.readUpTo(limit: Long): ByteArray {
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var total = 0L
|
||||
while (total < limit) {
|
||||
val read = read(buffer, 0, minOf(buffer.size.toLong(), limit - total).toInt())
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
total += read
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- What Train shares through its FileProvider (ADR 0030, D45): the route
|
||||
file it writes for the share sheet, and nothing else of its cache. -->
|
||||
<paths>
|
||||
<cache-path name="shared" path="shared/"/>
|
||||
</paths>
|
||||
Loading…
Reference in a new issue