diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp index 0a31cf3..9d362a4 100644 --- a/app/src/main/cpp/jni_bridge.cpp +++ b/app/src/main/cpp/jni_bridge.cpp @@ -98,6 +98,7 @@ static jmethodID g_onMessage = nullptr; static jmethodID g_onOutput = nullptr; static jmethodID g_onParameterChanged = nullptr; static jmethodID g_onLog = nullptr; +static jmethodID g_onTextSize = nullptr; // Returns an env for the current thread, attaching it if necessary. // [attached] is set true when the caller must DetachCurrentThread afterwards. @@ -187,9 +188,10 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { g_onOutput = env->GetStaticMethodID(g_nbClass, "onOutput", "(ILjava/lang/String;)V"); g_onParameterChanged = env->GetStaticMethodID(g_nbClass, "onParameterChanged", "(I)V"); g_onLog = env->GetStaticMethodID(g_nbClass, "onLog", "(ILjava/lang/String;)V"); - LOGI("JNI_OnLoad: callbacks resolved (clipboard=%p speak=%p msg=%p out=%p param=%p log=%p)", + g_onTextSize = env->GetStaticMethodID(g_nbClass, "onTextSize", "(Ljava/lang/String;I[F)Z"); + LOGI("JNI_OnLoad: callbacks resolved (clipboard=%p speak=%p msg=%p out=%p param=%p log=%p textSize=%p)", (void*)g_onClipboard, (void*)g_onSpeak, (void*)g_onMessage, (void*)g_onOutput, - (void*)g_onParameterChanged, (void*)g_onLog); + (void*)g_onParameterChanged, (void*)g_onLog, (void*)g_onTextSize); return JNI_VERSION_1_6; } @@ -757,6 +759,54 @@ Java_at_dasher_android_NativeBridge_nativeSetParameterCallback(JNIEnv*, jclass, if (s && s->ctx) dasher_set_parameter_callback(s->ctx, parameterCallback, nullptr); } +// ── Text measurement (DasherCore v0.2.4 / upstream #56) ──────────────────── +// The engine's label layout needs real glyph advances for the font the canvas +// draws opcode-5 text with. Only Kotlin knows that font (a Paint with a +// Typeface), so the C wrapper marshals the request into +// NativeBridge.onTextSize(text, fontSize, out[2]); Kotlin fills the array and +// returns true. Fires on the dasher_frame() thread (the main thread here). + +static int textSizeCallback(const char* text, int font_size, int* out_width, int* out_height, void*) { + if (!g_nbClass || !g_onTextSize || !text) return 1; + bool attached = false; + JNIEnv* env = attachEnv(attached); + if (!env) return 1; + + jstring jtext = env->NewStringUTF(text); + jfloatArray out = env->NewFloatArray(2); + int ok = 1; + if (jtext && out) { + const jboolean measured = + env->CallStaticBooleanMethod(g_nbClass, g_onTextSize, jtext, static_cast(font_size), out); + if (measured) { + jfloat dims[2] = {0, 0}; + env->GetFloatArrayRegion(out, 0, 2, dims); + if (dims[0] >= 0 && dims[1] >= 0) { + *out_width = static_cast(dims[0]); + *out_height = static_cast(dims[1]); + ok = 0; + } + } + } + if (env->ExceptionCheck()) env->ExceptionClear(); + if (out) env->DeleteLocalRef(out); + if (jtext) env->DeleteLocalRef(jtext); + if (attached) g_jvm->DetachCurrentThread(); + return ok; +} + +JNIEXPORT void JNICALL +Java_at_dasher_android_NativeBridge_nativeSetTextSizeCallback(JNIEnv*, jclass, jlong handle) { + auto* s = fromHandle(handle); + if (s && s->ctx) dasher_set_text_size_callback(s->ctx, textSizeCallback, nullptr); +} + +JNIEXPORT void JNICALL +Java_at_dasher_android_NativeBridge_nativeTextMetricsChanged(JNIEnv*, jclass, jlong handle) { + auto* s = fromHandle(handle); + if (s && s->ctx) dasher_text_metrics_changed(s->ctx); +} + // ── Engine callbacks (see DasherCore/docs/CUSTOM_ACTIONS.md) ──────────────── // Each installs a C wrapper that marshals back into NativeBridge.onX(...). diff --git a/app/src/main/java/at/dasher/android/AnalyticsService.kt b/app/src/main/java/at/dasher/android/AnalyticsService.kt index 803dc5f..d0689a0 100644 --- a/app/src/main/java/at/dasher/android/AnalyticsService.kt +++ b/app/src/main/java/at/dasher/android/AnalyticsService.kt @@ -82,7 +82,7 @@ object AnalyticsService { val defaults: Map = mapOf( "platform" to "android", "app_variant" to "dasher-android", - "app_version" to appVersion(), + "app_version" to appVersion, "os_version" to "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})", // RFC 0001 promises no location data. PostHog Cloud derives $geoip_* (city, // postal code, lat/lon) from the client IP even with project IP @@ -201,7 +201,7 @@ object AnalyticsService { private fun crashProperties(thread: String, engineLogTail: String): Map { val props = mutableMapOf( "thread" to thread, - "app_version" to appVersion(), + "app_version" to appVersion, "os_version" to "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})", "locale" to (appContext?.let { LocaleHelper.currentLanguageTag(it) } ?: "system"), // crash reports bypass capture()'s defaults — carry the flag here too (RFC 0001) @@ -219,7 +219,7 @@ object AnalyticsService { val header = buildString { append("exception_type=").append(t::class.java.name).append('\n') append("thread=").append(threadName).append('\n') - append("app_version=").append(appVersion()).append('\n') + append("app_version=").append(appVersion).append('\n') append("os_version=Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})\n") } val body = if (engineTail.isNotBlank()) "$stack\n--- engine log ---\n$engineTail" else stack @@ -263,8 +263,9 @@ object AnalyticsService { prefs(context).edit().putString(KEY_ANON_ID, it).apply() } - private fun appVersion(): String = - try { BuildConfig.VERSION_NAME } catch (_: Throwable) { "unknown" } + /** App version for analytics and display (RFC 0016) — one source of truth. */ + val appVersion: String + get() = try { BuildConfig.VERSION_NAME } catch (_: Throwable) { "unknown" } private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) diff --git a/app/src/main/java/at/dasher/android/DasherEngine.kt b/app/src/main/java/at/dasher/android/DasherEngine.kt index 2eedd9c..8ac4eb8 100644 --- a/app/src/main/java/at/dasher/android/DasherEngine.kt +++ b/app/src/main/java/at/dasher/android/DasherEngine.kt @@ -557,6 +557,26 @@ class DasherEngine( NativeBridge.nativeSetLogCallback(nativeHandle) } + /** + * Installs the text-measurement callback (DasherCore v0.2.4 / upstream + * #56) so the engine lays labels out with real glyph advances from the + * canvas's Paint instead of its codepoints×size/2 estimate, whose error + * compounds down the label chain and jumbles deep-zoom text. + * [measure] must use the same paint + size transform the canvas draws + * opcode-5 text with (see DasherCanvasView.measureGlyphText). + */ + fun installTextSizeCallback(measure: (text: String, fontSize: Int, out: FloatArray) -> Boolean) { + if (nativeHandle == 0L) return + NativeBridge.onTextSizeListener = measure + NativeBridge.nativeSetTextSizeCallback(nativeHandle) + } + + /** Invalidate cached label measurements (canvas font changed). */ + fun textMetricsChanged() { + if (nativeHandle == 0L) return + NativeBridge.nativeTextMetricsChanged(nativeHandle) + } + /** [Choreographer.FrameCallback] — one render step per vsync. */ override fun doFrame(frameTimeNanos: Long) { if (!running) return diff --git a/app/src/main/java/at/dasher/android/DasherImeService.kt b/app/src/main/java/at/dasher/android/DasherImeService.kt index 6653bcc..3ae9007 100644 --- a/app/src/main/java/at/dasher/android/DasherImeService.kt +++ b/app/src/main/java/at/dasher/android/DasherImeService.kt @@ -212,6 +212,17 @@ class DasherImeService : InputMethodService() { } eng.setLowMemoryMode(true) eng.installEngineCallbacks() + // Real label metrics (DasherCore v0.2.4 / upstream #56), measured with + // THIS keyboard's canvas paint. NativeBridge listeners are static, so + // the IME must reinstall its own on every engine creation rather than + // inherit the (possibly stale) app Activity's. + canvasView?.onGlyphFontChanged = { eng.textMetricsChanged() } + eng.installTextSizeCallback { text, fontSize, out -> + val dims = canvasView?.measureGlyphText(text, fontSize) ?: return@installTextSizeCallback false + out[0] = dims.first + out[1] = dims.second + true + } // RFC 0007: push the OS dark-mode state so a SYSTEM-mode user gets the right // derived palette (mirrors MainActivity; settings/appearance persist on disk // and are read at engine creation). diff --git a/app/src/main/java/at/dasher/android/MainActivity.kt b/app/src/main/java/at/dasher/android/MainActivity.kt index 7b1cdbe..08a257d 100644 --- a/app/src/main/java/at/dasher/android/MainActivity.kt +++ b/app/src/main/java/at/dasher/android/MainActivity.kt @@ -224,6 +224,7 @@ class MainActivity : ComponentActivity() { eng.installEngineCallbacks() eng.installParameterCallback() // two-way sync (settings <-> toolbar/canvas) eng.installLogCallback() // engine diagnostics → logcat + installTextMeasurement(eng) // real label metrics (DasherCore v0.2.4 / upstream #56) // RFC 0009 A2: report the engine's sticky error flag (once per session). eng.onEngineError = { AnalyticsService.captureEngineError("frame loop") } // RFC 0007: push the OS dark-mode state into the engine so a SYSTEM @@ -440,6 +441,17 @@ class MainActivity : ComponentActivity() { canvasView?.glyphFontName = eng.stringValue(dasherFontKey) } + /** Wires real label measurement into the engine (DasherCore v0.2.4 / #56). */ + private fun installTextMeasurement(eng: DasherEngine) { + canvasView?.onGlyphFontChanged = { eng.textMetricsChanged() } + eng.installTextSizeCallback { text, fontSize, out -> + val dims = canvasView?.measureGlyphText(text, fontSize) ?: return@installTextSizeCallback false + out[0] = dims.first + out[1] = dims.second + true + } + } + private fun saveOutput() { if (fullText.isEmpty()) { Toast.makeText(this, "Nothing to save", Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/at/dasher/android/NativeBridge.kt b/app/src/main/java/at/dasher/android/NativeBridge.kt index b0f7a98..361df21 100644 --- a/app/src/main/java/at/dasher/android/NativeBridge.kt +++ b/app/src/main/java/at/dasher/android/NativeBridge.kt @@ -215,6 +215,12 @@ object NativeBridge { // ── Parameter-change callback (two-way sync: settings <-> toolbar) ── @JvmStatic external fun nativeSetParameterCallback(handle: Long) + /** Install the engine text-measurement callback (see [onTextSizeListener]). */ + @JvmStatic external fun nativeSetTextSizeCallback(handle: Long) + + /** Invalidate cached text measurements after the canvas font changed. */ + @JvmStatic external fun nativeTextMetricsChanged(handle: Long) + // ── Game mode ── @JvmStatic external fun nativeEnterGameMode(handle: Long): Int @JvmStatic external fun nativeLeaveGameMode(handle: Long) @@ -256,6 +262,16 @@ object NativeBridge { @JvmStatic var onOutputListener: ((type: Int, text: String) -> Unit)? = null @JvmStatic var onParameterChangedListener: ((key: Int) -> Unit)? = null + /** + * Real text measurement for the engine's label layout (DasherCore v0.2.4 / + * upstream #56). Implementations must measure with the SAME Paint the + * canvas draws opcode-5 text with. Receives (text, engine font size); + * fills out[0]=width, out[1]=height in px and returns true, or returns + * false to let the engine fall back to its estimate. Fires on the thread + * that calls nativeFrame (main). + */ + @JvmStatic var onTextSizeListener: ((text: String, fontSize: Int, out: FloatArray) -> Boolean)? = null + @JvmStatic fun onClipboard(text: String) { onClipboardListener?.invoke(text) } @JvmStatic fun onSpeak(text: String, interrupt: Int) { onSpeakListener?.invoke(text, interrupt != 0) @@ -264,4 +280,9 @@ object NativeBridge { @JvmStatic fun onLog(level: Int, text: String) { onLogListener?.invoke(level, text) } @JvmStatic fun onOutput(type: Int, text: String) { onOutputListener?.invoke(type, text) } @JvmStatic fun onParameterChanged(key: Int) { onParameterChangedListener?.invoke(key) } + + /** Called from JNI (textSizeCallback); signature must match the cached method ID. */ + @JvmStatic fun onTextSize(text: String, fontSize: Int, out: FloatArray): Boolean { + return onTextSizeListener?.invoke(text, fontSize, out) ?: false + } } diff --git a/app/src/main/java/at/dasher/android/SettingsScreen.kt b/app/src/main/java/at/dasher/android/SettingsScreen.kt index 9ee8c60..e4b4d9d 100644 --- a/app/src/main/java/at/dasher/android/SettingsScreen.kt +++ b/app/src/main/java/at/dasher/android/SettingsScreen.kt @@ -202,6 +202,17 @@ private fun PrivacyContent(context: android.content.Context) { OutlinedButton(onClick = { AnalyticsService.resetId(context) }) { Text("Reset anonymous ID") } + + // RFC 0016: the app version, so support can start from "which build + // are you on?". Same constant analytics already reports, so UI and + // telemetry cannot disagree. Interim home: bottom of the Privacy tab, + // pending the About section (RFC 0006). + Text( + "Dasher ${AnalyticsService.appVersion}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp) + ) } } diff --git a/app/src/main/java/at/dasher/android/ui/DasherCanvasView.kt b/app/src/main/java/at/dasher/android/ui/DasherCanvasView.kt index b18e3e8..5abf4d3 100644 --- a/app/src/main/java/at/dasher/android/ui/DasherCanvasView.kt +++ b/app/src/main/java/at/dasher/android/ui/DasherCanvasView.kt @@ -55,9 +55,36 @@ class DasherCanvasView @JvmOverloads constructor( set(value) { field = value textPaint.typeface = typefaceFor(value) + // Label measurements are font-relative; the engine must re-measure + // with the new face (DasherCore v0.2.4). + onGlyphFontChanged?.invoke() postInvalidateOnAnimation() } + /** + * Fired by [glyphFontName]'s setter so the owner can call + * NativeBridge.nativeTextMetricsChanged — kept as a callback because the + * view doesn't own the engine handle. + */ + var onGlyphFontChanged: (() -> Unit)? = null + + /** + * Measure text with the SAME paint and size transform the canvas uses for + * opcode-5 text (DasherCore v0.2.4 / upstream #56): the engine's font-size + * value is treated as a radius and scaled exactly like onDraw does + * (×2.5, floored at 8). Measuring with a plain fontSize would desynchronise + * label layout from drawing. Returns null when not measurable; callers + * fall back to the engine's estimate. + */ + fun measureGlyphText(text: String, engineFontSize: Int): Pair? { + if (text.isEmpty() || engineFontSize <= 0) return null + val size = (engineFontSize * 2.5f).coerceAtLeast(8f) + textPaint.textSize = size + val width = textPaint.measureText(text) + val fm = textPaint.fontMetrics + return Pair(width, fm.bottom - fm.top) + } + private fun typefaceFor(name: String): Typeface = if (name.isEmpty()) Typeface.DEFAULT_BOLD else Typeface.create(name, Typeface.BOLD) diff --git a/third_party/DasherCore b/third_party/DasherCore index d01c221..8825d6c 160000 --- a/third_party/DasherCore +++ b/third_party/DasherCore @@ -1 +1 @@ -Subproject commit d01c2210689f73299b241d3edb5227e1dd375521 +Subproject commit 8825d6c9b17ea52b042a08f277d89bbe8b24cf74