Skip to content
Merged
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
54 changes: 52 additions & 2 deletions app/src/main/cpp/jni_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<jint>(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<int>(dims[0]);
*out_height = static_cast<int>(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(...).

Expand Down
11 changes: 6 additions & 5 deletions app/src/main/java/at/dasher/android/AnalyticsService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ object AnalyticsService {
val defaults: Map<String, Any> = 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
Expand Down Expand Up @@ -201,7 +201,7 @@ object AnalyticsService {
private fun crashProperties(thread: String, engineLogTail: String): Map<String, Any> {
val props = mutableMapOf<String, Any>(
"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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/at/dasher/android/DasherEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/at/dasher/android/DasherImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/at/dasher/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/at/dasher/android/NativeBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
11 changes: 11 additions & 0 deletions app/src/main/java/at/dasher/android/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}

Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/at/dasher/android/ui/DasherCanvasView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Float, Float>? {
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)
Expand Down
2 changes: 1 addition & 1 deletion third_party/DasherCore
Loading