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:
Tizian 「ティツィアーン」 2026-10-29 10:44:47 +01:00
commit 5ff9b2e053
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1190 additions and 5 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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()
}

View file

@ -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>