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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,6 @@ android/app/src/main/jniLibs/
*.jks
/wasm-visor

# `make install-shellcheck` drops the binary here for `make lint-shell` to find.
/shellcheck

37 changes: 35 additions & 2 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,44 @@ adb shell
./libskywire-mobile.so visor -c ./skywire-config.json
```

There are no unit tests in the app module yet; UI/integration tests will be
added alongside the feature screens. Go-side tests run from the repo root as
Host-side unit tests are the ones that state facts about the sources rather
than about a running app — the manifest not handing the lock screen to every
screen (`ManifestKeyguardTest`), and the string catalogues staying complete and
formattable (`TranslationCatalogTest`, `AppLanguageTest`). Run them with
`./gradlew :app:testDebugUnitTest`. Go-side tests run from the repo root as
usual (`make test`); the `mobile` build variant is compile-checked in CI by
the `android` job with a size budget.

## Languages

The interface ships in English and Simplified Chinese, chosen in Settings ▸
Language and remembered per app. On Android 13+ the platform owns that choice
(it is the same setting as Settings ▸ Apps ▸ Skywire ▸ Language); below 13 it
lives in the app's own prefs and every Activity and Service picks it up by
wrapping its base context. Both paths are behind `core/AppLocale.kt`.

Only the app's own interface is translated. Logs, the visor's own output and
anything quoted from a process or an HTTP response stay in English — they are
read next to a desktop's and matched against the Go source, and a translated
log line is one nobody can search for.

Adding a language is three edits and nothing else:

1. `app/src/main/res/values-<tag>/strings.xml` — every translatable string
from `values/strings.xml`. Chinese has one plural category (`other`); check
what yours has before copying a `<plurals>`.
2. A constant in `core/AppLanguage.kt` with the language's BCP-47 tag, and its
name **in its own script** as a `translatable="false"` string
(`settings_language_zh_cn` is the pattern). The picker builds itself from
the enum — the Settings screen needs no edit.
3. The tag in `app/src/main/res/xml/locales_config.xml`, which is what
Android 13+ reads to list the app in its own language settings.

`TranslationCatalogTest` then holds all three together: it fails the build if a
string is missing from a translation, if a translation asks for a format
argument the call site does not pass, or if the enum, the values folder and
the locale config disagree about what is shipped.

## Project layout

```
Expand Down
24 changes: 24 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ android {
compose = true
}

// The in-app language picker can only offer what is installed. Play's App
// Bundle language splits deliver the device's language and nothing else,
// so a phone set to English would install without the Chinese resources
// and picking 简体中文 would silently give English back. The release
// workflow builds an APK today, which is exactly why this is set now: it
// is the line nobody would think of on the day a bundle target is added.
bundle {
language {
enableSplit = false
}
}

packaging {
jniLibs {
// The core service EXECS libskywire-mobile.so from applicationInfo.nativeLibraryDir
Expand All @@ -83,10 +95,22 @@ android {
// stays UP-TO-DATE through the exact edit it exists to catch, and reports
// success for a test it never ran. Verified by re-adding the attribute and
// watching the task run and fail.
// TranslationCatalogTest reads the string catalogues and the locale config the
// same way and for the same reason, so they are inputs too. Without this a
// translation edit — the one thing that guard exists to check — leaves the task
// UP-TO-DATE and the build green. Verified the same way: break a placeholder in
// values-zh-rCN and watch the task run and fail.
tasks.withType<Test>().configureEach {
inputs.file("src/main/AndroidManifest.xml")
.withPathSensitivity(PathSensitivity.RELATIVE)
.withPropertyName("appManifest")
inputs.files(
fileTree("src/main/res") {
include("values*/strings.xml", "xml/locales_config.xml")
},
)
.withPathSensitivity(PathSensitivity.RELATIVE)
.withPropertyName("stringCatalogues")
}

dependencies {
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Skywire.Splash">
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/main/java/com/skycoin/skywire/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.skycoin.skywire

import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
Expand All @@ -13,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.FragmentActivity
import com.skycoin.skywire.core.AppLocale
import com.skycoin.skywire.core.AppLock
import com.skycoin.skywire.core.AppPreferences
import com.skycoin.skywire.core.AppVisibility
Expand All @@ -39,6 +41,15 @@ import com.skycoin.skywire.ui.theme.SkywireTheme
* one. Handling it is [DeepLinks]' job; taking it is this one's.
*/
class MainActivity : FragmentActivity() {

/**
* The chosen interface language, applied before a single resource is read.
* Below API 33 this is the only thing that applies it — see [AppLocale].
*/
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(AppLocale.wrap(newBase))
}

override fun onCreate(savedInstanceState: Bundle?) {
val splash = installSplashScreen()
super.onCreate(savedInstanceState)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.skycoin.skywire.api

import android.content.Context
import com.skycoin.skywire.R
import com.skycoin.skywire.core.SecretStore
import com.skycoin.skywire.core.SkydexProfile
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -45,7 +46,9 @@ data class MarketStatus(
*/
class SkydexApi private constructor(context: Context) {

private val secrets = SecretStore(context.applicationContext)
private val app = context.applicationContext

private val secrets = SecretStore(app)

// Loopback, and a server that either answers at once or isn't up yet.
private val client = OkHttpClient.Builder()
Expand Down Expand Up @@ -138,7 +141,7 @@ class SkydexApi private constructor(context: Context) {
runCatching { json.decodeFromString(ApiError.serializer(), body).error }
.getOrNull()
?.takeIf { it.isNotEmpty() }
?: "market connect failed ($code)"
?: app.getString(R.string.dex_error_connect, code)

@Serializable
private data class ConnectRequest(@SerialName("market_pk") val marketPk: String)
Expand Down
54 changes: 54 additions & 0 deletions android/app/src/main/java/com/skycoin/skywire/core/AppLanguage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.skycoin.skywire.core

import java.util.Locale

/**
* The language the interface is drawn in. [SYSTEM] is whatever the phone is
* set to; every other entry names a translation this app actually ships, as
* `res/values-<tag>/strings.xml`.
*
* Adding a language is three edits and nothing else: drop in the values folder,
* add a constant here with its BCP-47 tag, and list the tag in
* `res/xml/locales_config.xml` — that file is what Android 13+ reads to offer
* the app in Settings ▸ Apps ▸ Skywire ▸ Language. The picker in Settings
* builds itself from [entries].
*
* Only the app's own interface follows this. Logs, the visor's own output and
* anything the network reports stay in the language they were written in —
* they are read alongside a `skywire cli` on a desktop, and a translated log
* line is a log line nobody can search for.
*/
enum class AppLanguage(val tag: String) {
/** No tag: the platform picks from the phone's language list. */
SYSTEM(""),
ENGLISH("en"),
CHINESE_SIMPLIFIED("zh-CN"),
;

companion object {
const val PREF_KEY = "app_language"

/** Anything unrecognised — an older build's value — reads as [SYSTEM]. */
fun of(stored: String?): AppLanguage =
entries.firstOrNull { it.name == stored } ?: SYSTEM

/**
* The entry a BCP-47 tag list means, as `LocaleManager` hands it back.
*
* Matched on the language subtag alone, because the platform is free to
* canonicalise: ask it for `zh-CN` and a later read can return
* `zh-Hans-CN`. One translation per language is shipped here, so the
* subtag is enough to find it, and a tag for a language that is not
* shipped is the same situation as no tag at all — [SYSTEM].
*/
fun ofTags(tags: String?): AppLanguage {
val first = tags?.split(',')?.firstOrNull()?.trim().orEmpty()
if (first.isEmpty()) return SYSTEM
val language = Locale.forLanguageTag(first).language
if (language.isEmpty()) return SYSTEM
return entries.firstOrNull {
it != SYSTEM && Locale.forLanguageTag(it.tag).language == language
} ?: SYSTEM
}
}
}
99 changes: 99 additions & 0 deletions android/app/src/main/java/com/skycoin/skywire/core/AppLocale.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.skycoin.skywire.core

import android.app.LocaleManager
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.LocaleList
import androidx.annotation.RequiresApi
import androidx.core.content.edit
import java.util.Locale

/**
* Where the chosen interface language is kept, and how it reaches the strings.
*
* Two mechanisms behind one door, because the platform grew its own halfway
* through the range this app supports:
*
* - **API 33 and up** owns per-app language. [set] hands the choice to
* `LocaleManager`; the system persists it, restarts the activities and
* lists the app in Settings ▸ Apps ▸ Skywire ▸ Language. From then on the
* platform is the source of truth, which is why [current] asks it rather
* than our own store — a change made in system settings has to show up on
* our screen too, or the two disagree about what the app is running in.
* - **API 26–32** has nothing to hand it to. The choice lives in the prefs
* below and every component picks it up by wrapping its base context with
* [wrap]. Nothing recreates the Activity on its own there, so [set] says so
* in its return value.
*
* The one honest caveat, and only below 33: a service that is *already*
* running keeps the language it was created with, because its resources were
* resolved then. In practice that is the core service's notification text
* until the visor is next stopped and started. Activities are recreated on the
* spot and so read correctly straight away.
*
* Deliberately its own tiny SharedPreferences file rather than a key in
* [AppPreferences]: this is read from `attachBaseContext`, before anything is
* on screen and on the main thread, and DataStore is asynchronous by
* construction. One synchronous string is what that moment can afford.
*/
object AppLocale {

private const val PREFS = "locale"

/** What the interface is currently drawn in. */
fun current(context: Context): AppLanguage =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
AppLanguage.ofTags(localeManager(context)?.applicationLocales?.toLanguageTags())
} else {
AppLanguage.of(prefs(context).getString(AppLanguage.PREF_KEY, null))
}

/**
* Persist [language] and apply it.
*
* Returns true when the caller still has to call `Activity.recreate()` —
* below API 33 nothing else will. On 33+ the platform restarts the
* activities itself and a second recreate would only throw the screen
* away twice.
*/
fun set(context: Context, language: AppLanguage): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
localeManager(context)?.applicationLocales = when (language) {
AppLanguage.SYSTEM -> LocaleList.getEmptyLocaleList()
else -> LocaleList.forLanguageTags(language.tag)
}
return false
}
prefs(context).edit { putString(AppLanguage.PREF_KEY, language.name) }
return true
}

/**
* The context a component should run on, for `attachBaseContext`.
*
* A no-op on API 33+, where the platform has already resolved resources
* against the per-app locale before this is reached — wrapping again would
* pin a stale choice over the system's current one.
*/
fun wrap(base: Context): Context {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return base
val language = current(base)
if (language == AppLanguage.SYSTEM) return base
val locale = Locale.forLanguageTag(language.tag)
// Not only the resources: dates, and anything else formatted without a
// Context in hand, read the process default.
Locale.setDefault(locale)
val config = Configuration(base.resources.configuration)
config.setLocale(locale)
config.setLayoutDirection(locale)
return base.createConfigurationContext(config)
}

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun localeManager(context: Context): LocaleManager? =
context.getSystemService(LocaleManager::class.java)

private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
45 changes: 37 additions & 8 deletions android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,12 @@ object ChatMedia {
private const val CHANNEL_ID = "chat-media"
private const val NOTIFICATION_ID = 3

/** What the notification's skip buttons move by. */
private const val SEEK_STEP_MS = 10_000L
/**
* What the notification's skip buttons move by. The seconds are also what
* their labels say, so a step changed here changes both.
*/
private const val SEEK_STEP_SECONDS = 10
private const val SEEK_STEP_MS = SEEK_STEP_SECONDS * 1000L

private const val ACTION_PLAY = "com.skycoin.skywire.media.PLAY"
private const val ACTION_PAUSE = "com.skycoin.skywire.media.PAUSE"
Expand Down Expand Up @@ -147,7 +151,10 @@ object ChatMedia {
val session = session(context)
session.setMetadata(
MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, state.optString("title", "Audio"))
.putString(
MediaMetadata.METADATA_KEY_TITLE,
state.optString("title", context.getString(R.string.chat_media_default_title)),
)
.putString(MediaMetadata.METADATA_KEY_ARTIST, state.optString("artist", "SkyChat"))
.putString(MediaMetadata.METADATA_KEY_ALBUM, "SkyChat")
.apply {
Expand Down Expand Up @@ -218,25 +225,47 @@ object ChatMedia {
)
return Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.skywire_logo)
.setContentTitle(state.optString("title", "Audio"))
.setContentTitle(
state.optString("title", context.getString(R.string.chat_media_default_title)),
)
.setContentText(state.optString("artist", "SkyChat"))
.setContentIntent(open)
.setDeleteIntent(button(context, ACTION_STOP))
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(playing)
.addAction(
action(context, R.drawable.ic_media_back, "Back 10s", ACTION_BACK),
action(
context,
R.drawable.ic_media_back,
context.getString(R.string.chat_media_back, SEEK_STEP_SECONDS),
ACTION_BACK,
),
)
.addAction(
if (playing) {
action(context, R.drawable.ic_media_pause, "Pause", ACTION_PAUSE)
action(
context,
R.drawable.ic_media_pause,
context.getString(R.string.chat_media_pause),
ACTION_PAUSE,
)
} else {
action(context, R.drawable.ic_media_play, "Play", ACTION_PLAY)
action(
context,
R.drawable.ic_media_play,
context.getString(R.string.chat_media_play),
ACTION_PLAY,
)
},
)
.addAction(
action(context, R.drawable.ic_media_forward, "Forward 10s", ACTION_FORWARD),
action(
context,
R.drawable.ic_media_forward,
context.getString(R.string.chat_media_forward, SEEK_STEP_SECONDS),
ACTION_FORWARD,
),
)
.setStyle(
Notification.MediaStyle()
Expand Down
Loading
Loading