diff --git a/.gitignore b/.gitignore index a09d45e351..c2d9d2697a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 + diff --git a/android/README.md b/android/README.md index a4caf918f1..60813f17b0 100644 --- a/android/README.md +++ b/android/README.md @@ -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-/strings.xml` — every translatable string + from `values/strings.xml`. Chinese has one plural category (`other`); check + what yours has before copying a ``. +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 ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 40ceb978d4..0aef131692 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 @@ -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().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 { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7cf7a67e07..2ce7bee2fc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt b/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt index c3c0292716..7ffb91bcd8 100644 --- a/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt +++ b/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt @@ -1,5 +1,6 @@ package com.skycoin.skywire +import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle @@ -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 @@ -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) diff --git a/android/app/src/main/java/com/skycoin/skywire/api/SkydexApi.kt b/android/app/src/main/java/com/skycoin/skywire/api/SkydexApi.kt index 3d4aeaabab..6dd31a7296 100644 --- a/android/app/src/main/java/com/skycoin/skywire/api/SkydexApi.kt +++ b/android/app/src/main/java/com/skycoin/skywire/api/SkydexApi.kt @@ -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 @@ -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() @@ -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) diff --git a/android/app/src/main/java/com/skycoin/skywire/core/AppLanguage.kt b/android/app/src/main/java/com/skycoin/skywire/core/AppLanguage.kt new file mode 100644 index 0000000000..a62dc55e60 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/AppLanguage.kt @@ -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-/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 + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/AppLocale.kt b/android/app/src/main/java/com/skycoin/skywire/core/AppLocale.kt new file mode 100644 index 0000000000..5e1f47458f --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/AppLocale.kt @@ -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) +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt b/android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt index 869f38fe37..6a45fac640 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt @@ -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" @@ -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 { @@ -218,7 +225,9 @@ 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)) @@ -226,17 +235,37 @@ object ChatMedia { .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() diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt index d78a6ab210..38418a4cd9 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt @@ -1,5 +1,7 @@ package com.skycoin.skywire.core +import android.content.Context +import com.skycoin.skywire.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -19,8 +21,17 @@ import java.util.concurrent.TimeUnit * that runs the visor. Nothing is hand-written: `config gen` produces the * file, then [applyPhoneProfile] enforces the phone constraints the * generator has no flags for. + * + * [context] is for the failures alone: every one of them is rendered to the + * user — the red caption under Connect, or a Settings snackbar — so the prose + * comes from the string resources, with the exit code, the binary path and the + * captured output passed in as arguments. */ -class ConfigManager(private val paths: SkywirePaths, private val secrets: SecretStore) { +class ConfigManager( + private val paths: SkywirePaths, + private val secrets: SecretStore, + private val context: Context, +) { /** * Opens the config when it is sealed at rest. Held here rather than passed @@ -29,7 +40,7 @@ class ConfigManager(private val paths: SkywirePaths, private val secrets: Secret * fresh identity over a sealed one, which is the single worst thing this * class could do. */ - private val vault = ConfigVault(paths) + private val vault = ConfigVault(paths, context) data class CommandResult(val exitCode: Int, val output: String, val timedOut: Boolean) { val ok get() = exitCode == 0 && !timedOut @@ -56,16 +67,23 @@ class ConfigManager(private val paths: SkywirePaths, private val secrets: Secret vault.unseal().getOrElse { return@withContext Result.failure(it) } if (!paths.visorBinary.canExecute()) { return@withContext Result.failure( - IllegalStateException("core binary missing or not executable: ${paths.visorBinary}"), + IllegalStateException( + context.getString(R.string.core_binary_missing, paths.visorBinary.toString()), + ), ) } if (!paths.configFile.exists()) { val gen = runGen() if (!gen.ok) { + // Two sentences rather than one with an optional clause: a + // timeout is a different answer to "why", and it has to read + // as one in every language. + val sentence = + if (gen.timedOut) R.string.core_config_gen_timed_out + else R.string.core_config_gen_failed return@withContext Result.failure( IllegalStateException( - "config gen failed (exit ${gen.exitCode}${if (gen.timedOut) ", timed out" else ""}):\n" + - gen.output.takeLast(4000), + context.getString(sentence, gen.exitCode, gen.output.takeLast(4000)), ), ) } @@ -92,7 +110,7 @@ class ConfigManager(private val paths: SkywirePaths, private val secrets: Secret // the visor with an opaque fatal — fail here with a usable message. Result.failure( IllegalStateException( - "config file is unreadable (${e.message}) — clearing app data regenerates it", + context.getString(R.string.core_config_unreadable, e.message.orEmpty()), e, ), ) @@ -374,7 +392,11 @@ class ConfigManager(private val paths: SkywirePaths, private val secrets: Secret val sk = secretKey.trim() // A shape check first, so an obvious paste error costs no process. if (sk.length != SK_HEX_LENGTH || !sk.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { - return Result.failure(IllegalArgumentException("not a $SK_HEX_LENGTH-character hex secret key")) + return Result.failure( + IllegalArgumentException( + context.getString(R.string.settings_sk_invalid, SK_HEX_LENGTH), + ), + ) } val result = runCommand( listOf(paths.visorBinary.absolutePath, "config", "pk", sk), @@ -387,12 +409,16 @@ class ConfigManager(private val paths: SkywirePaths, private val secrets: Secret .lastOrNull { it.length == PK_HEX_LENGTH && it.all { c -> c.isDigit() || c.lowercaseChar() in 'a'..'f' } } return when { result.ok && pk != null -> Result.success(pk) + // The CLI's own last line is shown as it printed it — it is the + // core's answer, and it is what a search or an issue report is + // matched against. Only the fallback, when it printed nothing at + // all, is ours to write. else -> Result.failure( IllegalArgumentException( result.output.lineSequence() .map { it.trim() } .lastOrNull { it.isNotEmpty() } - ?: "the core could not read that secret key", + ?: context.getString(R.string.settings_sk_unreadable), ), ) } @@ -429,7 +455,13 @@ class ConfigManager(private val paths: SkywirePaths, private val secrets: Secret paths.configFile.writeText(json.encodeToString(JsonObject.serializer(), seeded)) val gen = runGen() if (!gen.ok) { - error("config regeneration failed (exit ${gen.exitCode}):\n${gen.output.takeLast(2000)}") + error( + context.getString( + R.string.core_config_regen_failed, + gen.exitCode, + gen.output.takeLast(2000), + ), + ) } clearIdentityData() pk diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ConfigVault.kt b/android/app/src/main/java/com/skycoin/skywire/core/ConfigVault.kt index 4745dfafd3..84161a9a70 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/ConfigVault.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/ConfigVault.kt @@ -1,7 +1,9 @@ package com.skycoin.skywire.core +import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties +import com.skycoin.skywire.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -47,8 +49,12 @@ import javax.crypto.spec.GCMParameterSpec * word. Every entry point that touches the config unseals first, and * [sealedExists] is checked before any generation, so the failure mode is a * refusal rather than a silent new identity. + * + * [context] is here for one reason: the failures below are read by the user, + * on the Home caption or a Settings snackbar, so their text comes from the + * string resources like any other sentence the app writes. */ -class ConfigVault(private val paths: SkywirePaths) { +class ConfigVault(private val paths: SkywirePaths, private val context: Context) { /** [AppPreferences] key holding the user's choice. */ companion object { @@ -84,11 +90,7 @@ class ConfigVault(private val paths: SkywirePaths) { if (paths.configFile.exists()) return@withContext Result.success(Unit) runCatching { val plain = decrypt(paths.sealedConfigFile.readBytes()) - ?: error( - "the sealed config cannot be opened — this phone's keystore key is gone " + - "(a factory reset, or a screen lock that was removed and re-added). " + - "The identity in it is unrecoverable; a new one can be generated.", - ) + ?: error(context.getString(R.string.core_sealed_key_gone)) writePrivate(paths.configFile, plain) paths.sealedConfigFile.delete() Unit @@ -110,7 +112,7 @@ class ConfigVault(private val paths: SkywirePaths) { val plain = paths.configFile.readBytes() writePrivate(paths.sealedConfigFile, encrypt(plain)) check(decrypt(paths.sealedConfigFile.readBytes()) != null) { - "sealed config failed to read back; leaving the plaintext in place" + context.getString(R.string.core_seal_readback_failed) } paths.configFile.delete() Unit @@ -137,7 +139,8 @@ class ConfigVault(private val paths: SkywirePaths) { val sealed = paths.sealedConfigFile if (!sealed.exists()) error("no config on disk") return String( - decrypt(sealed.readBytes()) ?: error("the sealed config cannot be opened"), + decrypt(sealed.readBytes()) + ?: error(context.getString(R.string.core_sealed_unreadable)), Charsets.UTF_8, ) } diff --git a/android/app/src/main/java/com/skycoin/skywire/core/DiagnosticsExport.kt b/android/app/src/main/java/com/skycoin/skywire/core/DiagnosticsExport.kt index b2ea963fbe..afd0a0acc3 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/DiagnosticsExport.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/DiagnosticsExport.kt @@ -45,7 +45,7 @@ object DiagnosticsExport { ): Unit = withContext(Dispatchers.IO) { val app = context.applicationContext val paths = SkywirePaths(app) - val config = ConfigManager(paths, SecretStore(app)) + val config = ConfigManager(paths, SecretStore(app), app) val api = VisorApi.get(app) val notes = mutableListOf() diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SkyVpnService.kt b/android/app/src/main/java/com/skycoin/skywire/core/SkyVpnService.kt index 196a061053..49809d2ca1 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/SkyVpnService.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkyVpnService.kt @@ -59,6 +59,11 @@ import java.util.Collections */ class SkyVpnService : VpnService() { + /** Its session name and errors are the user's, so they follow the language. */ + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(AppLocale.wrap(newBase)) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val json = Json { ignoreUnknownKeys = true } diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt b/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt index b6c9fdd56d..5978f68c95 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt @@ -45,6 +45,11 @@ import java.util.concurrent.TimeUnit */ class SkywireCoreService : Service() { + /** Its notification is the user's, so it follows the chosen language. */ + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(AppLocale.wrap(newBase)) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private lateinit var paths: SkywirePaths private lateinit var configManager: ConfigManager @@ -61,8 +66,8 @@ class SkywireCoreService : Service() { override fun onCreate() { super.onCreate() paths = SkywirePaths(this) - configManager = ConfigManager(paths, SecretStore(this)) - vault = ConfigVault(paths) + configManager = ConfigManager(paths, SecretStore(this), this) + vault = ConfigVault(paths, this) prefs = AppPreferences(this) createChannel() } @@ -120,8 +125,12 @@ class SkywireCoreService : Service() { ).getOrElse { err -> log.line("=== config generation failed ===") log.line(err.message ?: "unknown error") - CoreServiceState.mutableState.value = - CoreState.Failed(err.message ?: "config generation failed") + // The message [ConfigManager] failed with is already the + // user's sentence, in the user's language; the fallback is + // only for a failure that carried none. + CoreServiceState.mutableState.value = CoreState.Failed( + err.message ?: getString(R.string.core_config_failed), + ) return@launch } @@ -135,8 +144,9 @@ class SkywireCoreService : Service() { spawnVisor(config) } catch (e: Exception) { log.line("spawn failed: $e") - CoreServiceState.mutableState.value = - CoreState.Failed("could not start the visor: ${e.message}") + CoreServiceState.mutableState.value = CoreState.Failed( + getString(R.string.core_spawn_failed, e.message.orEmpty()), + ) return@launch } child = process @@ -185,7 +195,7 @@ class SkywireCoreService : Service() { // Anything unexpected must land in Failed, not crash the app. log.line("=== core service error: $e ===") CoreServiceState.mutableState.value = - CoreState.Failed(e.message ?: "core service error") + CoreState.Failed(e.message ?: getString(R.string.core_service_error)) } finally { // The visor has exited, so the config on disk is final — // including anything the visor rewrote while it ran, which is diff --git a/android/app/src/main/java/com/skycoin/skywire/core/VoiceCallService.kt b/android/app/src/main/java/com/skycoin/skywire/core/VoiceCallService.kt index 3a14c7ff43..5e23d4dbfa 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/VoiceCallService.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/VoiceCallService.kt @@ -35,6 +35,11 @@ import kotlinx.coroutines.cancel */ class VoiceCallService : android.app.Service() { + /** Its call notification is the user's, so it follows the language. */ + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(AppLocale.wrap(newBase)) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private lateinit var engine: VoiceAudioEngine diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatViewModel.kt index a18507928a..dee41eb612 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatViewModel.kt @@ -3,6 +3,7 @@ package com.skycoin.skywire.ui.chat import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.R import com.skycoin.skywire.api.AppState import com.skycoin.skywire.api.SkychatApi import com.skycoin.skywire.api.VisorApi @@ -138,7 +139,11 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { delay(READY_INTERVAL_MS) } mutable.update { - it.copy(starting = false, error = startError ?: "SkyChat did not answer on $url") + it.copy( + starting = false, + error = startError + ?: getApplication().getString(R.string.chat_error_no_answer, url), + ) } } diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt index bc32139b5c..a81375ac14 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt @@ -21,6 +21,7 @@ import android.webkit.WebView import android.webkit.WebViewClient import androidx.core.content.ContextCompat import androidx.core.net.toUri +import com.skycoin.skywire.R import com.skycoin.skywire.core.SkychatProfile import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine @@ -126,7 +127,7 @@ internal object ChatWebView { // wrong; proceeding again would spin forever. if (secret == null || authAttempts++ > 0) { handler.cancel() - onError("SkyChat rejected the stored password") + onError(view.context.getString(R.string.chat_error_password)) return } handler.proceed(SkychatProfile.USER, secret) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/ServerUi.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/ServerUi.kt index 6b42bfcf76..e715b1cf79 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/components/ServerUi.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/ServerUi.kt @@ -78,32 +78,51 @@ fun formatBytes(bytes: Long): String { * `2d 3h 4m 5s` — a visor's uptime, which is measured in days rather than the * minutes a tunnel session lasts, so the days unit is worth its width and the * seconds keep ticking visibly. + * + * Composable for the units alone: `d`/`h`/`m`/`s` are English abbreviations, + * and a language that writes them out (`2天3小时`) also sets them solid — which + * is why even the separator between the parts is a resource. */ +@Composable fun formatUptime(seconds: Double): String { val total = seconds.toLong() val days = total / 86_400 val hours = (total % 86_400) / 3_600 val minutes = (total % 3_600) / 60 - return buildString { - if (days > 0) append("${days}d ") - if (hours > 0 || days > 0) append("${hours}h ") - if (minutes > 0 || hours > 0 || days > 0) append("${minutes}m ") - append("${total % 60}s") + val parts = mutableListOf() + if (days > 0) parts += stringResource(R.string.unit_days, days.toString()) + if (hours > 0 || days > 0) parts += stringResource(R.string.unit_hours, hours.toString()) + if (minutes > 0 || hours > 0 || days > 0) { + parts += stringResource(R.string.unit_minutes, minutes.toString()) } + parts += stringResource(R.string.unit_seconds, (total % 60).toString()) + return parts.joinToString(stringResource(R.string.unit_separator)) } /** `1h 04m 12s`, dropping the leading units that are still zero. */ +@Composable fun formatDuration(seconds: Long): String { val h = seconds / 3600 val m = (seconds % 3600) / 60 val s = seconds % 60 + val separator = stringResource(R.string.unit_separator) return when { - h > 0 -> String.format(Locale.US, "%dh %02dm %02ds", h, m, s) - m > 0 -> String.format(Locale.US, "%dm %02ds", m, s) - else -> String.format(Locale.US, "%ds", s) + h > 0 -> listOf( + stringResource(R.string.unit_hours, h.toString()), + stringResource(R.string.unit_minutes, pad(m)), + stringResource(R.string.unit_seconds, pad(s)), + ).joinToString(separator) + m > 0 -> listOf( + stringResource(R.string.unit_minutes, m.toString()), + stringResource(R.string.unit_seconds, pad(s)), + ).joinToString(separator) + else -> stringResource(R.string.unit_seconds, s.toString()) } } +/** Zero-padded so a running clock does not change width as it ticks. */ +private fun pad(value: Long): String = value.toString().padStart(2, '0') + /** Status dot colors shared by the app screens — the theme's accents. */ val CONNECTED_GREEN = SkyAccents.success val PENDING_AMBER = SkyAccents.warning diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/VisorWords.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/VisorWords.kt new file mode 100644 index 0000000000..05c5a257db --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/VisorWords.kt @@ -0,0 +1,72 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R + +/** + * The visor's own English words, put into the user's language on the way to + * the screen. + * + * The visor reports what an app is doing, and how its services are, as prose + * it wrote itself — `detailed_status` is the string in + * `pkg/app/appserver/app_state.go`, and `services_health` is the one in + * `pkg/visor/api.go`. Both arrive as English text rather than as a code, so + * they are matched as strings; that is what the API sends. + * + * Everything unrecognised is shown exactly as it arrived. A status this list + * has never heard of — a newer visor, a state added upstream — is still worth + * reading in English, and is certainly worth more than a blank or a guess. + * That is also what keeps this file from being a place a release can break: + * fall through, never fail. + */ + +/** + * The values `detailed_status` can carry. Kept here rather than next to one + * screen because SkySOCKS, SkyVPN and the Apps hub all read the same field — + * three copies of `"Running"` is one typo away from a status that never + * matches. + */ +object AppStatus { + const val STARTING = "Starting" + const val RUNNING = "Running" + const val CONNECTING = "Connecting" + const val RECONNECTING = "Connection failed, reconnecting" + const val SHUTTING_DOWN = "Shutting down" + const val STOPPED = "Stopped" +} + +/** What the visor says an app is doing, in the user's language. */ +@Composable +fun appStatusText(detailedStatus: String): String = when { + detailedStatus.equals(AppStatus.STARTING, ignoreCase = true) -> + stringResource(R.string.app_status_starting) + detailedStatus.equals(AppStatus.RUNNING, ignoreCase = true) -> + stringResource(R.string.app_status_running) + detailedStatus.equals(AppStatus.CONNECTING, ignoreCase = true) -> + stringResource(R.string.app_status_connecting) + detailedStatus.equals(AppStatus.RECONNECTING, ignoreCase = true) -> + stringResource(R.string.app_status_reconnecting) + detailedStatus.equals(AppStatus.SHUTTING_DOWN, ignoreCase = true) -> + stringResource(R.string.app_status_shutting_down) + detailedStatus.equals(AppStatus.STOPPED, ignoreCase = true) -> + stringResource(R.string.app_status_stopped) + else -> detailedStatus +} + +/** + * How a service reports itself: the aggregate `services_health` on a visor + * card, and the per-service rows behind it. `healthy` and `connecting` are the + * aggregate's two values; the per-service rows add `unhealthy` and `error`. + */ +@Composable +fun healthText(status: String): String = when { + status.equals(HEALTHY, ignoreCase = true) -> stringResource(R.string.health_healthy) + status.equals("unhealthy", ignoreCase = true) -> stringResource(R.string.health_unhealthy) + status.equals("connecting", ignoreCase = true) -> stringResource(R.string.health_connecting) + status.equals("error", ignoreCase = true) -> stringResource(R.string.health_error) + else -> status +} + +/** The one value that also decides a colour, so it is named twice over. */ +const val HEALTHY = "healthy" diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexViewModel.kt index 821fdf6d27..a3a1cc1677 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexViewModel.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexViewModel.kt @@ -3,6 +3,7 @@ package com.skycoin.skywire.ui.dex import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.R import com.skycoin.skywire.api.AppState import com.skycoin.skywire.api.MarketStatus import com.skycoin.skywire.api.SkydexApi @@ -144,8 +145,11 @@ class DexViewModel(app: Application) : AndroidViewModel(app) { * with the new key in place. */ fun connect() = action { + val context = getApplication() val pk = mutable.value.entry.trim() - if (!isMarketPk(pk)) throw IOException("That is not a market public key.") + if (!isMarketPk(pk)) { + throw IOException(context.getString(R.string.dex_error_invalid_key)) + } val current = visor.app(SkydexProfile.APP) runCatching { visor.updateApp(SkydexProfile.APP, status = VisorApi.APP_STOP) } @@ -157,7 +161,9 @@ class DexViewModel(app: Application) : AndroidViewModel(app) { mutable.update { it.copy(app = started) } val url = SkydexProfile.baseUrl(SkydexProfile.listenPort(started.args)) - if (!awaitUi(url)) throw IOException("SkyDEX did not answer on $url") + if (!awaitUi(url)) { + throw IOException(context.getString(R.string.dex_error_no_answer, url)) + } val market = skydex.connect(url, pk) saveRecent(SavedMarket(pk, market.marketName)) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexWebView.kt b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexWebView.kt index 6da1474f81..2e25a033ac 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexWebView.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexWebView.kt @@ -15,6 +15,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.core.net.toUri +import com.skycoin.skywire.R import com.skycoin.skywire.core.SkydexProfile import org.json.JSONObject @@ -99,7 +100,7 @@ internal object DexWebView { // wrong; proceeding again would spin forever. if (secret == null || authAttempts++ > 0) { handler.cancel() - onError("SkyDEX rejected the stored password") + onError(view.context.getString(R.string.dex_error_password)) return } handler.proceed(SkydexProfile.USER, secret) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt index b297b72005..296acdab44 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt @@ -65,8 +65,10 @@ import com.skycoin.skywire.ui.components.CONNECTED_GREEN import com.skycoin.skywire.ui.components.InfoRow import com.skycoin.skywire.ui.components.SectionCard import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.HEALTHY import com.skycoin.skywire.ui.components.formatDuration import com.skycoin.skywire.ui.components.formatUptime +import com.skycoin.skywire.ui.components.healthText import com.skycoin.skywire.ui.components.shortPk import com.skycoin.skywire.ui.logs.LogSources import java.time.Instant @@ -683,7 +685,7 @@ private fun healthLabel(visor: VisorSummary): String { return when { !visor.online -> "—" health.isEmpty() -> stringResource(R.string.fleet_health_unknown) - else -> health + else -> healthText(health) } } @@ -704,7 +706,6 @@ private fun secondsSince(rfc3339: String): Long? = runCatching { (System.currentTimeMillis() - Instant.parse(rfc3339).toEpochMilli()).coerceAtLeast(0) / 1000 }.getOrNull() -private const val HEALTHY = "healthy" /** * How old a snapshot has to be before the card says so. Comfortably past one diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt index 2122ea519d..06aaee47ba 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt @@ -60,7 +60,9 @@ import com.skycoin.skywire.core.CoreState import com.skycoin.skywire.ui.components.InfoRow import com.skycoin.skywire.ui.components.PulseRing import com.skycoin.skywire.ui.components.SectionCard +import com.skycoin.skywire.ui.components.HEALTHY import com.skycoin.skywire.ui.components.formatUptime +import com.skycoin.skywire.ui.components.healthText import com.skycoin.skywire.ui.components.shortPk import com.skycoin.skywire.ui.logs.LogSources import com.skycoin.skywire.ui.theme.SkyAccents @@ -450,8 +452,15 @@ private fun VisorInfoCard( state.serviceHealth.forEach { entry -> InfoRow( label = entry.name, - value = entry.status.ifEmpty { entry.error.ifEmpty { "?" } }, - valueColor = if (entry.status.equals("healthy", ignoreCase = true)) { + // The status is the visor's own word for it, so it + // is translated where one is known. The error is + // the service's own text and stays as it arrived. + value = when { + entry.status.isNotEmpty() -> healthText(entry.status) + entry.error.isNotEmpty() -> entry.error + else -> "?" + }, + valueColor = if (entry.status.equals(HEALTHY, ignoreCase = true)) { SkyAccents.success } else { MaterialTheme.colorScheme.onSurfaceVariant diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt index 5911a92275..a90705a5d9 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt @@ -176,7 +176,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { authResetAttempted = true val app = getApplication() SkywireCoreService.restart(app) { - ConfigManager(SkywirePaths(app), SecretStore(app)).deleteUsersDb() + ConfigManager(SkywirePaths(app), SecretStore(app), app).deleteUsersDb() } } diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt index 78f8ae507d..23b10fd104 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt @@ -70,12 +70,12 @@ import com.skycoin.skywire.ui.components.HelpTopic import com.skycoin.skywire.ui.components.SkyTopBar import com.skycoin.skywire.ui.components.countryText import com.skycoin.skywire.ui.components.formatBytes +import com.skycoin.skywire.ui.components.AppStatus import com.skycoin.skywire.ui.components.formatDuration import com.skycoin.skywire.ui.navigation.Routes import com.skycoin.skywire.ui.socks.SocksArgs import com.skycoin.skywire.ui.theme.SkyAccents import com.skycoin.skywire.ui.theme.SkyHeroGradient -import com.skycoin.skywire.ui.vpn.VpnStatus /** * The apps hub behind the raised cloud: category chips, a live SkyVPN hero @@ -313,9 +313,9 @@ private fun VpnHeroCard( val on = state.vpnOn val starting = on && ( vpn?.status == AppState.STATUS_STARTING || - vpn?.detailedStatus == VpnStatus.CONNECTING + vpn?.detailedStatus == AppStatus.CONNECTING ) - val reconnecting = on && vpn?.detailedStatus == VpnStatus.RECONNECTING + val reconnecting = on && vpn?.detailedStatus == AppStatus.RECONNECTING val statusText = when { !on -> stringResource(R.string.state_disconnected) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsViewModel.kt index b1fd2ea211..220dc7d5fa 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsViewModel.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsViewModel.kt @@ -99,7 +99,9 @@ class DiagnosticsViewModel(app: Application) : AndroidViewModel(app) { val resolver = getApplication().contentResolver resolver.openOutputStream(uri, "wt")?.use { out -> DiagnosticsExport.writeTo(getApplication(), out, apps) - } ?: error("could not open the chosen file for writing") + } ?: error( + getApplication().getString(R.string.diag_export_unwritable), + ) } mutable.update { it.copy(message = getApplication().getString(R.string.diag_export_done)) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt index d8daa0b94c..a8fb8611e3 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt @@ -8,6 +8,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -54,6 +56,7 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.skycoin.skywire.R +import com.skycoin.skywire.core.AppLanguage import com.skycoin.skywire.core.ThemeMode import com.skycoin.skywire.ui.components.Biometrics import com.skycoin.skywire.ui.components.InfoRow @@ -210,6 +213,15 @@ fun SettingsScreen( ) } item { ThemeCard(state, viewModel::setThemeMode) } + item { + LanguageCard(state) { language -> + // Below API 33 nothing applies the choice on its own — the + // Activity has to be built again on it. See AppLocale. + if (viewModel.setLanguage(language)) { + context.findFragmentActivity()?.recreate() + } + } + } item { DiagnosticsRow(onOpenDiagnostics) } item { AboutCard(state) } } @@ -680,6 +692,49 @@ private fun themeLabel(mode: ThemeMode): Int = when (mode) { ThemeMode.DARK -> R.string.settings_theme_dark } +/** + * The interface language, built from [AppLanguage.entries] so that shipping a + * translation is a resource folder and an enum constant — this screen needs no + * edit for the second one. + * + * Each language is labelled in its own script. The user most likely to come + * looking is the one who cannot read the language currently on screen. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LanguageCard(state: SettingsUiState, onPick: (AppLanguage) -> Unit) { + SectionCard { + Text(stringResource(R.string.settings_language), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.settings_language_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + // Wrapping: language names are as long as their own script makes them, + // and the list grows with every translation added. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AppLanguage.entries.forEach { language -> + FilterChip( + selected = state.language == language, + onClick = { onPick(language) }, + label = { Text(stringResource(languageLabel(language))) }, + ) + } + } + } +} + +private fun languageLabel(language: AppLanguage): Int = when (language) { + AppLanguage.SYSTEM -> R.string.settings_language_system + AppLanguage.ENGLISH -> R.string.settings_language_en + AppLanguage.CHINESE_SIMPLIFIED -> R.string.settings_language_zh_cn +} + @Composable private fun DiagnosticsRow(onOpen: () -> Unit) { SectionCard { diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsViewModel.kt index 5782004bc6..90086bbbd1 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.skycoin.skywire.R import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.AppLanguage +import com.skycoin.skywire.core.AppLocale import com.skycoin.skywire.core.AppLock import com.skycoin.skywire.core.AppPreferences import com.skycoin.skywire.core.AppVisibility @@ -45,6 +47,8 @@ data class SettingsUiState( /** False when the phone has no screen lock and no enrolled biometric. */ val biometricsAvailable: Boolean = true, val themeMode: ThemeMode = ThemeMode.SYSTEM, + /** The interface language — the phone's own until the user picks one. */ + val language: AppLanguage = AppLanguage.SYSTEM, /** The visor config is encrypted at rest while the core is down. */ val configEncrypted: Boolean = ConfigVault.DEFAULT, /** Whether Doze has been told to leave this app's network alone. */ @@ -79,8 +83,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { private val prefs = AppPreferences(app) private val paths = SkywirePaths(app) - private val config = ConfigManager(paths, SecretStore(app)) - private val vault = ConfigVault(paths) + private val config = ConfigManager(paths, SecretStore(app), app) + private val vault = ConfigVault(paths, app) private val api = VisorApi.get(app) private val mutable = MutableStateFlow(SettingsUiState()) @@ -89,6 +93,9 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { private var actionJob: Job? = null init { + // Not a flow like the rest: on API 33+ the platform holds this one, and + // changing it recreates everything that could be observing it anyway. + mutable.update { it.copy(language = AppLocale.current(app)) } viewModelScope.launch { CoreServiceState.state.collectLatest { core -> mutable.update { it.copy(coreState = core) } @@ -241,7 +248,9 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { // the tail of the old file behind. resolver.openOutputStream(uri, "wt")?.use { out -> out.write(json.toByteArray(Charsets.UTF_8)) - } ?: error("could not open the chosen file for writing") + } ?: error( + getApplication().getString(R.string.settings_export_unwritable), + ) } mutable.update { it.copy(message = getApplication().getString(R.string.settings_export_done)) @@ -308,6 +317,21 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { prefs.putString(ThemeMode.PREF_KEY, mode.name) } } + /** + * Written straight through rather than through [prefs], because the very + * next thing that happens is the Activity being thrown away and rebuilt on + * the new language: an asynchronous write would still be in flight while + * the new one reads. + * + * True back means the caller has to recreate the Activity itself — see + * [AppLocale.set]. + */ + fun setLanguage(language: AppLanguage): Boolean { + val recreate = AppLocale.set(getApplication(), language) + mutable.update { it.copy(language = language) } + return recreate + } + fun messageShown() { mutable.update { it.copy(message = null) } } diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt index 17b21f2b31..271fb99305 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt @@ -66,6 +66,8 @@ import com.skycoin.skywire.ui.components.HelpTopic import com.skycoin.skywire.ui.components.SkyTopBar import com.skycoin.skywire.ui.components.TransportPreferenceCard import com.skycoin.skywire.ui.components.TransportPreferenceSheet +import com.skycoin.skywire.ui.components.AppStatus +import com.skycoin.skywire.ui.components.appStatusText import com.skycoin.skywire.ui.components.flagEmoji import com.skycoin.skywire.ui.components.formatBytes import com.skycoin.skywire.ui.components.shortPk @@ -177,12 +179,13 @@ private fun StatusCard(state: SocksUiState, viewModel: SocksViewModel) { // The visor's own wording for what the app is doing ("Starting", // "Connection failed, reconnecting", …) — more useful than the // numeric status whenever it disagrees with the label above. + // Translated by appStatusText, which passes through what it cannot map. state.app?.detailedStatus - ?.takeIf { it.isNotEmpty() && !it.equals(RUNNING_STATUS, ignoreCase = true) } + ?.takeIf { it.isNotEmpty() && !it.equals(AppStatus.RUNNING, ignoreCase = true) } ?.let { detail -> Spacer(Modifier.height(4.dp)) Text( - detail, + appStatusText(detail), style = MaterialTheme.typography.bodySmall, color = if (state.errored) { MaterialTheme.colorScheme.error @@ -463,6 +466,5 @@ private fun statusLabel(state: SocksUiState): Pair = when { MaterialTheme.colorScheme.onSurfaceVariant } -private const val RUNNING_STATUS = "Running" private const val MIN_PORT = 1024 private const val MAX_PORT = 65535 diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnModels.kt b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnModels.kt index 3461f9538e..7f4d1c7728 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnModels.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnModels.kt @@ -41,13 +41,3 @@ object VpnArgs { private const val KILLSWITCH = "killswitch" } -/** - * The visor's own wording for what vpn-client is doing, as it reports it in - * `detailed_status`. Matched as strings because that is what the API sends — - * the constants live in pkg/app/appserver/app_state.go. - */ -object VpnStatus { - const val RUNNING = "Running" - const val CONNECTING = "Connecting" - const val RECONNECTING = "Connection failed, reconnecting" -} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt index 3f98eff512..f5560cf2fa 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt @@ -79,6 +79,8 @@ import com.skycoin.skywire.ui.components.TransportPreferenceCard import com.skycoin.skywire.ui.components.TransportPreferenceSheet import com.skycoin.skywire.ui.components.flagEmoji import com.skycoin.skywire.ui.components.formatBytes +import com.skycoin.skywire.ui.components.AppStatus +import com.skycoin.skywire.ui.components.appStatusText import com.skycoin.skywire.ui.components.formatDuration import com.skycoin.skywire.ui.components.shortPk @@ -239,13 +241,14 @@ private fun StatusCard( // The visor's own wording for what the app is doing ("Connecting", // "Connection failed, reconnecting") — more useful than the numeric - // status whenever it disagrees with the label above. + // status whenever it disagrees with the label above. Translated by + // appStatusText, which passes through anything it does not know. state.app?.detailedStatus - ?.takeIf { it.isNotEmpty() && !it.equals(VpnStatus.RUNNING, ignoreCase = true) } + ?.takeIf { it.isNotEmpty() && !it.equals(AppStatus.RUNNING, ignoreCase = true) } ?.let { detail -> Spacer(Modifier.height(4.dp)) Text( - detail, + appStatusText(detail), style = MaterialTheme.typography.bodySmall, color = if (state.errored) { MaterialTheme.colorScheme.error diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnViewModel.kt index fd97d66667..14956bee39 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnViewModel.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnViewModel.kt @@ -16,6 +16,7 @@ import com.skycoin.skywire.core.SkyVpnService import com.skycoin.skywire.core.TransportPreference import com.skycoin.skywire.core.VpnTunnel import com.skycoin.skywire.core.VpnTunnelState +import com.skycoin.skywire.ui.components.AppStatus import com.skycoin.skywire.ui.components.SavedServer import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -103,7 +104,7 @@ data class VpnUiState( /** The tunnel is up and carrying: the visor says so and traffic is in it. */ val carrying: Boolean get() = running && tunnel.established && - app?.detailedStatus != VpnStatus.RECONNECTING + app?.detailedStatus != AppStatus.RECONNECTING /** * The interface is in place with nothing carrying traffic through it — diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/CoinIcons.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/CoinIcons.kt index d564c21e78..e57761f2a5 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/CoinIcons.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/CoinIcons.kt @@ -39,7 +39,9 @@ fun coinIconFile(context: Context, name: String): File = fun importCoinIconFrom(context: Context, uri: Uri): String { val source = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) - } ?: throw IllegalArgumentException("that image cannot be read") + } ?: throw IllegalArgumentException( + context.getString(R.string.wallet_add_coin_icon_unreadable), + ) val side = minOf(source.width, source.height) val square = Bitmap.createBitmap( source, diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletHistory.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletHistory.kt index 31bbdd7ba1..129073188a 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletHistory.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletHistory.kt @@ -291,7 +291,8 @@ fun WalletTxScreen( DetailRow(stringResource(R.string.wallet_tx_fee), feeText(coin, tx.fee)) DetailRow( stringResource(R.string.wallet_tx_confirmations), - if (tx.confirmed) Amounts.groupThousands(tx.confirmations.toString()) else "0 of 1", + if (tx.confirmed) Amounts.groupThousands(tx.confirmations.toString()) + else stringResource(R.string.wallet_tx_confirmations_pending), ) HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHighest) Row( @@ -344,7 +345,8 @@ fun WalletTxScreen( Text( stringResource( R.string.wallet_tx_explorer_note, - template.format("").removeSuffix("/").toUri().host ?: "the explorer", + template.format("").removeSuffix("/").toUri().host + ?: stringResource(R.string.wallet_tx_explorer_fallback), ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt index 31cc562a2e..9b9707d0ab 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -430,10 +431,10 @@ private fun StaleBanner(state: WalletUiState) { val at = Instant.ofEpochMilli(snapshot.fetchedAtMs).atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("HH:mm")) val minutes = ((System.currentTimeMillis() - snapshot.fetchedAtMs) / 60000L).coerceAtLeast(1) - val age = when { - minutes >= 60 -> "${minutes / 60} h ${minutes % 60} min" - minutes == 1L -> "1 minute" - else -> "$minutes minutes" + val age = if (minutes >= 60) { + stringResource(R.string.wallet_stale_age_hours, minutes / 60, minutes % 60) + } else { + pluralStringResource(R.plurals.wallet_stale_age_minutes, minutes.toInt(), minutes.toInt()) } stringResource(R.string.wallet_stale_banner, at, age) } diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletViewModel.kt index de5697cb4b..54434655e1 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletViewModel.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletViewModel.kt @@ -149,10 +149,17 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { fun messageShown() = mutable.update { it.copy(message = null) } - private fun report(e: Exception): String = when (e) { - is WalletException -> e.message ?: "rejected" - is IOException -> e.message?.take(200) ?: "no route to the node" - else -> e.message?.take(200) ?: e::class.java.simpleName + private fun report(e: Exception): String { + val app: Application = getApplication() + // The exception's own message is kept verbatim — it comes from the + // node or the core and is what gets pasted into an issue. Only the + // fallbacks, written here, are translated. The last one falls back to + // the class name, which stays a class name. + return when (e) { + is WalletException -> e.message ?: app.getString(R.string.wallet_error_rejected) + is IOException -> e.message?.take(200) ?: app.getString(R.string.wallet_error_no_route) + else -> e.message?.take(200) ?: e::class.java.simpleName + } } private fun action(block: suspend () -> Unit) { @@ -209,8 +216,9 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { } fun addFiberCoin(name: String, ticker: String, nodeUrl: String, icon: String?, onDone: () -> Unit) = action { - require(name.isNotBlank()) { "give the coin a name" } - require(ticker.isNotBlank()) { "give the coin a ticker" } + val app: Application = getApplication() + require(name.isNotBlank()) { app.getString(R.string.wallet_add_coin_name_required) } + require(ticker.isNotBlank()) { app.getString(R.string.wallet_add_coin_ticker_required) } val spec = repo.addFiberCoin(name, ticker, nodeUrl, icon) repo.setSelectedCoin(spec.id) onDone() @@ -224,10 +232,11 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { icon: String?, onDone: () -> Unit, ) = action { - require(name.isNotBlank()) { "give the token a name" } - require(ticker.isNotBlank()) { "give the token a ticker" } + val app: Application = getApplication() + require(name.isNotBlank()) { app.getString(R.string.wallet_add_token_name_required) } + require(ticker.isNotBlank()) { app.getString(R.string.wallet_add_token_ticker_required) } val parsed = decimals.trim().toIntOrNull() - requireNotNull(parsed) { "decimals must be a number — 6 for USDT-like tokens, 18 for most" } + requireNotNull(parsed) { app.getString(R.string.wallet_add_token_decimals_invalid) } val spec = repo.addErc20Token(name, ticker, contract, parsed, icon) repo.setSelectedCoin(spec.id) onDone() @@ -337,9 +346,12 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { val active = st.active ?: return val coin = st.coin val send = st.send + val app: Application = getApplication() val amount = if (send.sendMax) 0uL else Amounts.parse(send.amountText, coin.exponent) ?: run { - mutable.update { it.copy(send = send.copy(planError = "enter a valid amount")) } + mutable.update { + it.copy(send = send.copy(planError = app.getString(R.string.wallet_send_invalid_amount))) + } return } mutable.update { it.copy(send = send.copy(planning = true, planError = null)) } @@ -358,7 +370,13 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { mutable.update { it.copy(send = it.send.copy(planning = false, planError = e.message)) } } catch (e: IOException) { mutable.update { - it.copy(send = it.send.copy(planning = false, planError = e.message?.take(200) ?: "no route to the node")) + it.copy( + send = it.send.copy( + planning = false, + planError = e.message?.take(200) + ?: app.getString(R.string.wallet_error_no_route), + ), + ) } } } diff --git a/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt index 93e00e98fa..d3b636617e 100644 --- a/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt +++ b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt @@ -3,6 +3,7 @@ package com.skycoin.skywire.wallet import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import com.skycoin.skywire.R import com.skycoin.wallet.AddressBook import com.skycoin.wallet.SignedTx import com.skycoin.wallet.TxPlan @@ -62,7 +63,7 @@ class WalletRepository private constructor(private val context: Context) { suspend fun addFiberCoin(name: String, ticker: String, nodeUrl: String, icon: String? = null): CoinSpec { val url = nodeUrl.trim().removeSuffix("/") require(url.toHttpUrlOrNull() != null) { - "the node address must be a full URL, like http://node.example.com:6420" + context.getString(R.string.wallet_add_coin_node_invalid) } val spec = CoinSpec( id = "fiber-${UUID.randomUUID().toString().take(8)}", @@ -84,9 +85,9 @@ class WalletRepository private constructor(private val context: Context) { */ suspend fun addErc20Token(name: String, ticker: String, contract: String, decimals: Int, icon: String? = null): CoinSpec { require(EthCrypto.isValidAddress(contract.trim())) { - "the contract must be a 0x… address (checksummed or all-lowercase)" + context.getString(R.string.wallet_add_token_contract_invalid) } - require(decimals in 0..36) { "decimals must be between 0 and 36" } + require(decimals in 0..36) { context.getString(R.string.wallet_add_token_decimals_range) } val spec = CoinSpec( id = "erc20-${UUID.randomUUID().toString().take(8)}", name = name.trim(), @@ -125,11 +126,12 @@ class WalletRepository private constructor(private val context: Context) { */ suspend fun removeUserCoin(coinId: String): CoinSpec? { val spec = coin(coinId) ?: return null - require(!spec.builtIn) { "${spec.name} is built in and cannot be removed" } + require(!spec.builtIn) { context.getString(R.string.wallet_coin_remove_builtin, spec.name) } val held = wallets().first().count { it.coinId == coinId } require(held == 0) { - val what = if (held == 1) "its wallet" else "its $held wallets" - "Remove $what first — deleting a wallet erases its recovery phrase from this phone." + context.resources.getQuantityString( + R.plurals.wallet_coin_remove_blocked_count, held, held, + ) } seeds.store.edit { prefs -> val current = prefs[KEY_FIBER_COINS]?.let { @@ -216,7 +218,7 @@ class WalletRepository private constructor(private val context: Context) { withContext(Dispatchers.IO) { val core = coreFor(spec) val seed = normalizeSeed(mnemonic) - require(core.validateSeed(seed)) { "invalid recovery phrase" } + require(core.validateSeed(seed)) { context.getString(R.string.wallet_seed_invalid) } val (receiveCount, changeCount) = if (restored) { runCatching { core.scanUsed(seed) }.getOrDefault(1 to 0) @@ -390,7 +392,7 @@ class WalletRepository private constructor(private val context: Context) { suspend fun newReceiveAddress(walletId: String): String { val meta = wallet(walletId) ?: error("unknown wallet") val spec = coin(meta.coinId) ?: error("unknown coin") - val seed = seeds.seed(walletId) ?: error("seed unavailable") + val seed = seeds.seed(walletId) ?: error(context.getString(R.string.wallet_seed_unavailable)) val book = coreFor(spec).deriveAddresses( seed, meta.receiveAddresses.size + 1, meta.changeAddresses.size, ) @@ -453,7 +455,7 @@ class WalletRepository private constructor(private val context: Context) { ): TxPlan = withContext(Dispatchers.IO) { val meta = wallet(walletId) ?: error("unknown wallet") val spec = coin(meta.coinId) ?: error("unknown coin") - val seed = seeds.seed(walletId) ?: error("seed unavailable") + val seed = seeds.seed(walletId) ?: error(context.getString(R.string.wallet_seed_unavailable)) coreFor(spec).buildTx( seed = seed, book = AddressBook(meta.receiveAddresses, meta.changeAddresses), @@ -469,7 +471,7 @@ class WalletRepository private constructor(private val context: Context) { val meta = wallet(walletId) ?: error("unknown wallet") val spec = coin(meta.coinId) ?: error("unknown coin") val core = coreFor(spec) - val seed = seeds.seed(walletId) ?: error("seed unavailable") + val seed = seeds.seed(walletId) ?: error(context.getString(R.string.wallet_seed_unavailable)) val signed: SignedTx = core.signTx(seed, plan) val txid = core.broadcast(signed) diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..c936e1fab3 --- /dev/null +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,735 @@ + + + + + Skywire + + 首页 + 聊天 + 钱包 + 设置 + 应用 + + SkySOCKS + SkyVPN + SkyDEX + SkyChat + 钱包 + 集群 + SkyMeet + + + 已安装 %1$d 个 · 运行中 %2$d 个 + 全部 + 网络 + 金融 + 社交 + 你的应用 + SOCKS5 代理 + 兑换 · SKY / BTC + 聊天室与消息 + SKY · Fibercoin · BTC · ETH + 你在别处运行的节点 + 加密通话 + 重新连接中 + 启动中 + 尚未选择出口 + 下行 + 上行 + 流量 + SkyVPN 开关 + + %1$d 跳 + + 跳数 — + 断网保护已开启 + 断网保护已关闭 + %1$s SKY + + 已连接 %d 个节点 + + + + 路由长度 + 最快 + 均衡 + 私密 + 直连出口的路由,也是这个网络能达到的最快方式。出口依然无法读取你的流量,但它会看到发起请求的正是这台设备。 + 流量经中间节点转发,因此没有任何一个节点能同时看到是谁在请求、请求的又是什么。速度更慢,而且每多一跳,就多一台必须保持在线的机器。 + 正在等待节点报告它的路由设置。 + 自定义 + 4+ + 自定义路由长度 + 节点建立的每条路由,至少都要经过这么多跳。第一跳之后每增加一跳,请求者与请求内容之间的隔离就更强一分,代价是更高的延迟,以及多一台必须保持在线的机器。路由查找最深只搜索 10 跳,因此更大的值永远无法建立路由。 + 最小跳数 + 请输入 1 到 10 之间的数字。 + + + 网络地址 + 本设备 + 流量出口 + 不经过 SkyVPN + 出口位置未知 + 正在等待节点… + 运营商共享地址 + 无法被发现 + 连接 SkyVPN 后,本机地址不会改变:隧道由本应用承载,它自身的流量必须留在隧道之外。改变的是其他应用的流量从哪里离开网络,也就是上面的出口。 + 出口 %1$s + + 敬请期待 + 连接 + 断开 + 返回 + 保存 + 取消 + + + Skywire 核心 + 在 Skywire 节点运行期间显示。 + Skywire 正在运行 + 正在启动节点… + 已连接到 Skywire 网络。 + 节点已崩溃,正在重启(第 %1$d 次尝试)。 + + + 来电 + 通话中 + 来电 + 正在呼叫… + 接听 + 拒接 + 通话中 + SkyChat 正在使用麦克风。 + 允许使用麦克风,对方才能听到你的声音。 + 挂断 + 静音 + 取消静音 + 免提 + 未接来电 + + + 未连接 + 正在启动… + 已连接 + 正在停止… + 正在重启(第 %1$d 次尝试)… + 点按“连接”即可在本机启动 Skywire 核心。 + 正在加入 Skywire 网络。首次连接可能需要几分钟。 + 核心未能启动。请查看进程日志。 + 节点 + 公钥 + 版本 + 运行时长 + 更多信息 + 收起信息 + 传输通道 + 总计 + DMSG 服务器 + 服务状态 + 已复制 + 查看日志 + + + 日志 + 核心 + 进程 + 跟随 + 暂停 + 分享 + 筛选… + 暂无日志。 + 正在获取日志… + 丢失 %1$d 行(缓冲区已覆盖) + 核心 API 无法访问,无法显示新内容。即使节点无法启动,“进程”来源仍然可用。 + + + 正在连接… + 连接失败 + Skywire 核心未运行。请从“首页”启动。 + 正在等待 Skywire 核心启动… + 服务器 + 延迟 + 传输量 + 重新连接 + 在下方选择一台服务器进行连接。 + 供其他应用使用的 SOCKS5 + SkySOCKS 连接期间,把支持代理的应用或浏览器指向该地址即可。整机流量路由将随 SkyVPN 提供。 + 更改端口 + 监听端口 + SOCKS5 监听始终绑定在 127.0.0.1,只有本机上的应用能访问。 + 端口 + 请输入 1024 到 65535 之间的端口。 + 服务器(%1$d) + 按公钥、国家或版本搜索 + 没有匹配的服务器。 + 服务发现未返回任何代理服务器。 + 重试 + 刷新 + + + 位置未知 + + + %1$s天 + %1$s小时 + %1$s分 + %1$s秒 + + + + 正在启动 + 运行中 + 正在连接 + 连接失败,正在重连 + 正在关闭 + 已停止 + 正常 + 异常 + 连接中 + 错误 + + + 正在连接… + 连接失败 + 已被断网保护阻断 + Skywire 核心未运行。请从“首页”启动。 + 正在等待 Skywire 核心启动… + 出口 + 重新连接 + 在下方选择一个出口,让本机流量经由它转发。 + 隧道已断开,断网保护正在让其他所有应用保持离线。断开连接即可恢复正常网络。 + Skywire 自身的流量不走隧道——隧道正是由它承载的。 + 出口(%1$d) + 没有匹配的出口。 + 服务发现未返回任何 VPN 服务器。 + 按公钥、国家或版本搜索 + + 本次会话 + 延迟 + 传输量 + 速度 + 会话时长 + 本机累计 + 接口地址 + + 断网保护 + 隧道断开时保留网络接口,使流量不会绕过它泄漏出去。在隧道恢复之前,其他应用将无法联网。 + “始终开启的 VPN”设置 + 若要获得最强的保障——在应用启动前就开始拦截——请在 Android 设置中为 Skywire 打开“始终开启的 VPN”和“阻止不使用 VPN 的连接”。 + + Android 尚未授予 VPN 权限。 + VPN 服务无法打开其交接套接字:%1$s + 要让这台手机通过 Skywire 路由流量,必须授予 VPN 权限。 + Android 没有可打开的 VPN 设置页面。 + + + 传输通道 + 更改 + 建立到服务器的路由时优先尝试这一类型,其余类型仍作为备用。 + 首选传输通道 + Skywire 连接时会先尝试这一类型,无法建立时再回退到其他类型。下次连接时生效。 + 移动端推荐 + DMSG + 通过 DMSG 服务器中转。在任何运营商 NAT 后面都能用——手机总能建立的连接。 + STCPR + 直连服务器的 TCP。可用时最快,但移动网络很少能提供它所需的可达性。 + SUDPH + 带打洞的 UDP 直连。需要 NAT 允许打洞——很多运营商并不允许。 + + + 聊天操作 + 重新加载 + 正在启动 SkyChat… + 正在等待 Skywire 核心启动… + Skywire 核心未运行。请从“首页”启动。 + SkyChat 拒绝了已保存的密码。 + SkyChat 未在 %1$s 上响应。 + SkyChat 播放 + 语音消息或片段播放期间,可播放、暂停和跳过。 + 音频 + 后退 %1$d 秒 + 暂停 + 播放 + 前进 %1$d 秒 + + + 市场 + 市场公钥 + 要交易的市场。连接后会通过 Skywire 接入该市场,并在下方打开它的交易界面。 + 市场公钥是以 02 或 03 开头的 66 位十六进制字符。 + 最近的市场 + 正在通过 Skywire 接入市场… + Skywire 核心未运行。请从“首页”启动。 + 正在等待 Skywire 核心启动… + 连接市场失败(%1$d) + SkyDEX 拒绝了已保存的密码。 + SkyDEX 未在 %1$s 上响应。 + 这不是市场公钥。 + + + 启用集群 + 允许你自己的节点通过 DMSG 连到这台手机,并在这里上报状态。默认关闭。 + 正在重启 Skywire 核心以使更改生效… + 打开集群? + 关闭集群? + Skywire 核心只在启动时读取该设置,因此必须重启。任何 SkySOCKS 或 SkyVPN 连接都会中断,需要重新连接。 + 重启核心 + + 在这里看你的其他节点 + 集群会列出你在别处运行的节点——每个是否在线、正在运行什么、已运行多久。你可以重启其中一个。除此之外没有别的:不涉及传输通道、应用和设置。 + 打开后,这台手机会作为状态端点通过 DMSG 变得可达,那些节点便可连入。只有已经带有这台手机公钥的节点才能连接——而且它们依然无法控制这里的任何东西。 + + 添加节点 + 在另一台机器上,把这台手机的公钥加入该节点的配置: + 这台手机 + 正在等待核心上报这台手机的公钥… + skywire cli config update hv --add-pks %1$s + 点按即可复制。 + 在该节点的 skywire-config.json 所在位置运行这条命令,然后重启该节点。它连上后就会出现在列表中。 + + Skywire 核心未运行。请从“首页”启动。 + 正在等待 Skywire 核心启动… + 节点(%1$d) + 还没有节点连接。点按上方的 ? 查看如何添加。 + 刷新 + 离线 + 健康状况 + 未知 + 上次响应在 %1$s 前,以上都是那时的数据 + 重启 + 为此节点命名 + 名称 + 给 %1$s 起个名字,方便你分辨是哪台机器。名称只保存在本手机,不会写入该节点。清空输入框即可删除。 + 要重启 %1$s 吗?它会关闭正在运行的一切,重新读取配置后再启动——离线几秒,经由它路由的连接都会断开。 + 已向 %1$s 发送重启指令。它会先离线,然后自行恢复。 + + 核心无法重新启动:%1$s + 核心服务意外出错。 + 节点无法启动:%1$s + 已加密的配置无法读回,因此保留了未加密的配置。 + 无法打开已加密的配置。 + 无法打开已加密的配置——这部手机的密钥库密钥已经不在了(可能是恢复出厂设置,或者锁屏密码被删除后又重新设置)。其中的身份无法恢复,但可以生成一个新的身份。 + 无法生成配置。 + 无法读取配置文件(%1$s)——清除本应用的数据可重新生成。 + 无法重新生成配置(退出码 %1$d):\n%2$s + 无法生成配置——生成超时(退出码 %1$d):\n%2$s + 无法生成配置(退出码 %1$d):\n%2$s + Skywire 核心程序缺失或不可执行:%1$s + + + Skywire 已锁定 + 解锁 + 解锁 Skywire + 请使用指纹、面容或锁屏密码。 + + + 身份 + 这部手机本身就是一个 Skywire 节点,其他一切都通过它的密钥来寻址。下面两项操作都会终结这个身份。 + 尚无身份。在首页连接一次,就会生成一个。 + 正在以新身份重启 Skywire 核心… + 更换私钥 + 新建身份 + 粘贴你想让这部手机使用的 64 字符私钥。Skywire 核心会先校验,之后才会改动任何内容。 + 私钥 + 这已经是本节点的密钥,无需更改。 + 核心无法读取该私钥。 + 这不是私钥——应为 %1$d 位十六进制字符。 + 身份已更换。核心正在以新密钥启动。 + 已生成新身份。核心正在以它启动。 + + 要更换本节点的身份吗? + 新的公钥将是 %1$s。 + 此操作无法撤销 + 以上内容会全部抹除,这部手机将变成节点 %1$s。如果日后还想换回来,请确认你在别处保有当前私钥;如果没有,请先导出配置。 + 更换身份 + + 要生成新身份吗? + 新密钥在这部手机上生成。目前还没有人拥有它,在你再次分享之前,没有人能通过它找到你。 + 此操作无法撤销 + 当前密钥会被销毁,而不是存档。如果你没有导出配置,这个身份就永远消失了——发往它的一切也一并消失。 + 生成新身份 + + + 这部手机以 %1$s 身份保存的一切都会丢失:聊天记录和联系人、群组,以及其他节点认识它的那个地址。这些事后都无法恢复,也不会转移到新密钥上。 + + 继续 + + + 配置 + 完整的节点配置,导出为一个由你自己保管的文件。这是把该身份迁移到其他设备或日后找回的唯一途径。 + 导出配置 + 要导出配置吗? + 文件中以明文包含本节点的私钥。任何人读到它,都能以本节点的身份运行。请存放在只有你能访问的地方——不要放共享网盘,也不要发到聊天里。 + 导出 Skywire 配置 + 配置已导出。 + 无法打开所选文件进行写入。 + + + 应用锁 + 打开 Skywire 时,以及离开约半分钟后再回到应用时,都会要求指纹、面容或锁屏密码。同时会在最近任务列表中隐藏应用内容,并禁止截屏。 + 这部手机既没有设置锁屏密码,也没有录入生物识别,因此无从校验。 + 打开安全设置 + 这部手机没有可打开的安全设置页面。 + 开启应用锁 + 关闭应用锁 + 请确认是你本人。 + + + 主题 + 跟随系统 + 浅色 + 深色 + + + 语言 + 应用界面所用的语言。日志和节点自身报告的内容仍为英文——它们要与桌面端的日志放在一起看,翻译过的日志行没有人能搜到。 + 跟随系统 + + 关于 + 应用版本 + 核心版本 + + + 日志与诊断 + 查看各个日志来源、一次性导出全部日志,并设置核心的记录量。 + + 日志与诊断 + 日志来源 + 与各页面“日志”按钮打开的是同一个查看器,这里把所有日志集中列出。 + 节点自身的运行日志。 + 核心进程输出的内容——节点无法启动时,只剩这一份日志可看。 + 本应用的日志,由节点保存。 + + 全部导出 + 把以上所有来源打包成一个 zip,附带设备信息和配置——不含私钥。 + 导出全部日志 + 正在收集日志… + 诊断信息已导出。 + 无法打开所选文件进行写入。 + + 核心日志级别 + 节点记录多少内容。debug 和 trace 用于复现问题——在手机上会写入大量没人会看的日志。 + 将日志级别设为 %1$s? + Skywire 核心只在启动时读取该设置,因此必须重启。所有 SkySOCKS 或 SkyVPN 连接都会中断,需要重新连接。 + + “连接”将启动 Skywire 核心——即将推出。 + + + 设置你的 %1$s 钱包 + 密钥在本设备上生成,也只留在本设备上。Skywire 看不到它们,它们也不会被发送到网络。 + 创建新钱包 + 生成一组新的助记词 + 用助记词恢复 + 输入已有的 12 个助记词 + + 助记词 + 按顺序抄下这十二个助记词,并离线保存。拿到它们的人就能花掉你的币。 + 本页面已禁止截屏,也没有复制按钮。 + 继续 + + 确认助记词 + 从刚抄下的助记词中输入其中三个。全部匹配后钱包即启用。 + 第 %1$d 个词 + 输入该词 + 这不是第 %1$d 个词。请核对你抄下的内容。 + 请输入第 %1$d 个词。 + 启用钱包 + 再看一次助记词 + 钱包已启用 + + 恢复钱包 + 第 %1$d 个词,共 %2$d 个 + 粘贴完整助记词 + 已从剪贴板读取十二个助记词 + 下一个词 + 候选词来自设备上的 BIP39 词表。你输入的内容不会发送到任何地方。 + 恢复钱包 + 这组助记词校验不通过。十二个词中有一个写错了,或者顺序不对。 + 12 个词只输入了 %1$d 个。助记词必须完整输入,否则无法恢复。 + 钱包已恢复,正在扫描余额。 + 正在恢复并扫描余额… + + 选择币种 + 选择币种 + 在 %1$d 种币种中搜索 + + Skycoin 主网 · 原生链 + Fibercoin + 比特币主网 + 以太坊主网 + 以太坊上的 ERC-20 代币 + + 连接详情(%1$d 条事件) + 连接详情——收集中… + 节点没有为本次尝试记录任何日志。 + 复制详情 + + + 当前只能使用 1 跳:经中间节点路由需要与公共节点保持开放的传输通道,而该功能已关闭。在设置中开启“连接公共节点”后即可使用 2 跳或 3 跳。 + + 连接公共节点 + 保持与公共节点之间的传输通道,让网络可以经这部手机路由,也让这部手机可以经它们路由。会消耗电量和移动数据,因此默认关闭。路由长度超过 1 跳时必须开启。 + 核心下次启动时生效。 + + + 远程管理 + 允许另一个节点——你自己桌上的那台——通过网络操作本节点:启动和停止应用、修改路由、读取日志。在授权公钥之前外部无法访问;被授权的公钥拥有完全控制权,因此只应授权你自己的设备。双向都不提供 shell 或文件访问。 + 授权或撤销在核心下次启动时生效。 + 授权访问… + 授权 + 撤销 + 授权远程管理 + 粘贴控制端节点的公钥——在那台机器上运行“skywire cli visor pk”即可打印出来。核心下次启动后,那台机器将获得本节点的完全控制权,同时这部手机会出现在它的 hypervisor 列表中。 + 公钥 + 这不是节点公钥——应为 66 位十六进制字符。 + 已授权访问。核心下次启动时生效。 + 已撤销访问。核心下次启动时生效。 + + 添加币种或代币 + + 移除该币种 + 移除 %1$s? + 它只会从你的币种列表中消失。链上不会有任何变化,也不会动到任何密钥——你可以用相同的信息重新添加。 + %1$s 下仍有钱包 + 请先移除它的钱包。删除钱包会把助记词从这部手机上抹除,所以那是与整理列表无关的另一个决定。 + + 请先移除它的 %1$d 个钱包——删除钱包会把助记词从这部手机上抹除。 + + %1$s 是内置币种,无法移除 + 移除 + 知道了 + 已移除 %1$s + %1$s 币时 + %1$d 个已确认输出 · 该链没有币时 + 以太坊主网 · 手续费以 ETH 支付 + 以太坊上的 %1$s · 手续费以 ETH 支付 + 无法连接节点服务器。余额和历史记录最后更新于 %1$s,距今 %2$s。在节点服务器恢复响应前无法发送。 + + %1$d 分钟 + + %1$d 小时 %2$d 分钟 + 无法连接节点服务器。该钱包尚未同步——节点服务器恢复响应后才会显示余额。 + 发送已停用:没有到节点服务器的路由 + 没有到节点服务器的路由 + 已被拒绝 + 接收 + 发送 + 最近活动 + 查看全部 + 暂无活动 + 该钱包收到的币会显示在这里。 + 钱包 + 已接收 + 已发送 + 来自 %1$s · %2$s + 发往 %1$s · %2$s + 钱包内部转账 · %1$s + + 接收 + 只能向该地址转入 %1$s。 + 点按复制完整地址 + 复制 + 分享 + 地址已复制到剪贴板 + 此钱包中的其他地址 + %1$s 中的地址 + 它们都来自同一组助记词。每次使用新地址,可以让你的余额更难被追踪。 + 默认 · 使用中 + 从未使用 + 生成新地址 + 已在 %1$s 中生成新地址 + 已切换到所选地址收款 + + 发送 + 收款方 + %1$s 地址 + bc1… 或 1… 开头的地址 + 0x… 开头的地址 + 粘贴 + 扫描二维码 + 金额 + 可用 %1$s %2$s + 请输入有效金额 + 全部 + “全部”会发送所有 %1$s。币时不会转出——其中一部分会作为手续费被销毁。 + “全部”会花掉每一笔已确认的输出并扣除矿工费,因此金额会随费率变动。 + “全部”会预留少量 ETH 用作 Gas,确切金额在预览页计算。 + “全部”会发送全部代币余额。Gas 仍从同一地址的 ETH 中扣除。 + 手续费 + 销毁的币时 + 交易后币时 + %1$s 交易不消耗 %1$s。进入交易的币时会有十分之一作为手续费被销毁,剩下的一半随币一同转出。 + 费率 + %1$d sat/vB + 按 %1$d vB 预估手续费 + 经济 + 普通 + 优先 + ~2 h + ~30 min + ~10 min + 网络手续费 + 预览时计算 + Gas 上限 %1$d · 每单位 Gas 最高 %2$d gwei + 显示的是按当前价格计算的最高手续费——未用完的 Gas 不会收取。 + 代币转账会消耗 Gas,而 Gas 由发送地址中的 ETH 支付——请在存放代币的地址里留一点 ETH。 + 预览 + 你即将发送 + + 到 %2$s 网络上的 %1$s。在你确认之前不会签名。 + 金额 + 矿工费,按 %1$d sat/vB + 网络手续费,最高 + 从钱包转出合计 + 交易后 %1$s 余额 + 交易后币时 + 返回 + 签名并发送 + 签署这笔交易 + 签名在本机完成,你的私钥不会发送到任何地方。 + 已广播到网络 + %1$s %2$s 正在发往 %3$s。在网络确认之前,交易一直处于待确认状态。 + 完成 + 在记录中查看 + 交易 ID 已复制 + + 交易记录 + 全部 + 转出 + 转入 + 待确认 + 这里还没有内容 + 这个钱包还没有任何 %1$s 的转入或转出。 + 没有符合此筛选条件的 %1$s 交易。 + 显示我的地址 + 今天 + 昨天 + + 交易 + 已确认 + 待确认 · 等待首次确认 + 来自 + 发往 + 钱包 + 日期 + 手续费 + 确认数 + 0 / 1 + 交易 ID + 在区块浏览器中打开 + 将在浏览器中打开 %1$s。该区块浏览器会看到这个交易 ID。 + 区块浏览器 + %1$s 币时 + + 钱包 + 使用中 + 1 个地址 + %1$d 个地址 + 添加钱包 + 1 个钱包 + %1$d 个钱包 + 新建 + %1$s · 创建于 %2$s + 使用此钱包 + 重命名 + 重命名钱包 + 显示助记词 + 以明文显示全部十二个助记词。需要先验证指纹。 + 从本机移除 + 删除本机保存的密钥。没有助记词,币将永远无法找回。 + 从本机移除 %1$s? + 本机保存的密钥会被删除。如果你没有抄下那十二个助记词,这个钱包里的币将再也无人能够动用,包括你自己。 + 移除 + 保留 + %1$s 已从本机移除 + 确认是你本人 + %1$s 的助记词将在下一个页面以明文显示。 + 任何人读到这十二个助记词,都能在任意设备上转走 %1$s 中的全部币,不需要你的手机。只在没有人能看到你屏幕的地方查看它们。 + 已禁用截屏。本页面将在 %1$s 后自动关闭。 + 隐藏助记词 + + 添加币种或代币 + Fibercoin + ERC-20 代币 + 每一种 Fibercoin 都运行与 Skycoin 相同的节点软件。填写币种名称,并让钱包指向一个你信任的节点服务器——余额、手续费和精度都来自那台节点服务器。 + ERC-20 代币都运行在以太坊上,彼此之间只有合约不同。小数位必须与合约本身一致:USDT 这类代币为 6 位,多数代币为 18 位,否则金额会显示错误。 + 币种名称 + 例如 MDL Talent Hub + 请填写币种名称 + 代号 + 例如 MDL + 请填写币种代号 + 图标 + 选择图片 + 移除 + 无法读取这张图片 + 节点服务器地址 + http://node.example.com:6420 + 节点服务器地址必须是完整的 URL,例如 http://node.example.com:6420 + 合约地址 + 0x… + 合约地址必须是 0x… 开头的地址(校验和格式或全小写) + 小数位 + 18 + 小数位必须在 0 到 36 之间 + 小数位必须是数字:USDT 这类代币为 6 位,多数代币为 18 位 + 请填写代币代号 + 请填写代币名称 + 添加币种 + 无法读取助记词——本钱包创建之后,手机的密钥库已经发生变化。 + 助记词无效 + + + 关于本页面 + 知道了 + + 关于 SkyChat + 消息、通话、群组和频道都经由 Skywire 传输,而不是某家公司的服务器。你在这里的身份就是本节点的公钥——用 QR 码按钮把它发出去,拿到它的人就能联系到你。\n\n这与桌面端是同一个聊天,只是为手机重新排版。历史记录只保存在本机;没有账号,也不会自行同步,所以换到另一部手机时,要先在聊天内的设置里导出,再到新手机上导入。\n\nSkyChat 的日志在“设置 \u25b8 诊断”里,与本应用保存的其他日志放在一起。 + + 关于应用 + 你的节点能运行的一切都集中在这里。点开一张卡片进入该应用自己的页面;无论你在看哪一个,节点本身都在底下持续运行。\n\nSkySOCKS 和 SkyVPN 把其他流量送进网络,SkyDEX 用于交易,SkyChat 用于聊天,集群用于查看你在别处运行的节点。变灰的卡片表示尚未实现。 + + 关于钱包 + Skycoin、与它共用节点软件的各种 Fibercoin,还有比特币——全部由本机生成、从不离开本机的密钥持有。Skywire 看不到这些密钥,它们也绝不会被发送到网络上。\n\n十二个助记词就是钱包本身。任何人只要看到它们,不需要你的手机也能在任意设备上花掉这些币;而你一旦弄丢,没有人能帮你找回钱包——我们不能,任何人都不能。\n\n余额和交易记录来自每个币种所指向的节点服务器,因此当某个币种的节点服务器无法连接时,只会显示最后一次已知的余额,并且无法转账。 + + 关于设置 + 这部手机在网络上的身份,以及本应用的行为方式。身份是保存在文件里的一对密钥——请在需要之前就备份好,因为弄丢它就等于弄丢了你的地址、聊天记录的另一半,以及与这把密钥绑定的所有奖励。\n\n所有日志都在“诊断”里:节点核心、应用进程,以及在其之下运行的每个应用。连不上的时候就到那里看。 + + 关于 SkySOCKS + 一个从别处出网的 SOCKS5 代理。选好服务器后,这部手机会开放一个本地代理端口;凡是指向该端口的流量,都会从你选择的服务器离开网络,而不是从这里。\n\n它只代理你配置为走它的流量,不会接管整部手机。接管整部手机的是 SkyVPN。\n\nSkySOCKS 的日志在“设置 \u25b8 诊断”里。 + + 关于 SkyVPN + 整部手机的流量,都经过你选择的服务器。首次启用时 Android 会请求添加 VPN 配置;开启期间,每个应用的流量都从出口服务器离开,而不是从这里。\n\n密钥始终留在本机,出口服务器能看到它转发的流量——任何 VPN 出口都是如此。关闭后会立即恢复正常路由。\n\nSkyVPN 的日志在“设置 \u25b8 诊断”里。 + + 关于 SkyDEX + 与做市商交易,连接经由 Skywire,而不是公开互联网。这个页面是交易所自己的界面,运行在你当前连接的市场上。\n\n订单和余额属于那个市场,不属于“钱包”标签页里的钱包——两者互相独立。\n\nSkyDEX 的日志在“设置 \u25b8 诊断”里。 + 关于集群 + 你在其他机器上运行的节点——台式机、服务器、Skyminer——从这部手机看过去的样子。它是一扇窗,不是遥控器:传回来的是状态,唯一能做的操作是重启。\n\n默认关闭。开启会重启这部手机的核心,因为这项设置只在节点自我构建时读取一次。\n\n节点自己的日志在这里的卡片上,因为那条日志来自远程机器;本应用的日志在“设置 \u25b8 诊断”里。你给节点起的名字保存在本机,不会跟着它走。 + + 电池 + Skywire 会在后台持续运行,但屏幕熄灭一段时间后,Android 仍可能暂停它的网络——消息和通话会等到手机下次唤醒时才到达,而不是在发出时到达。允许 Skywire 忽略电池优化可以消除这段延迟。这会更耗电;不允许也一样能用。 + Skywire 已获准忽略电池优化,因此屏幕熄灭时连接仍会保持。Android 的电池设置随时可能收回这项权限。 + 允许 + 暂不 + 这部手机没有可打开的电池优化设置页面。 + 让 Skywire 在后台保持连接? + 屏幕熄灭一段时间后,Android 可能暂停本应用的网络。允许它忽略电池优化,消息就能即发即到。 + + 加密存储配置 + 每当 Skywire 断开连接时,用一把本机不会交出的密钥封存配置文件。设备被检查时可以保护私钥;连接期间则没有区别。 + 如果这部手机被重置——或者锁屏密码被移除后重新设置——封存密钥会随之销毁,配置中的身份将无法恢复。如果这对你很重要,请先导出配置。 + 确认以明文存储配置 + 配置已加密。 + 断开连接后将对配置加密。 + 配置已不再加密。 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index db31c80365..da8dcde853 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -169,6 +169,30 @@ Unknown location + + %1$sd + %1$sh + %1$sm + %1$ss + " " + + + Starting + Running + Connecting + Connection failed, reconnecting + Shutting down + Stopped + healthy + unhealthy + connecting + error + Connecting… Connection failed @@ -223,8 +247,15 @@ Starting SkyChat… Waiting for the Skywire core to come up… The Skywire core is not running. Start it from the Home tab. + SkyChat rejected the stored password + SkyChat did not answer on %1$s SkyChat playback Play, pause and skip a voice message or clip while it is playing. + Audio + Back %1$ds + Pause + Play + Forward %1$ds Market @@ -235,6 +266,10 @@ Reaching the market over Skywire… The Skywire core is not running. Start it from the Home tab. Waiting for the Skywire core to come up… + market connect failed (%1$d) + SkyDEX rejected the stored password + SkyDEX did not answer on %1$s + That is not a market public key. Enable Fleet @@ -274,6 +309,17 @@ Restart sent to %1$s. It goes offline and comes back on its own. The core could not be started again: %1$s + The core service failed unexpectedly. + The visor could not be started: %1$s + The sealed config could not be read back, so the unencrypted one is left in place. + The sealed config cannot be opened. + The sealed config cannot be opened — this phone’s keystore key is gone (a factory reset, or a screen lock that was removed and re-added). The identity in it is unrecoverable; a new one can be generated. + The config could not be generated. + The config file cannot be read (%1$s) — clearing this app’s data regenerates it. + The config could not be regenerated (exit %1$d):\n%2$s + The config could not be generated — it timed out (exit %1$d):\n%2$s + The config could not be generated (exit %1$d):\n%2$s + The Skywire core binary is missing or not executable: %1$s Skywire is locked @@ -291,6 +337,8 @@ Paste the 64-character secret key you want this phone to run as. It is checked by the Skywire core before anything changes. Secret key That is already this visor’s key — nothing to change. + The core could not read that secret key. + Not a secret key — expected %1$d hex characters. Identity replaced. The core is starting with the new key. New identity generated. The core is starting with it. @@ -319,6 +367,7 @@ The file contains this visor’s secret key in plain text. Anyone who reads it can run as this visor. Save it somewhere only you can reach — not a shared drive, not a chat. Export the Skywire config Config exported. + The chosen file could not be opened for writing. App lock @@ -335,6 +384,14 @@ System Light Dark + + + Language + What this app is written in. Logs and whatever the visor itself reports stay in English — they are read next to a desktop’s, and a translated log line is one nobody can search for. + System + English + 简体中文 About App version Core version @@ -355,6 +412,7 @@ Export all logs Collecting logs… Diagnostics exported. + The chosen file could not be opened for writing. Core log level How much the visor writes. Debug and trace are for reproducing a problem — on a phone they are a lot of writing for a log nobody is reading. @@ -457,6 +515,11 @@ It goes from your coin list. Nothing on the chain changes and no keys are touched — you can add it again with the same details. %1$s still has wallets Remove its wallets first. Deleting a wallet erases its recovery phrase from this phone, so it is a separate decision from tidying this list. + + Remove its wallet first — deleting a wallet erases its recovery phrase from this phone. + Remove its %1$d wallets first — deleting a wallet erases its recovery phrase from this phone. + + %1$s is built in and cannot be removed Remove Got it %1$s removed @@ -465,8 +528,15 @@ Ethereum mainnet · fees paid in ETH %1$s on Ethereum · fees paid in ETH Node unreachable. Balance and history last updated at %1$s, %2$s ago. Sending is off until the node answers. + + %1$d minute + %1$d minutes + + %1$d h %2$d min Node unreachable. This wallet has not synced yet — balances show once the node answers. Send is disabled: no route to the node + no route to the node + rejected Receive Send Recent activity @@ -504,6 +574,7 @@ Scan a QR code Amount Available %1$s %2$s + enter a valid amount Max Max sends every %1$s. Coin Hours are not sent — a share of them is burned as the fee. Max spends every confirmed output and subtracts the miner fee, so the amount moves with the rate. @@ -569,9 +640,11 @@ Date Fee Confirmations + 0 of 1 Transaction id Open in explorer Opens %1$s in your browser. The explorer will see this transaction id. + the explorer %1$s Coin Hours Wallets @@ -608,19 +681,29 @@ Every ERC-20 token lives on Ethereum and differs only in its contract. The decimals must match the contract’s own — 6 for USDT-like tokens, 18 for most — or amounts will read wrong. Coin name e.g. MDL Talent Hub + give the coin a name Ticker e.g. MDL + give the coin a ticker Icon Choose image Remove + that image cannot be read Node address http://node.example.com:6420 + the node address must be a full URL, like http://node.example.com:6420 Contract address 0x… + the contract must be a 0x… address (checksummed or all-lowercase) Decimals 18 + decimals must be between 0 and 36 + decimals must be a number — 6 for USDT-like tokens, 18 for most + give the token a ticker + give the token a name Add coin The phrase cannot be read — the phone’s keystore has changed since this wallet was created. + invalid recovery phrase + + Battery Skywire keeps running in the background, but Android may still pause its network once the screen has been off for a while — messages and calls then arrive when the phone next wakes rather than when they were sent. Allowing Skywire to ignore battery optimisation closes that gap. It costs battery, and the app works either way. Skywire is allowed to ignore battery optimisation, so its connections stay up while the screen is off. Android\u2019s battery settings can take this back at any time. diff --git a/android/app/src/main/res/xml/locales_config.xml b/android/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000000..ba6b7233cd --- /dev/null +++ b/android/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/test/java/com/skycoin/skywire/AppLanguageTest.kt b/android/app/src/test/java/com/skycoin/skywire/AppLanguageTest.kt new file mode 100644 index 0000000000..e7c0ef0090 --- /dev/null +++ b/android/app/src/test/java/com/skycoin/skywire/AppLanguageTest.kt @@ -0,0 +1,54 @@ +package com.skycoin.skywire + +import com.skycoin.skywire.core.AppLanguage +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * What a stored value and a platform tag list mean. + * + * Both directions matter and they are not the same one. Below API 33 the + * choice comes back from our own prefs as an enum constant's name; on 33+ it + * comes back from `LocaleManager` as BCP-47, and the platform is free to + * canonicalise what it was given — ask for `zh-CN` and a later read can return + * `zh-Hans-CN`. A picker that fails to recognise its own stored choice shows + * "System" while the app is plainly in Chinese, and the user cannot get back. + */ +class AppLanguageTest { + + @Test + fun storedNamesRoundTrip() { + for (language in AppLanguage.entries) { + assertEquals(language, AppLanguage.of(language.name)) + } + } + + @Test + fun unknownStoredValueFallsBackToSystem() { + // An older build's value, or a language that has since been dropped. + assertEquals(AppLanguage.SYSTEM, AppLanguage.of("KLINGON")) + assertEquals(AppLanguage.SYSTEM, AppLanguage.of(null)) + assertEquals(AppLanguage.SYSTEM, AppLanguage.of("")) + } + + @Test + fun platformTagsResolveToTheShippedTranslation() { + // Every shape the platform is entitled to hand back for what we set. + for (tags in listOf("zh-CN", "zh-Hans-CN", "zh", "zh-CN,en")) { + assertEquals("tags=$tags", AppLanguage.CHINESE_SIMPLIFIED, AppLanguage.ofTags(tags)) + } + for (tags in listOf("en", "en-US", "en-GB,fr")) { + assertEquals("tags=$tags", AppLanguage.ENGLISH, AppLanguage.ofTags(tags)) + } + } + + @Test + fun noTagAndUnshippedLanguagesReadAsSystem() { + // An empty list is how LocaleManager says "follow the system". + assertEquals(AppLanguage.SYSTEM, AppLanguage.ofTags("")) + assertEquals(AppLanguage.SYSTEM, AppLanguage.ofTags(null)) + // A language the app does not ship is the same situation as no choice: + // the system resolves the resources, and so should the picker. + assertEquals(AppLanguage.SYSTEM, AppLanguage.ofTags("fa-IR")) + } +} diff --git a/android/app/src/test/java/com/skycoin/skywire/TranslationCatalogTest.kt b/android/app/src/test/java/com/skycoin/skywire/TranslationCatalogTest.kt new file mode 100644 index 0000000000..e12b04f779 --- /dev/null +++ b/android/app/src/test/java/com/skycoin/skywire/TranslationCatalogTest.kt @@ -0,0 +1,202 @@ +package com.skycoin.skywire + +import com.skycoin.skywire.core.AppLanguage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Every language the app offers has to actually be there, and every string in + * it has to be usable. + * + * Three failures this catches, all of which look like nothing in a diff and + * are only visible on a phone set to the language nobody on the team reads: + * + * - A string added to the English catalog and not to the others. The screen + * silently falls back to English, so it ships looking finished. + * - A format placeholder dropped or retyped in translation — `%1$s` becoming + * `%1$d`, or vanishing. That is not a cosmetic fault: `getString` throws + * `IllegalFormatException` and takes the screen down with it, in that + * language only. + * - A language offered in [AppLanguage] with no `values-` folder behind it, or + * one missing from `locales_config.xml` — the picker shows an entry that + * changes nothing, or Android 13+ refuses to list the app in its own + * per-app language settings. + * + * Asserted against the resource sources rather than a built `R`, for the same + * reason [ManifestKeyguardTest] reads the manifest: this is a statement about + * what is committed, and it has to hold before anything is assembled. + */ +class TranslationCatalogTest { + + private val res = File("src/main/res") + + /** `%1$s`, `%2$d`, a bare `%s` — everything `String.format` will act on. */ + private val placeholder = Regex("%(?:(\\d+)\\$)?([a-zA-Z])") + + @Test + fun everyOfferedLanguageIsShippedAndDeclared() { + val declared = locales() + for (language in AppLanguage.entries) { + if (language == AppLanguage.SYSTEM) continue + // en is the default catalog — values/, not values-en/. + val folder = if (language == AppLanguage.ENGLISH) { + File(res, "values") + } else { + File(res, "values-${language.tag.replace("-", "-r")}") + } + assertTrue( + "AppLanguage.$language offers ${language.tag} but ${folder.path} " + + "has no strings.xml — the picker would show a language that changes nothing", + File(folder, "strings.xml").isFile, + ) + assertTrue( + "AppLanguage.$language offers ${language.tag}, which is not in " + + "res/xml/locales_config.xml (declared: $declared) — Android 13+ reads that " + + "file to list the app in Settings ▸ Apps ▸ Skywire ▸ Language", + declared.contains(language.tag), + ) + } + for (tag in declared) { + assertTrue( + "locales_config.xml declares $tag, which no AppLanguage entry offers — " + + "the system would let the user pick a language the app cannot show in Settings", + AppLanguage.entries.any { it.tag == tag }, + ) + } + } + + @Test + fun everyTranslatableStringIsTranslated() { + val english = catalog(File(res, "values/strings.xml")) + for ((tag, translated) in translations()) { + val missing = english.keys - translated.keys + assertTrue( + "$tag is missing ${missing.size} string(s) that values/strings.xml has, so those " + + "screens fall back to English in a language that looks finished: " + + missing.sorted().joinToString(", ").take(600), + missing.isEmpty(), + ) + val unknown = translated.keys - english.keys + assertTrue( + "$tag defines string(s) the English catalog does not — a rename left them " + + "behind, and nothing reads them: " + unknown.sorted().joinToString(", "), + unknown.isEmpty(), + ) + } + } + + /** + * A `` must carry exactly the English placeholders. A `` + * is checked against what its categories offer between them, not against + * any one of them: an English `one` item is free to drop the count that + * `other` interpolates ("its wallet" reads better than "its 1 wallet"), + * and a language with a single category still needs the number. What must + * never happen either way is a translation asking for an argument the call + * site does not pass. + */ + @Test + fun placeholdersSurviveTranslation() { + val english = catalog(File(res, "values/strings.xml")) + for ((tag, translated) in translations()) { + for ((name, values) in translated) { + val source = english.getValue(name) + val offered = source.flatMap { placeholdersOf(it) }.toSet() + for (value in values) { + val used = placeholdersOf(value) + if (source.size == 1) { + assertEquals( + "$tag:$name has different format placeholders from the English " + + "string. getString() throws IllegalFormatException at runtime " + + "for this, in this language only.\n en: ${source.first()}\n " + + "$tag: $value", + placeholdersOf(source.first()), + used, + ) + } else { + assertTrue( + "$tag:$name uses $used, but the English plural only ever passes " + + "$offered — the extra argument does not exist at the call site " + + "and formatting throws.\n $tag: $value", + offered.containsAll(used), + ) + } + } + } + } + } + + /** + * Simplified Chinese has one plural category: `other`. An `one` item there + * is never selected — it is dead text that reads as a covered case. + */ + @Test + fun chinesePluralsCarryOnlyTheCategoryItUses() { + val file = File(res, "values-zh-rCN/strings.xml") + for (plurals in elements(file, "plurals")) { + val quantities = plurals.getElementsByTagName("item").let { items -> + (0 until items.length).map { (items.item(it) as Element).getAttribute("quantity") } + } + assertEquals( + "${plurals.getAttribute("name")} in values-zh-rCN carries $quantities. " + + "Chinese selects `other` for every count; anything else is never shown.", + listOf("other"), + quantities, + ) + } + } + + // --- reading the resources --- + + /** Every shipped translation but the default catalog: tag -> its strings. */ + private fun translations(): Map>> = + AppLanguage.entries + .filter { it != AppLanguage.SYSTEM && it != AppLanguage.ENGLISH } + .associate { language -> + val folder = "values-${language.tag.replace("-", "-r")}" + language.tag to catalog(File(res, "$folder/strings.xml")) + } + + /** name -> every value under it (one for a string, one per item for plurals). */ + private fun catalog(file: File): Map> { + assertTrue("cannot find ${file.absolutePath}", file.isFile) + val out = linkedMapOf>() + for (el in elements(file, "string")) { + if (el.getAttribute("translatable") == "false") continue + out[el.getAttribute("name")] = listOf(el.textContent) + } + for (el in elements(file, "plurals")) { + val items = el.getElementsByTagName("item") + out[el.getAttribute("name")] = + (0 until items.length).map { items.item(it).textContent } + } + return out + } + + private fun elements(file: File, tag: String): List { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val nodes = document.getElementsByTagName(tag) + return (0 until nodes.length) + .map { nodes.item(it) as Element } + // Top level only: an inside is not a string of its own. + .filter { it.parentNode === document.documentElement } + } + + private fun placeholdersOf(value: String): List = + placeholder.findAll(value) + .map { it.value } + .filter { it != "%%" } + .sorted() + .toList() + + private fun locales(): List { + val file = File(res, "xml/locales_config.xml") + assertTrue("cannot find ${file.absolutePath}", file.isFile) + return elements(file, "locale").map { + it.getAttribute("android:name").ifEmpty { it.getAttribute("name") } + } + } +} diff --git a/ci_scripts/install-shellcheck.sh b/ci_scripts/install-shellcheck.sh index c942bed07c..4897be7eb1 100755 --- a/ci_scripts/install-shellcheck.sh +++ b/ci_scripts/install-shellcheck.sh @@ -1,13 +1,64 @@ #!/usr/bin/env bash +# +# Put a shellcheck where `make lint-shell` will find it: ./shellcheck if this +# can fetch the release, otherwise whatever the machine already has. +# +# Both halves are deliberate. The download is preferred because it is the same +# version everywhere. The fallback exists because github.com/releases is not a +# dependency a lint job can rely on from CI — it answered 503, then died +# mid-transfer, on consecutive runs — and a linter that cannot be downloaded is +# not a reason to fail a build when the runner image already ships one. +# +# What is NOT acceptable is the third behaviour, which is what this file used to +# do: print curl's errors, exit 0 anyway, and leave lint-shell to quietly pick +# up a different binary. That turned an outage into a lint error against source +# annotated for another version, and cost a red build to read. Hence `set -e`, +# and hence the fallback announcing itself. +# +# (Mind the wrapping here: a comment line beginning with the linter's own name +# is read as a directive, not as prose, and fails the file it explains.) + +set -euo pipefail osname="$(uname -s | tr '[:upper:]' '[:lower:]')" osarch="$(uname -m)" -mkdir -p ./scheck +# The release assets do not spell the architectures the way uname does, and an +# unbuildable name is indistinguishable from an outage at the other end: an +# Apple Silicon Mac asks for `darwin.arm64`, which has never existed, and gets +# back whatever the network says about a URL with no file behind it. Every +# asset for ARM is published as `aarch64`. +case "$osarch" in + arm64) osarch="aarch64" ;; + amd64) osarch="x86_64" ;; +esac -curl -L -o shellcheck-stable.tar.xz "https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.${osname}.${osarch}.tar.xz" +url="https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.${osname}.${osarch}.tar.xz" -tar -xvf shellcheck-stable.tar.xz -C ./scheck +trap 'rm -rf ./scheck ./shellcheck-stable.tar.xz' EXIT + +mkdir -p ./scheck -mv ./scheck/shellcheck-stable/shellcheck ./shellcheck -rm -rf ./scheck ./shellcheck-stable.tar.xz +# --fail so an HTML error page is not mistaken for a tarball, and --retry +# because a release download dying mid-transfer often fixes itself. +if curl --fail --location --retry 3 --retry-delay 2 --retry-all-errors \ + -o shellcheck-stable.tar.xz "$url" +then + tar -xf shellcheck-stable.tar.xz -C ./scheck + mv ./scheck/shellcheck-stable/shellcheck ./shellcheck + echo "installed ./shellcheck from $url" + ./shellcheck --version +elif command -v shellcheck >/dev/null 2>&1; then + # No ./shellcheck is written, which is exactly what lint-shell's + # `command -v ./shellcheck || command -v shellcheck` falls back on. + echo "WARNING: could not download $url" + echo "WARNING: falling back to the shellcheck already on PATH:" + shellcheck --version + echo "WARNING: its findings may differ from the pinned release — see the" + echo "WARNING: SC2317/SC2329 note in ci_scripts/mux-route-probe.sh." +else + echo "FATAL: could not download $url, and no shellcheck is on PATH." >&2 + echo "FATAL: install one (apt-get install shellcheck, brew install" >&2 + echo "FATAL: shellcheck, pip install shellcheck-py) and try again." >&2 + exit 1 +fi diff --git a/ci_scripts/mux-route-probe.sh b/ci_scripts/mux-route-probe.sh index 2786f2b7ac..17617fe22d 100755 --- a/ci_scripts/mux-route-probe.sh +++ b/ci_scripts/mux-route-probe.sh @@ -276,7 +276,12 @@ pre_rg="$("${CLI[@]}" rg ls --json 2>/dev/null || echo '[]')" pre_rg_count=$(printf '%s' "$pre_rg" | jq '. // [] | length') tmpdir=$(mktemp -d -t muxprobe.XXXXXX) -# shellcheck disable=SC2329 # invoked via trap, not direct call +# Both codes, because shellcheck has named this one finding two ways: SC2329 +# on the function ("never invoked") and SC2317 on each command inside it +# ("appears to be unreachable"). Which one you get depends on the version, and +# lint-shell installs whatever `stable` currently is — so suppressing only the +# one your local copy prints is how this passed review and then failed CI. +# shellcheck disable=SC2317,SC2329 # invoked via trap, not direct call cleanup() { rm -rf "$tmpdir" # Restore route minhops if --avoid-direct mutated it. Always