Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ee051ab2 | ||
|
|
0e35a794c9 | ||
|
|
5ffb5a2dbb | ||
|
|
ae06a58013 | ||
|
|
9109eb8460 | ||
|
|
1ef8c983c7 |
No files matched your search
@@ -40,3 +40,21 @@
|
|||||||
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
||||||
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
||||||
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
||||||
|
|
||||||
|
## Android-приложение: выкладка через панель SHX VPN
|
||||||
|
|
||||||
|
Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель
|
||||||
|
`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку.
|
||||||
|
|
||||||
|
- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree):
|
||||||
|
`./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`.
|
||||||
|
- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`.
|
||||||
|
Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=<id>`.
|
||||||
|
- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что
|
||||||
|
`NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии).
|
||||||
|
- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра,
|
||||||
|
если процесс запущен раньше). Не печатать значение токена.
|
||||||
|
- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает.
|
||||||
|
- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом).
|
||||||
|
- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и
|
||||||
|
требует отдельного подтверждения.
|
||||||
@@ -45,8 +45,8 @@ android {
|
|||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode 167
|
versionCode 172
|
||||||
versionName "2.18.2"
|
versionName "2.19.4"
|
||||||
|
|
||||||
/* Base URL of the update storage, trailing slash included */
|
/* Base URL of the update storage, trailing slash included */
|
||||||
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||||
@@ -74,6 +74,17 @@ android {
|
|||||||
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
||||||
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
||||||
|
|
||||||
|
/* SHX update API (web panel). When UPDATE_API_URL, UPDATE_API_PUBLIC_KEY and UPDATE_API_HMAC_KEY are all set,
|
||||||
|
the app checks for updates there; the bucket path above stays as a fallback while the API is unreachable.
|
||||||
|
UPDATE_API_PUBLIC_KEY is the raw Ed25519 public key (base64, 32 bytes) shown on the panel's "API updates" page;
|
||||||
|
the HMAC key is only a guard against casual requests (an APK can always be unpacked), integrity rests on the
|
||||||
|
manifest signature and on the SHA-256 plus APK signature checks. */
|
||||||
|
buildConfigField "String", "UPDATE_API_URL", "\"${envOr('UPDATE_API_URL', '')}\""
|
||||||
|
buildConfigField "String", "UPDATE_API_PUBLIC_KEY", "\"${envOr('UPDATE_API_PUBLIC_KEY', '')}\""
|
||||||
|
buildConfigField "String", "UPDATE_API_HMAC_KEY", "\"${xorB64(envOr('UPDATE_API_HMAC_KEY', ''))}\""
|
||||||
|
/* public download page, opened by «Download from the site» when an update fails */
|
||||||
|
buildConfigField "String", "UPDATE_DOWNLOAD_PAGE_URL", "\"${envOr('UPDATE_DOWNLOAD_PAGE_URL', 'https://get.shnt.top/get/universal')}\""
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
arguments '-j' + Runtime.runtime.availableProcessors()
|
arguments '-j' + Runtime.runtime.availableProcessors()
|
||||||
@@ -175,6 +186,8 @@ dependencies {
|
|||||||
implementation 'androidx.activity:activity-compose:1.11.0'
|
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||||
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
||||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
|
/* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */
|
||||||
|
implementation 'net.i2p.crypto:eddsa:0.3.0'
|
||||||
|
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
@@ -182,6 +195,8 @@ dependencies {
|
|||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.assertj:assertj-core:3.27.6'
|
testImplementation 'org.assertj:assertj-core:3.27.6'
|
||||||
testImplementation 'org.mockito:mockito-core:5.20.0'
|
testImplementation 'org.mockito:mockito-core:5.20.0'
|
||||||
|
/* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */
|
||||||
|
testImplementation 'org.json:json:20240303'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||||
@@ -189,3 +204,32 @@ dependencies {
|
|||||||
androidTestImplementation composeBom
|
androidTestImplementation composeBom
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step.
|
||||||
|
./gradlew uploadShx -> draft in the beta channel, type phone
|
||||||
|
./gradlew uploadShx -PshxPublish -PshxRollout=25
|
||||||
|
./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..."
|
||||||
|
Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens).
|
||||||
|
Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */
|
||||||
|
tasks.register('uploadShx', Exec) {
|
||||||
|
group = 'publishing'
|
||||||
|
description = 'Build the release APK and upload it to the SHX VPN panel'
|
||||||
|
dependsOn 'assembleRelease'
|
||||||
|
def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk')
|
||||||
|
def script = rootProject.file('scripts/upload-apk.mjs')
|
||||||
|
doFirst {
|
||||||
|
def apk = apkFile.get().asFile
|
||||||
|
if (!apk.exists()) {
|
||||||
|
throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)")
|
||||||
|
}
|
||||||
|
def args = ['node', script.absolutePath, apk.absolutePath,
|
||||||
|
'--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(),
|
||||||
|
'--channel', (findProperty('shxChannel') ?: 'beta').toString()]
|
||||||
|
def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString()
|
||||||
|
if (notes) args += ['--notes', notes]
|
||||||
|
if (project.hasProperty('shxPublish')) {
|
||||||
|
args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()]
|
||||||
|
}
|
||||||
|
commandLine args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,3 +64,6 @@
|
|||||||
-keep class * extends androidx.room.RoomDatabase {
|
-keep class * extends androidx.room.RoomDatabase {
|
||||||
<init>();
|
<init>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
|
||||||
|
-dontwarn sun.security.x509.**
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
@@ -339,6 +340,17 @@
|
|||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.BootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||||
|
import org.strongswan.android.logic.scenarios.ScenarioEngine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the automation up again after a reboot or a change of the clock.
|
||||||
|
*
|
||||||
|
* Starting the process runs the application's start-up (network rules, scenarios),
|
||||||
|
* but alarms do not survive a reboot, and a time or time zone change moves the
|
||||||
|
* edges of the scenario time windows. This receiver re-arms what the alarms
|
||||||
|
* carried and lets the rules decide again. It never starts the tunnel by itself:
|
||||||
|
* the rules and scenarios do, and a profile without a saved password stays off.
|
||||||
|
*/
|
||||||
|
class BootReceiver : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent?)
|
||||||
|
{
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
DiagLog.i(TAG, "system event: $action")
|
||||||
|
if (action == Intent.ACTION_BOOT_COMPLETED)
|
||||||
|
{
|
||||||
|
PauseManager.rearmAfterRestart(context)
|
||||||
|
}
|
||||||
|
/* start() re-arms the time-window alarm for the current clock and zone */
|
||||||
|
ScenarioEngine.start(context)
|
||||||
|
AutoConnectManager.getInstance(context).reevaluate()
|
||||||
|
val pending = goAsync()
|
||||||
|
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object
|
||||||
|
{
|
||||||
|
const val TAG = "BootReceiver"
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -269,7 +269,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
VpnProfile profile = loadProfile(uuid);
|
VpnProfile profile = loadProfile(uuid);
|
||||||
if (profile != null)
|
if (profile != null)
|
||||||
{
|
{
|
||||||
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD));
|
String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
|
||||||
|
if (password != null)
|
||||||
|
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
|
||||||
|
* password: keep the one saved with the profile instead of
|
||||||
|
* wiping it, or the tunnel never comes back («password unavailable») */
|
||||||
|
profile.setPassword(password);
|
||||||
|
}
|
||||||
/* a backup port of F_Profile, for this attempt only */
|
/* a backup port of F_Profile, for this attempt only */
|
||||||
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
||||||
if (port > 0)
|
if (port > 0)
|
||||||
|
|||||||
+18
-5
@@ -25,6 +25,7 @@ import android.net.Network;
|
|||||||
import android.net.NetworkRequest;
|
import android.net.NetworkRequest;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
|
|
||||||
public class NetworkManager extends BroadcastReceiver implements Runnable
|
public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||||
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
private volatile boolean mRegistered;
|
private volatile boolean mRegistered;
|
||||||
private ConnectivityManager.NetworkCallback mCallback;
|
private ConnectivityManager.NetworkCallback mCallback;
|
||||||
private Thread mEventNotifier;
|
private Thread mEventNotifier;
|
||||||
private int mConnectedNetworks = 0;
|
/* a set, not a counter: a repeated onAvailable or a missed onLost cannot
|
||||||
|
* make it drift away from the networks that really exist */
|
||||||
|
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
|
||||||
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
||||||
|
|
||||||
public NetworkManager(Context context)
|
public NetworkManager(Context context)
|
||||||
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
{
|
{
|
||||||
/* we expect this to be called if connected to at least one network during
|
/* we expect this to be called if connected to at least one network during
|
||||||
* callback registration */
|
* callback registration */
|
||||||
mConnectedNetworks += 1;
|
mConnectedNetworks.add(network);
|
||||||
mEvents.addLast(true);
|
mEvents.addLast(true);
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
synchronized (NetworkManager.this)
|
synchronized (NetworkManager.this)
|
||||||
{
|
{
|
||||||
/* in particular mobile connections are disconnected overlapping with WiFi */
|
/* in particular mobile connections are disconnected overlapping with WiFi */
|
||||||
mConnectedNetworks -= 1;
|
mConnectedNetworks.remove(network);
|
||||||
mEvents.addLast(mConnectedNetworks > 0);
|
mEvents.addLast(!mConnectedNetworks.isEmpty());
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
|
|
||||||
public void Register()
|
public void Register()
|
||||||
{
|
{
|
||||||
mEvents.clear();
|
synchronized (this)
|
||||||
|
{
|
||||||
|
mEvents.clear();
|
||||||
|
mConnectedNetworks.clear();
|
||||||
|
}
|
||||||
mRegistered = true;
|
mRegistered = true;
|
||||||
mEventNotifier = new Thread(this);
|
mEventNotifier = new Thread(this);
|
||||||
mEventNotifier.start();
|
mEventNotifier.start();
|
||||||
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
connected = mEvents.removeFirst();
|
connected = mEvents.removeFirst();
|
||||||
|
/* a burst of identical states (several networks coming up at once)
|
||||||
|
* is one change for charon, not one reconnect attempt each */
|
||||||
|
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
|
||||||
|
{
|
||||||
|
mEvents.removeFirst();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/* call the native parts without holding the lock */
|
/* call the native parts without holding the lock */
|
||||||
networkChanged(!connected);
|
networkChanged(!connected);
|
||||||
|
|||||||
@@ -229,6 +229,32 @@ object PauseManager
|
|||||||
alarmManager(ctx).cancel(alarmIntent(ctx))
|
alarmManager(ctx).cancel(alarmIntent(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a reboot: alarms do not survive it, so a pause that was running would
|
||||||
|
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun rearmAfterRestart(context: Context)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
val until = prefs.getLong(PREF_UNTIL, 0L)
|
||||||
|
val profile = prefs.getString(PREF_PROFILE, null)
|
||||||
|
if (profile == null || until == 0L)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (until > System.currentTimeMillis())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
|
||||||
|
store(ctx, until, profile)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onAlarm(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun store(ctx: Context, until: Long, profileUuid: String)
|
private fun store(ctx: Context, until: Long, profileUuid: String)
|
||||||
{
|
{
|
||||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import java.util.PriorityQueue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
|
||||||
|
* bookkeeping of the single alarm that wakes the device for the earliest one.
|
||||||
|
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
|
||||||
|
*
|
||||||
|
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
|
||||||
|
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
|
||||||
|
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
|
||||||
|
* allow-while-idle alarms that the keepalive needs.
|
||||||
|
*
|
||||||
|
* Not thread-safe; the caller synchronises.
|
||||||
|
*/
|
||||||
|
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
|
||||||
|
{
|
||||||
|
private class Job(val id: String, val time: Long) : Comparable<Job>
|
||||||
|
{
|
||||||
|
override fun compareTo(other: Job): Int = time.compareTo(other.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val jobs = PriorityQueue<Job>()
|
||||||
|
private var armed = NONE
|
||||||
|
|
||||||
|
/** Due time the alarm is currently set for, or [NONE]. */
|
||||||
|
val armedAt: Long get() = armed
|
||||||
|
|
||||||
|
val size: Int get() = jobs.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a job.
|
||||||
|
*
|
||||||
|
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
|
||||||
|
* armed alarm already covers the job
|
||||||
|
*/
|
||||||
|
fun add(id: String, time: Long): Boolean
|
||||||
|
{
|
||||||
|
jobs.add(Job(id, time))
|
||||||
|
if (armed == NONE || time < armed - coalesceMs)
|
||||||
|
{
|
||||||
|
armed = time
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the jobs that are due at [now] (or within the coalescing window).
|
||||||
|
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
|
||||||
|
*/
|
||||||
|
fun popDue(now: Long): List<String>
|
||||||
|
{
|
||||||
|
val limit = now + coalesceMs
|
||||||
|
val due = ArrayList<String>()
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val head = jobs.peek() ?: break
|
||||||
|
if (head.time > limit)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
due.add(jobs.remove().id)
|
||||||
|
}
|
||||||
|
armed = jobs.peek()?.time ?: NONE
|
||||||
|
return due
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
|
||||||
|
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
|
||||||
|
|
||||||
|
fun clear()
|
||||||
|
{
|
||||||
|
jobs.clear()
|
||||||
|
armed = NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
const val NONE = -1L
|
||||||
|
const val COALESCE_MS = 2000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-51
@@ -24,10 +24,10 @@ import android.content.Context;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.IntentFilter;
|
import android.content.IntentFilter;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.List;
|
||||||
import java.util.PriorityQueue;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
private final String TAG = Scheduler.class.getSimpleName();
|
private final String TAG = Scheduler.class.getSimpleName();
|
||||||
private final Context mContext;
|
private final Context mContext;
|
||||||
private final AlarmManager mManager;
|
private final AlarmManager mManager;
|
||||||
private final PriorityQueue<ScheduledJob> mJobs;
|
/* due times are elapsedRealtime: unlike RTC they do not jump when the clock
|
||||||
|
* is set (NTP, manual change, time zone), which would delay keepalives and
|
||||||
|
* rekeyings by hours */
|
||||||
|
private final ScheduledJobQueue mJobs;
|
||||||
|
private boolean mExactLogged;
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
public Scheduler(Context context)
|
public Scheduler(Context context)
|
||||||
{
|
{
|
||||||
mContext = context;
|
mContext = context;
|
||||||
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||||
mJobs = new PriorityQueue<>();
|
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
|
||||||
|
|
||||||
IntentFilter filter = new IntentFilter();
|
IntentFilter filter = new IntentFilter();
|
||||||
filter.addAction(EXECUTE_JOB);
|
filter.addAction(EXECUTE_JOB);
|
||||||
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* alarms instead of crashing with a SecurityException (connectivity then
|
* alarms instead of crashing with a SecurityException (connectivity then
|
||||||
* only degrades slightly while timers may be delayed in doze mode).
|
* only degrades slightly while timers may be delayed in doze mode).
|
||||||
*
|
*
|
||||||
* @param time wakeup time based on RTC
|
* @param time wakeup time based on elapsedRealtime
|
||||||
*/
|
*/
|
||||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||||
private void scheduleAlarm(long time)
|
private void scheduleAlarm(long time)
|
||||||
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
exact = mManager.canScheduleExactAlarms();
|
exact = mManager.canScheduleExactAlarms();
|
||||||
}
|
}
|
||||||
|
if (!mExactLogged)
|
||||||
|
{ /* once per run, for support reports */
|
||||||
|
mExactLogged = true;
|
||||||
|
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (exact)
|
if (exact)
|
||||||
{
|
{
|
||||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (SecurityException e)
|
catch (SecurityException e)
|
||||||
{ /* permission might have been revoked, or OEM restrictions apply */
|
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||||
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
|
long time = SystemClock.elapsedRealtime() + ms;
|
||||||
mJobs.add(job);
|
if (mJobs.add(id, time))
|
||||||
|
{ /* the job is due before the armed alarm: move the alarm */
|
||||||
if (job == mJobs.peek())
|
scheduleAlarm(time);
|
||||||
{ /* update the alarm if the job has to be executed before all others */
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
@Override
|
@Override
|
||||||
public void onReceive(Context context, Intent intent)
|
public void onReceive(Context context, Intent intent)
|
||||||
{
|
{
|
||||||
ArrayList<ScheduledJob> jobs = new ArrayList<>();
|
List<String> jobs;
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = mJobs.peek();
|
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
|
||||||
while (job != null)
|
long next = mJobs.nextAlarm();
|
||||||
|
if (next != ScheduledJobQueue.NONE)
|
||||||
{
|
{
|
||||||
if (job.Time > now)
|
scheduleAlarm(next);
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
jobs.add(mJobs.remove());
|
|
||||||
job = mJobs.peek();
|
|
||||||
}
|
|
||||||
if (job != null)
|
|
||||||
{
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ScheduledJob job : jobs)
|
for (String id : jobs)
|
||||||
{
|
{
|
||||||
executeJob(job.Id);
|
executeJob(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* @param id job ID
|
* @param id job ID
|
||||||
*/
|
*/
|
||||||
public native void executeJob(String id);
|
public native void executeJob(String id);
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep track of scheduled jobs.
|
|
||||||
*/
|
|
||||||
private static class ScheduledJob implements Comparable<ScheduledJob>
|
|
||||||
{
|
|
||||||
String Id;
|
|
||||||
long Time;
|
|
||||||
|
|
||||||
ScheduledJob(String id, long time)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Time = time;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int compareTo(ScheduledJob o)
|
|
||||||
{
|
|
||||||
return Long.compare(Time, o.Time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+5
@@ -232,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
return;
|
return;
|
||||||
case RESTART:
|
case RESTART:
|
||||||
{
|
{
|
||||||
|
boolean autoRestart = uuid == null;
|
||||||
if (uuid == null)
|
if (uuid == null)
|
||||||
{
|
{
|
||||||
uuid = resolveAutoProfile(context, prefs);
|
uuid = resolveAutoProfile(context, prefs);
|
||||||
@@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
* once, which is harmless (retry flag set, same profile). */
|
* once, which is harmless (retry flag set, same profile). */
|
||||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||||
DiagLog.i(TAG, "restarting the VPN process");
|
DiagLog.i(TAG, "restarting the VPN process");
|
||||||
|
if (autoRestart)
|
||||||
|
{ /* this process does not remember the session was auto-started */
|
||||||
|
AutoConnectManager.getInstance(context).markAutoSessionRestored();
|
||||||
|
}
|
||||||
Intent start = new Intent(context, CharonVpnService.class);
|
Intent start = new Intent(context, CharonVpnService.class);
|
||||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||||
|
|||||||
+42
-6
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
|
|||||||
private var currentNetwork: NetworkKind? = null
|
private var currentNetwork: NetworkKind? = null
|
||||||
/* marks the session this manager started itself */
|
/* marks the session this manager started itself */
|
||||||
private val autoSession = AtomicBoolean(false)
|
private val autoSession = AtomicBoolean(false)
|
||||||
|
/* an auto session was restored by the watchdog and its tunnel is not up yet */
|
||||||
|
@Volatile private var awaitingRestoredTunnel = false
|
||||||
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||||
private var lastParkAt = 0L
|
private var lastParkAt = 0L
|
||||||
|
|
||||||
@@ -123,10 +125,28 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Network scenarios need the network events even when the network rules are off. */
|
||||||
|
private fun scenariosNeedNetwork(): Boolean =
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
|
||||||
|
|
||||||
|
/** Called when the scenarios changed: starts or stops the network listening to fit. */
|
||||||
|
fun scenariosChanged()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (config.enabled || scenariosNeedNetwork())
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun start()
|
fun start()
|
||||||
{
|
{
|
||||||
config = loadConfig()
|
config = loadConfig()
|
||||||
if (!config.enabled)
|
if (!config.enabled && !scenariosNeedNetwork())
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
|
|||||||
override fun stateChanged()
|
override fun stateChanged()
|
||||||
{
|
{
|
||||||
val state = service?.state ?: return
|
val state = service?.state ?: return
|
||||||
if (state == VpnStateService.State.DISABLED)
|
if (state != VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = false
|
||||||
|
}
|
||||||
|
else if (!awaitingRestoredTunnel)
|
||||||
{
|
{
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watchdog brought an auto session back in a fresh process, whose memory
|
||||||
|
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
|
||||||
|
* Wi-Fi instead of running all night; the DISABLED state of the not yet
|
||||||
|
* started service must not clear the mark.
|
||||||
|
*/
|
||||||
|
fun markAutoSessionRestored()
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = true
|
||||||
|
autoSession.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure decision function, unit-testable.
|
* Pure decision function, unit-testable.
|
||||||
*/
|
*/
|
||||||
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
|
|||||||
|
|
||||||
private fun evaluate()
|
private fun evaluate()
|
||||||
{
|
{
|
||||||
if (!running.get())
|
if (!running.get() || !config.enabled)
|
||||||
{
|
{ /* running only for the scenarios: they decide, not the network rules */
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val svc = service ?: return
|
val svc = service ?: return
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ object Events
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A report went to the panel: the code stays here so the user can find it later. */
|
||||||
|
@JvmStatic
|
||||||
|
fun reportSent(context: Context, code: String)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code),
|
||||||
|
context.getString(R.string.report_event_text), key = "report:$code",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The watchdog did not bring back [name]: a password would be needed. */
|
/** The watchdog did not bring back [name]: a password would be needed. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
||||||
|
|||||||
+66
-4
@@ -25,10 +25,12 @@ import android.content.IntentFilter
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
@@ -162,6 +164,9 @@ object ScenarioEngine
|
|||||||
private const val TAG = "Scenarios"
|
private const val TAG = "Scenarios"
|
||||||
private const val FILE_NAME = "scenarios.json"
|
private const val FILE_NAME = "scenarios.json"
|
||||||
private const val POLL_MS = 4000L
|
private const val POLL_MS = 4000L
|
||||||
|
/* how long a broadcast waits for the VPN state service of a fresh process */
|
||||||
|
private const val SERVICE_WAIT_MS = 8000L
|
||||||
|
private const val SERVICE_POLL_MS = 200L
|
||||||
/* the foreground app did not change for a minute: poll half as often */
|
/* the foreground app did not change for a minute: poll half as often */
|
||||||
private const val SLOW_AFTER_POLLS = 15
|
private const val SLOW_AFTER_POLLS = 15
|
||||||
private const val SLOW_POLL_MS = 8000L
|
private const val SLOW_POLL_MS = 8000L
|
||||||
@@ -193,9 +198,20 @@ object ScenarioEngine
|
|||||||
_list.value = list
|
_list.value = list
|
||||||
persist(context)
|
persist(context)
|
||||||
start(context)
|
start(context)
|
||||||
|
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
|
||||||
evaluate(context)
|
evaluate(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
|
||||||
|
@JvmStatic
|
||||||
|
fun needsNetworkEvents(context: Context): Boolean
|
||||||
|
{
|
||||||
|
load(context.applicationContext)
|
||||||
|
return _list.value.any {
|
||||||
|
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Starts listening (from the application); idempotent. */
|
/** Starts listening (from the application); idempotent. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun start(context: Context)
|
fun start(context: Context)
|
||||||
@@ -289,6 +305,39 @@ object ScenarioEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides again once the VPN state service exists. A broadcast that starts a
|
||||||
|
* fresh process arrives before the managers bound the service; evaluating
|
||||||
|
* then would silently drop the event. Gives up after [SERVICE_WAIT_MS]
|
||||||
|
* (the next network or alarm event evaluates again) and always calls [done].
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun evaluateWhenReady(context: Context, done: Runnable?)
|
||||||
|
{
|
||||||
|
val app = context.applicationContext
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS
|
||||||
|
val check = object : Runnable
|
||||||
|
{
|
||||||
|
override fun run()
|
||||||
|
{
|
||||||
|
if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline)
|
||||||
|
{
|
||||||
|
handler.postDelayed(this, SERVICE_POLL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluate(app)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
done?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.post(check)
|
||||||
|
}
|
||||||
|
|
||||||
/** Network, whitelist or alarm: decide again. */
|
/** Network, whitelist or alarm: decide again. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun evaluate(context: Context)
|
fun evaluate(context: Context)
|
||||||
@@ -431,10 +480,12 @@ object ScenarioEngine
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
/* without a live service nothing can be put back: keep the record so the
|
||||||
|
* next evaluation (the receiver waits for the service) still restores */
|
||||||
|
val service = VpnStateService.getLiveInstance() ?: return
|
||||||
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
||||||
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
||||||
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||||
val service = VpnStateService.getLiveInstance() ?: return
|
|
||||||
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
||||||
if (PauseManager.isPaused(context))
|
if (PauseManager.isPaused(context))
|
||||||
{
|
{
|
||||||
@@ -562,12 +613,22 @@ object ScenarioEngine
|
|||||||
{
|
{
|
||||||
c.add(Calendar.DAY_OF_YEAR, 1)
|
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
}
|
}
|
||||||
|
/* same choice as PauseManager: exact when the grant allows it */
|
||||||
|
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
if (exact)
|
||||||
|
{
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (_: SecurityException)
|
catch (_: SecurityException)
|
||||||
{
|
{ /* revoked in between: an inexact alarm is still better than none */
|
||||||
|
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
|
|||||||
override fun onReceive(context: Context, intent: Intent?)
|
override fun onReceive(context: Context, intent: Intent?)
|
||||||
{
|
{
|
||||||
ScenarioEngine.start(context)
|
ScenarioEngine.start(context)
|
||||||
ScenarioEngine.evaluate(context)
|
val pending = goAsync()
|
||||||
|
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+54
-5
@@ -15,6 +15,8 @@
|
|||||||
package org.strongswan.android.logic.stats
|
package org.strongswan.android.logic.stats
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
||||||
* counters are sampled every [SAMPLE_S] seconds and the growth is added to
|
* counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
|
||||||
|
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
|
||||||
* the current day; the counters restart with every connection, so a drop
|
* the current day; the counters restart with every connection, so a drop
|
||||||
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
||||||
*/
|
*/
|
||||||
@@ -46,12 +49,21 @@ object TrafficHistory
|
|||||||
private const val TAG = "TrafficHistory"
|
private const val TAG = "TrafficHistory"
|
||||||
private const val FILE_NAME = "traffic.json"
|
private const val FILE_NAME = "traffic.json"
|
||||||
private const val SAMPLE_S = 15L
|
private const val SAMPLE_S = 15L
|
||||||
|
private const val SAMPLE_OFF_S = 60L
|
||||||
|
/* the history lives in memory and is written out this often (not on every
|
||||||
|
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
|
||||||
|
* killed process loses at most this much of the statistics */
|
||||||
|
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
|
||||||
private const val KEEP_DAYS = 120
|
private const val KEEP_DAYS = 120
|
||||||
|
|
||||||
private val executor = Executors.newSingleThreadScheduledExecutor()
|
private val executor = Executors.newSingleThreadScheduledExecutor()
|
||||||
private var task: ScheduledFuture<*>? = null
|
private var task: ScheduledFuture<*>? = null
|
||||||
private var lastVpn = -1L
|
private var lastVpn = -1L
|
||||||
private var lastDirect = -1L
|
private var lastDirect = -1L
|
||||||
|
private var lastSampleAt = 0L
|
||||||
|
private var dirty = false
|
||||||
|
private var lastSavedAt = 0L
|
||||||
|
private var lastSavedDay: String? = null
|
||||||
@Volatile private var loaded = false
|
@Volatile private var loaded = false
|
||||||
private val days = LinkedHashMap<String, LongArray>()
|
private val days = LinkedHashMap<String, LongArray>()
|
||||||
private var firstDay: String? = null
|
private var firstDay: String? = null
|
||||||
@@ -86,11 +98,38 @@ object TrafficHistory
|
|||||||
val running = task ?: return
|
val running = task ?: return
|
||||||
running.cancel(false)
|
running.cancel(false)
|
||||||
task = null
|
task = null
|
||||||
executor.execute { sample(app) }
|
executor.execute {
|
||||||
|
sample(app, force = true)
|
||||||
|
flush(app)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sample(context: Context)
|
/** Writes the history out now if it changed since the last write. */
|
||||||
|
@Synchronized
|
||||||
|
fun flush(context: Context)
|
||||||
{
|
{
|
||||||
|
if (dirty)
|
||||||
|
{
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a changed history has to be written now: periodically and when the day turns. */
|
||||||
|
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
|
||||||
|
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
|
||||||
|
|
||||||
|
private fun screenOn(context: Context): Boolean =
|
||||||
|
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
|
||||||
|
|
||||||
|
private fun sample(context: Context, force: Boolean = false)
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val screen = screenOn(context)
|
||||||
|
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
|
||||||
|
{ /* screen off: a sample a minute is enough, the counters accumulate */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSampleAt = now
|
||||||
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
||||||
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
||||||
/* direct bytes only exist with the userspace router (s[3]) */
|
/* direct bytes only exist with the userspace router (s[3]) */
|
||||||
@@ -121,9 +160,16 @@ object TrafficHistory
|
|||||||
firstDay = key
|
firstDay = key
|
||||||
}
|
}
|
||||||
prune()
|
prune()
|
||||||
save(context)
|
dirty = true
|
||||||
|
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
|
||||||
|
{
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
_changes.value = System.currentTimeMillis()
|
_changes.value = System.currentTimeMillis()
|
||||||
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
if (screenOn(context))
|
||||||
|
{ /* nobody sees the widget with the screen off */
|
||||||
|
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
||||||
@@ -205,6 +251,9 @@ object TrafficHistory
|
|||||||
file.writeText(json.toString())
|
file.writeText(json.toString())
|
||||||
tmp.delete()
|
tmp.delete()
|
||||||
}
|
}
|
||||||
|
dirty = false
|
||||||
|
lastSavedAt = SystemClock.elapsedRealtime()
|
||||||
|
lastSavedDay = dayKey(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
catch (e: Exception)
|
catch (e: Exception)
|
||||||
{
|
{
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What goes into a report sent from the log screen: today's part of charon.log and,
|
||||||
|
* on request, the app's diag.log. No Android types here, so it runs in unit tests.
|
||||||
|
*/
|
||||||
|
object ReportContent
|
||||||
|
{
|
||||||
|
/* the panel unpacks a report with a 5 MB cap; 3 + 0.75 MB of text leaves room and stays far below 2 MB gzip */
|
||||||
|
const val CHARON_MAX_BYTES = 3 * 1024 * 1024
|
||||||
|
const val DIAG_MAX_BYTES = 768 * 1024
|
||||||
|
const val CUT_MARK = "... (earlier lines cut)\n"
|
||||||
|
|
||||||
|
/** Why a report was not sent; each maps to a reason line of the "not sent" sheet */
|
||||||
|
enum class Failure { NO_NETWORK, SERVER_DOWN, SIGNATURE, TOO_OFTEN, TOO_LARGE, REJECTED }
|
||||||
|
|
||||||
|
fun failure(httpCode: Int): Failure = when
|
||||||
|
{
|
||||||
|
httpCode == 0 -> Failure.NO_NETWORK
|
||||||
|
httpCode == 401 -> Failure.SIGNATURE
|
||||||
|
httpCode == 413 -> Failure.TOO_LARGE
|
||||||
|
httpCode == 429 -> Failure.TOO_OFTEN
|
||||||
|
httpCode >= 500 || httpCode == 404 -> Failure.SERVER_DOWN
|
||||||
|
else -> Failure.REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
/* charon: «Nov 8 14:02:11 06[IKE] ...» (strftime %b %e, C locale) */
|
||||||
|
private val CHARON_DATE = Regex("""^(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines of [month] (1-12) / [day] only; lines without a date stay with the dated line before them.
|
||||||
|
* When nothing is from today the whole text is kept: an old log still helps support.
|
||||||
|
*/
|
||||||
|
fun todayOnly(text: String, month: Int, day: Int): String
|
||||||
|
{
|
||||||
|
var keep = true
|
||||||
|
var today = false
|
||||||
|
val out = StringBuilder()
|
||||||
|
for (line in text.lineSequence())
|
||||||
|
{
|
||||||
|
CHARON_DATE.find(line)?.let { m ->
|
||||||
|
keep = MONTHS.indexOf(m.groupValues[1]) + 1 == month && m.groupValues[2].toInt() == day
|
||||||
|
today = today || keep
|
||||||
|
}
|
||||||
|
if (keep && line.isNotEmpty())
|
||||||
|
{
|
||||||
|
out.append(line).append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* undated lines alone (e.g. the cut mark of readTail) do not count as today's log */
|
||||||
|
return if (today) out.toString() else text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last [maxBytes] of UTF-8 [text], cut at a line start; the cut is marked */
|
||||||
|
fun tailBytes(text: String, maxBytes: Int): String
|
||||||
|
{
|
||||||
|
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||||
|
if (bytes.size <= maxBytes)
|
||||||
|
{
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
var start = bytes.size - maxBytes
|
||||||
|
/* '\n' is never part of a multi-byte UTF-8 sequence, so cutting after it keeps characters whole */
|
||||||
|
while (start < bytes.size && bytes[start - 1] != '\n'.code.toByte())
|
||||||
|
{
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
if (start == bytes.size)
|
||||||
|
{ /* one huge line: cut anywhere, but not inside a character (skip 10xxxxxx continuation bytes) */
|
||||||
|
start = bytes.size - maxBytes
|
||||||
|
while (start < bytes.size && (bytes[start].toInt() and 0xC0) == 0x80)
|
||||||
|
{
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CUT_MARK + String(bytes, start, bytes.size - start, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to [maxBytes] from the end of [file] ("" when missing); a partial first line is dropped */
|
||||||
|
fun readTail(file: File, maxBytes: Int): String
|
||||||
|
{
|
||||||
|
if (!file.exists())
|
||||||
|
{
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
RandomAccessFile(file, "r").use { f ->
|
||||||
|
val from = maxOf(0L, f.length() - maxBytes)
|
||||||
|
val buf = ByteArray((f.length() - from).toInt())
|
||||||
|
f.seek(from)
|
||||||
|
f.readFully(buf)
|
||||||
|
val text = String(buf, Charsets.UTF_8)
|
||||||
|
if (from > 0) CUT_MARK + text.substringAfter('\n') else text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One network check of the diagnostics screen: the panel shows them as a check list */
|
||||||
|
data class Check(val title: String, val value: String, val ok: Boolean?)
|
||||||
|
|
||||||
|
/** JSON array of the checks (no org.json: this object stays testable without Android) */
|
||||||
|
fun checksJson(checks: List<Check>): String = checks.joinToString(",", "[", "]") { c ->
|
||||||
|
"{\"title\":${jsonString(c.title)},\"value\":${jsonString(c.value)},\"ok\":${c.ok?.toString() ?: "null"}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonString(s: String): String = buildString {
|
||||||
|
append('"')
|
||||||
|
for (ch in s)
|
||||||
|
{
|
||||||
|
when
|
||||||
|
{
|
||||||
|
ch == '"' -> append("\\\"")
|
||||||
|
ch == '\\' -> append("\\\\")
|
||||||
|
ch == '\n' -> append("\\n")
|
||||||
|
ch == '\r' -> append("\\r")
|
||||||
|
ch == '\t' -> append("\\t")
|
||||||
|
ch < ' ' -> append("\\u%04x".format(ch.code))
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append('"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report text: the connection log, the network checks and diag.log when asked for */
|
||||||
|
fun build(charon: String, diag: String?, checks: List<Check>? = null): String = buildString {
|
||||||
|
append("=== charon.log ===\n")
|
||||||
|
append(tailBytes(charon, CHARON_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||||
|
if (!checks.isNullOrEmpty())
|
||||||
|
{
|
||||||
|
append("\n=== checks.json ===\n")
|
||||||
|
append(checksJson(checks)).append('\n')
|
||||||
|
}
|
||||||
|
if (diag != null)
|
||||||
|
{
|
||||||
|
append("\n=== diag.log ===\n")
|
||||||
|
append(tailBytes(diag, DIAG_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meta of a report from the log screen; [network] is "Wi-Fi", "mobile" or "none" */
|
||||||
|
fun meta(
|
||||||
|
versionName: String, versionCode: Long, manufacturer: String, model: String, release: String, sdk: Int,
|
||||||
|
network: String, comment: String, profile: String,
|
||||||
|
) = ReportUploader.Meta(
|
||||||
|
versionName = versionName,
|
||||||
|
versionCode = versionCode,
|
||||||
|
device = if (model.startsWith(manufacturer, ignoreCase = true)) model else "$manufacturer $model",
|
||||||
|
os = "Android $release (API $sdk)",
|
||||||
|
network = network,
|
||||||
|
source = "log",
|
||||||
|
comment = comment.trim(),
|
||||||
|
profile = profile,
|
||||||
|
)
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a diagnostic log to the web panel (POST /api/v1/reports) and returns the report
|
||||||
|
* code the user can quote to support. The request is signed like the update check.
|
||||||
|
* The "Send report" sheet of the log screen (LogActivity) calls it, see [ReportContent].
|
||||||
|
*/
|
||||||
|
class ReportUploader(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val hmacKey: String,
|
||||||
|
private val installId: String,
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
data class Meta(
|
||||||
|
val versionName: String,
|
||||||
|
val versionCode: Long,
|
||||||
|
val device: String,
|
||||||
|
val os: String,
|
||||||
|
val network: String,
|
||||||
|
val source: String,
|
||||||
|
val comment: String,
|
||||||
|
val profile: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class Result
|
||||||
|
{
|
||||||
|
data class Sent(val code: String) : Result()
|
||||||
|
data class Failed(val httpCode: Int, val detail: String) : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** Server limit: the whole request body must stay below 2 MB */
|
||||||
|
const val MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
const val BOUNDARY = "----shxreport"
|
||||||
|
private val CODE = Regex("^SHX-[0-9A-Z]{5}$")
|
||||||
|
|
||||||
|
/** The panel serves the app API under /api/v1 (the proxy routes only that prefix); the build may give just the host */
|
||||||
|
fun apiBase(url: String): String
|
||||||
|
{
|
||||||
|
val base = url.trim().trimEnd('/')
|
||||||
|
return if (base.endsWith("/api/v1")) base else "$base/api/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 201 with {"code":"SHX-XXXXX"} is the only success; a broken 201 is not "no network" */
|
||||||
|
fun parseResponse(httpCode: Int, body: String): Result
|
||||||
|
{
|
||||||
|
if (httpCode != 201)
|
||||||
|
{
|
||||||
|
return Result.Failed(httpCode, body.take(200))
|
||||||
|
}
|
||||||
|
val code = try { JSONObject(body).optString("code", "") } catch (e: Exception) { "" }
|
||||||
|
return if (CODE.matches(code)) Result.Sent(code) else Result.Failed(httpCode, "bad response: ${body.take(100)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gzip(text: String): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun metaJson(m: Meta): String = JSONObject()
|
||||||
|
.put("versionName", m.versionName).put("versionCode", m.versionCode)
|
||||||
|
.put("device", m.device.take(120)).put("os", m.os.take(60)).put("network", m.network.take(40))
|
||||||
|
.put("source", m.source).put("comment", m.comment.take(1000)).put("profile", m.profile.take(200))
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
/** multipart/form-data with the gzip log and the meta JSON */
|
||||||
|
fun multipartBody(logGz: ByteArray, meta: String): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
fun w(s: String) = out.write(s.toByteArray(Charsets.UTF_8))
|
||||||
|
w("--$BOUNDARY\r\nContent-Disposition: form-data; name=\"log\"; filename=\"log.gz\"\r\nContent-Type: application/gzip\r\n\r\n")
|
||||||
|
out.write(logGz)
|
||||||
|
/* a plain text field: @fastify/multipart turns an application/json field into an object and the server then reads "[object Object]" */
|
||||||
|
w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"meta\"\r\n\r\n")
|
||||||
|
w(meta)
|
||||||
|
w("\r\n--$BOUNDARY--\r\n")
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(logText: String, meta: Meta): Result
|
||||||
|
{
|
||||||
|
val body = multipartBody(gzip(logText), metaJson(meta))
|
||||||
|
if (body.size > MAX_BYTES)
|
||||||
|
{
|
||||||
|
return Result.Failed(413, "report is larger than 2 MB")
|
||||||
|
}
|
||||||
|
val url = apiBase(baseUrl) + "/reports"
|
||||||
|
val ts = clock() / 1000
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
conn.requestMethod = "POST"
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY")
|
||||||
|
conn.setRequestProperty("X-Install-Id", installId)
|
||||||
|
conn.setRequestProperty("X-Timestamp", ts.toString())
|
||||||
|
conn.setRequestProperty("X-Signature", UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("POST", URL(url).path, ts)))
|
||||||
|
conn.outputStream.use { it.write(body) }
|
||||||
|
val code = conn.responseCode
|
||||||
|
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes().decodeToString() } ?: ""
|
||||||
|
parseResponse(code, text)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Result.Failed(0, e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAEngine
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
||||||
|
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
|
||||||
|
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client of the SHX update API (/api/v1/updates/check).
|
||||||
|
*
|
||||||
|
* The wire contract is frozen in the web panel repository: docs/openapi.v1.json
|
||||||
|
* and packages/contracts/vectors/signing.json. [UpdateSigning] builds exactly
|
||||||
|
* the strings the server signs; UpdateApiTest checks it against those vectors.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
object UpdateSigning
|
||||||
|
{
|
||||||
|
const val SIGNING_VERSION = "shx-manifest-v1"
|
||||||
|
|
||||||
|
/** The fields covered by the manifest signature (notes are display-only and not signed) */
|
||||||
|
data class Fields(
|
||||||
|
val update: Boolean,
|
||||||
|
val mandatory: Boolean,
|
||||||
|
val versionCode: Long,
|
||||||
|
val versionName: String,
|
||||||
|
val sha256: String,
|
||||||
|
val size: Long,
|
||||||
|
val apkUrl: String,
|
||||||
|
val issuedAt: Long,
|
||||||
|
val checkHours: Int,
|
||||||
|
val wifiOnly: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fields joined by '\n' in a strict order; absent values are 0 / empty (same as the server) */
|
||||||
|
fun manifestInput(f: Fields): String
|
||||||
|
{
|
||||||
|
return listOf(
|
||||||
|
SIGNING_VERSION,
|
||||||
|
if (f.update) "1" else "0",
|
||||||
|
if (f.mandatory) "1" else "0",
|
||||||
|
f.versionCode.toString(),
|
||||||
|
f.versionName,
|
||||||
|
f.sha256,
|
||||||
|
f.size.toString(),
|
||||||
|
f.apkUrl,
|
||||||
|
f.issuedAt.toString(),
|
||||||
|
f.checkHours.toString(),
|
||||||
|
if (f.wifiOnly) "1" else "0",
|
||||||
|
).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestInput(method: String, path: String, timestamp: Long): String
|
||||||
|
{
|
||||||
|
return "${method.uppercase()}|$path|$timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hmacBase64(key: String, input: String): String
|
||||||
|
{
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
||||||
|
return Base64.Default.encode(mac.doFinal(input.toByteArray(Charsets.UTF_8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ed25519 verification on API 24+ (java.security Ed25519 exists only from API 33),
|
||||||
|
* therefore a small pure-Java implementation is used
|
||||||
|
*/
|
||||||
|
fun verifyEd25519(publicKeyRaw: ByteArray, input: String, signatureBase64: String): Boolean
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val spec = EdDSAPublicKeySpec(publicKeyRaw, EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519))
|
||||||
|
val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512"))
|
||||||
|
engine.initVerify(EdDSAPublicKey(spec))
|
||||||
|
engine.update(input.toByteArray(Charsets.UTF_8))
|
||||||
|
engine.verify(Base64.Default.decode(signatureBase64))
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the HTTP is delegated to, so the client can be tested without a network */
|
||||||
|
interface UpdateHttp
|
||||||
|
{
|
||||||
|
data class Response(val code: Int, val body: String)
|
||||||
|
|
||||||
|
fun get(url: String, headers: Map<String, String>): Response
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp
|
||||||
|
{
|
||||||
|
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
|
||||||
|
{
|
||||||
|
val conn = URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection
|
||||||
|
conn.connectTimeout = timeoutMs
|
||||||
|
conn.readTimeout = timeoutMs
|
||||||
|
conn.setRequestProperty("Connection", "close")
|
||||||
|
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||||
|
for ((k, v) in headers)
|
||||||
|
{
|
||||||
|
conn.setRequestProperty(k, v)
|
||||||
|
}
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val code = conn.responseCode
|
||||||
|
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
UpdateHttp.Response(code, stream?.use { it.readBytes().decodeToString() } ?: "")
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Policy delivered inside the signed manifest; the user's own settings stay the default */
|
||||||
|
data class UpdatePolicy(val checkHours: Int, val wifiOnly: Boolean)
|
||||||
|
|
||||||
|
sealed class UpdateApiResult
|
||||||
|
{
|
||||||
|
data class Update(val info: UpdateInfo, val mandatory: Boolean, val policy: UpdatePolicy, val issuedAt: Long) : UpdateApiResult()
|
||||||
|
|
||||||
|
data class NoUpdate(val policy: UpdatePolicy) : UpdateApiResult()
|
||||||
|
|
||||||
|
/** [kind]: NETWORK (try the legacy path), SIGNATURE (never fall back), OTHER (rate limit, bad request) */
|
||||||
|
data class Failure(val kind: UpdateError, val detail: String) : UpdateApiResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class UpdateApiClient(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val hmacKey: String,
|
||||||
|
private val publicKeyBase64: String,
|
||||||
|
private val installId: String,
|
||||||
|
private val http: UpdateHttp = HttpUrlConnectionHttp(),
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** The manifest is rejected when its issue time is far from the device clock (replay of a stale "no update") */
|
||||||
|
const val MAX_SKEW_SECONDS = 6 * 3600L
|
||||||
|
|
||||||
|
fun isConfigured(baseUrl: String, hmacKey: String, publicKey: String): Boolean
|
||||||
|
{
|
||||||
|
return baseUrl.isNotBlank() && hmacKey.isNotBlank() && publicKey.isNotBlank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun check(packageId: String, versionCode: Long, channel: String, abi: String?, sdk: Int, betaToken: String?): UpdateApiResult
|
||||||
|
{
|
||||||
|
val path = URL(ReportUploader.apiBase(baseUrl) + "/updates/check").path
|
||||||
|
val query = buildString {
|
||||||
|
append("package=").append(enc(packageId))
|
||||||
|
append("&versionCode=").append(versionCode)
|
||||||
|
append("&channel=").append(enc(channel))
|
||||||
|
if (!abi.isNullOrBlank()) append("&abi=").append(enc(abi))
|
||||||
|
append("&sdk=").append(sdk)
|
||||||
|
if (!betaToken.isNullOrBlank()) append("&betaToken=").append(enc(betaToken))
|
||||||
|
}
|
||||||
|
val ts = clock() / 1000
|
||||||
|
val headers = mapOf(
|
||||||
|
"X-Install-Id" to installId,
|
||||||
|
"X-Timestamp" to ts.toString(),
|
||||||
|
"X-Signature" to UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", path, ts)),
|
||||||
|
)
|
||||||
|
val response = try
|
||||||
|
{
|
||||||
|
http.get(ReportUploader.apiBase(baseUrl) + "/updates/check?" + query, headers)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.NETWORK, e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
response.code in 500..599 || response.code == 404 -> UpdateApiResult.Failure(UpdateError.NETWORK, "HTTP ${response.code}")
|
||||||
|
response.code != 200 -> UpdateApiResult.Failure(UpdateError.OTHER, "HTTP ${response.code}")
|
||||||
|
else -> parse(response.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(body: String): UpdateApiResult
|
||||||
|
{
|
||||||
|
val json = try { JSONObject(body) } catch (e: Exception) { return UpdateApiResult.Failure(UpdateError.OTHER, "bad json") }
|
||||||
|
val update = json.optBoolean("update", false)
|
||||||
|
val fields = UpdateSigning.Fields(
|
||||||
|
update = update,
|
||||||
|
mandatory = json.optBoolean("mandatory", false),
|
||||||
|
versionCode = if (update) json.optLong("versionCode", 0L) else 0L,
|
||||||
|
versionName = if (update) json.optString("versionName", "") else "",
|
||||||
|
sha256 = if (update) json.optString("sha256", "").lowercase() else "",
|
||||||
|
size = if (update) json.optLong("size", 0L) else 0L,
|
||||||
|
apkUrl = if (update) json.optString("apkUrl", "") else "",
|
||||||
|
issuedAt = json.optLong("issuedAt", 0L),
|
||||||
|
checkHours = json.optInt("checkHours", 12),
|
||||||
|
wifiOnly = json.optBoolean("wifiOnly", true),
|
||||||
|
)
|
||||||
|
val key = try { Base64.Default.decode(publicKeyBase64) } catch (e: Exception) { ByteArray(0) }
|
||||||
|
if (key.size != 32 || !UpdateSigning.verifyEd25519(key, UpdateSigning.manifestInput(fields), json.optString("signature", "")))
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest signature mismatch")
|
||||||
|
}
|
||||||
|
val skew = Math.abs(clock() / 1000 - fields.issuedAt)
|
||||||
|
if (skew > MAX_SKEW_SECONDS)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest is too old or the device clock is off ($skew s)")
|
||||||
|
}
|
||||||
|
val policy = UpdatePolicy(fields.checkHours, fields.wifiOnly)
|
||||||
|
if (!update)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.NoUpdate(policy)
|
||||||
|
}
|
||||||
|
if (fields.apkUrl.isBlank() || fields.sha256.length != 64)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.OTHER, "incomplete manifest")
|
||||||
|
}
|
||||||
|
val notes = HashMap<String, String>()
|
||||||
|
json.optJSONObject("notes")?.let { n -> for (k in n.keys()) notes[k.lowercase()] = n.optString(k, "") }
|
||||||
|
val info = UpdateInfo(
|
||||||
|
versionCode = fields.versionCode, versionName = fields.versionName, apkUrl = fields.apkUrl,
|
||||||
|
sha256 = fields.sha256, size = fields.size, notes = notes, fromApi = true, issuedAt = fields.issuedAt,
|
||||||
|
)
|
||||||
|
return UpdateApiResult.Update(info, fields.mandatory, policy, fields.issuedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
|
||||||
|
}
|
||||||
+203
-25
@@ -62,6 +62,7 @@ import java.net.Proxy
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
/** Categories of update failures, mapped to localized messages by the UI */
|
/** Categories of update failures, mapped to localized messages by the UI */
|
||||||
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
||||||
@@ -76,6 +77,10 @@ data class UpdateInfo(
|
|||||||
val sha256: String,
|
val sha256: String,
|
||||||
val size: Long,
|
val size: Long,
|
||||||
val notes: Map<String, String>,
|
val notes: Map<String, String>,
|
||||||
|
/* set for manifests from the SHX update API: the file URL is presigned and short-lived */
|
||||||
|
val fromApi: Boolean = false,
|
||||||
|
val issuedAt: Long = 0L,
|
||||||
|
val mandatory: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class UpdateState
|
sealed class UpdateState
|
||||||
@@ -117,6 +122,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
||||||
private const val UPDATES_NOTIFICATION_ID = 1001
|
private const val UPDATES_NOTIFICATION_ID = 1001
|
||||||
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
||||||
|
private const val PREF_INSTALL_ID = "pref_update_install_id"
|
||||||
|
private const val PREF_UPDATE_CHANNEL = "pref_update_channel"
|
||||||
|
private const val PREF_BETA_TOKEN = "pref_update_beta_token"
|
||||||
|
private const val PREF_API_CHECK_HOURS = "pref_update_api_check_hours"
|
||||||
|
private const val PREF_API_WIFI_ONLY = "pref_update_api_wifi_only"
|
||||||
|
/* a mandatory update the last check found: keeps the blocking sheet up across restarts and failed checks */
|
||||||
|
private const val PREF_MANDATORY_CODE = "pref_update_mandatory_code"
|
||||||
|
private const val PREF_MANDATORY_NAME = "pref_update_mandatory_name"
|
||||||
|
private const val PREF_MANDATORY_SIZE = "pref_update_mandatory_size"
|
||||||
|
/* refresh the manifest when it is older than this before downloading (the URL lives 10 minutes) */
|
||||||
|
private const val URL_MAX_AGE_SECONDS = 8 * 60L
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mInstance: UpdateManager? = null
|
private var mInstance: UpdateManager? = null
|
||||||
@@ -166,6 +182,11 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
* was known in this process: install as soon as the check finds it */
|
* was known in this process: install as soon as the check finds it */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mInstallPending = false
|
private var mInstallPending = false
|
||||||
|
/* set by cancelDownload(); the copy loop and the install step check it */
|
||||||
|
@Volatile
|
||||||
|
private var mCancelRequested = false
|
||||||
|
@Volatile
|
||||||
|
private var mDownloadConn: HttpURLConnection? = null
|
||||||
private var mVpnService: VpnStateService? = null
|
private var mVpnService: VpnStateService? = null
|
||||||
|
|
||||||
private val mConnection = object : ServiceConnection
|
private val mConnection = object : ServiceConnection
|
||||||
@@ -191,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
cleanup()
|
cleanup()
|
||||||
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
||||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS)
|
/* the interval comes from the server policy (signed manifest), 12 h until it is known */
|
||||||
|
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(checkIntervalHours(), TimeUnit.HOURS)
|
||||||
.setConstraints(Constraints.Builder()
|
.setConstraints(Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
/* background checks only run while charging - zero battery impact */
|
/* background checks only run while charging - zero battery impact */
|
||||||
@@ -224,7 +246,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
||||||
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS)
|
if (System.currentTimeMillis() - last >= TimeUnit.HOURS.toMillis(checkIntervalHours()))
|
||||||
{
|
{
|
||||||
return check()
|
return check()
|
||||||
}
|
}
|
||||||
@@ -244,6 +266,57 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
check()
|
check()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while the update channel is "beta" */
|
||||||
|
fun isBetaChannel(): Boolean = mPrefs.getString(PREF_UPDATE_CHANNEL, "release") == "beta"
|
||||||
|
|
||||||
|
/** Switches the channel (leaving beta forgets the invitation code) and checks right away */
|
||||||
|
fun setChannel(beta: Boolean, token: String?)
|
||||||
|
{
|
||||||
|
val edit = mPrefs.edit().putString(PREF_UPDATE_CHANNEL, if (beta) "beta" else "release")
|
||||||
|
if (beta) edit.putString(PREF_BETA_TOKEN, token?.trim()) else edit.remove(PREF_BETA_TOKEN)
|
||||||
|
edit.apply()
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Version name and size of a mandatory update newer than the installed app, or null */
|
||||||
|
fun pendingMandatory(): Pair<String, Long>?
|
||||||
|
{
|
||||||
|
if (mPrefs.getLong(PREF_MANDATORY_CODE, 0L) <= currentVersionCode())
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (mPrefs.getString(PREF_MANDATORY_NAME, null) ?: "") to mPrefs.getLong(PREF_MANDATORY_SIZE, -1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interrupts a running download, deletes the partial file and goes back to Idle */
|
||||||
|
@Synchronized
|
||||||
|
fun cancelDownload()
|
||||||
|
{
|
||||||
|
if (mDownloadJob?.isActive != true)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mInstallPending = false
|
||||||
|
mCancelRequested = true
|
||||||
|
mDownloadJob?.cancel()
|
||||||
|
/* unblocks a read waiting for the network */
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mDownloadConn?.disconnect()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadCancelled()
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "update: download cancelled")
|
||||||
|
cleanup()
|
||||||
|
cancelDownloadProgress()
|
||||||
|
mState.value = UpdateState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun check(): Job?
|
private fun check(): Job?
|
||||||
{
|
{
|
||||||
@@ -257,7 +330,12 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
mInstallPending = false
|
mInstallPending = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
|
val mandatory = info.mandatory && info.versionCode > currentVersionCode()
|
||||||
|
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis())
|
||||||
|
.putLong(PREF_MANDATORY_CODE, if (mandatory) info.versionCode else 0L)
|
||||||
|
.putString(PREF_MANDATORY_NAME, info.versionName)
|
||||||
|
.putLong(PREF_MANDATORY_SIZE, info.size)
|
||||||
|
.apply()
|
||||||
if (info.versionCode > currentVersionCode())
|
if (info.versionCode > currentVersionCode())
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
||||||
@@ -300,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isNetworkMetered())
|
/* server policy "Wi-Fi only" (default true); a policy of false allows the silent download on metered networks too */
|
||||||
|
if (mPrefs.getBoolean(PREF_API_WIFI_ONLY, true) && isNetworkMetered())
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
||||||
return
|
return
|
||||||
@@ -309,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mCancelRequested = false
|
||||||
mDownloadJob = mScope.launch {
|
mDownloadJob = mScope.launch {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
||||||
val apk = downloadApk(info)
|
val apk = downloadApk(info)
|
||||||
verifySignature(apk)
|
verifySignature(apk)
|
||||||
|
if (mCancelRequested)
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
if (isVpnDown() && canInstallSilently())
|
if (isVpnDown() && canInstallSilently())
|
||||||
{
|
{
|
||||||
Log.i(TAG, "Applying the downloaded update silently")
|
Log.i(TAG, "Applying the downloaded update silently")
|
||||||
@@ -340,6 +424,10 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (e: CancellationException)
|
||||||
|
{
|
||||||
|
onDownloadCancelled()
|
||||||
|
}
|
||||||
catch (e: Exception)
|
catch (e: Exception)
|
||||||
{
|
{
|
||||||
Log.e(TAG, "Silent update download failed", e)
|
Log.e(TAG, "Silent update download failed", e)
|
||||||
@@ -350,6 +438,9 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check interval from the server policy, kept between 1 hour and a week */
|
||||||
|
fun checkIntervalHours(): Long = mPrefs.getInt(PREF_API_CHECK_HOURS, 12).coerceIn(1, 168).toLong()
|
||||||
|
|
||||||
private fun isSilentUpdatesEnabled(): Boolean
|
private fun isSilentUpdatesEnabled(): Boolean
|
||||||
{
|
{
|
||||||
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
||||||
@@ -408,7 +499,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
||||||
{
|
{
|
||||||
Notification.Builder(context, UPDATES_CHANNEL)
|
val builder = Notification.Builder(context, UPDATES_CHANNEL)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText("$progress%")
|
.setContentText("$progress%")
|
||||||
@@ -416,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
.setStyle(Notification.ProgressStyle()
|
.setStyle(Notification.ProgressStyle()
|
||||||
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
||||||
.setProgress(progress))
|
.setProgress(progress))
|
||||||
.setRequestPromotedOngoing(true)
|
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
.build()
|
/* setRequestPromotedOngoing appeared in Android 16 QPR2 (API 36.1): on 36.0 it is a
|
||||||
|
* NoSuchMethodError that killed the app as soon as an update download started */
|
||||||
|
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1)
|
||||||
|
{
|
||||||
|
builder.setRequestPromotedOngoing(true)
|
||||||
|
}
|
||||||
|
builder.build()
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -519,16 +615,25 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
check()
|
check()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mCancelRequested = false
|
||||||
mDownloadJob = mScope.launch {
|
mDownloadJob = mScope.launch {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
||||||
val apk = downloadApk(info)
|
val apk = downloadApk(info)
|
||||||
verifySignature(apk)
|
verifySignature(apk)
|
||||||
|
if (mCancelRequested)
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
||||||
UpdateInstaller.install(context, apk)
|
UpdateInstaller.install(context, apk)
|
||||||
mState.value = UpdateState.Idle
|
mState.value = UpdateState.Idle
|
||||||
}
|
}
|
||||||
|
catch (e: CancellationException)
|
||||||
|
{
|
||||||
|
onDownloadCancelled()
|
||||||
|
}
|
||||||
catch (e: UpdateException)
|
catch (e: UpdateException)
|
||||||
{
|
{
|
||||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||||
@@ -560,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
|
|
||||||
private fun fetchManifest(): UpdateInfo?
|
private fun fetchManifest(): UpdateInfo?
|
||||||
{
|
{
|
||||||
|
if (apiConfigured())
|
||||||
|
{
|
||||||
|
when (val r = callApi())
|
||||||
|
{
|
||||||
|
is UpdateApiResult.Update -> return r.info.copy(mandatory = r.mandatory)
|
||||||
|
is UpdateApiResult.NoUpdate -> return upToDateMarker()
|
||||||
|
is UpdateApiResult.Failure ->
|
||||||
|
{
|
||||||
|
/* only a network or server failure falls back to the legacy bucket path; a bad
|
||||||
|
* signature or a rate limit must not be bypassed by asking the bucket */
|
||||||
|
if (r.kind != UpdateError.NETWORK || !legacyConfigured())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "update: API check failed: ${r.kind} ${r.detail}")
|
||||||
|
mState.value = UpdateState.Failed(r.kind, r.detail)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "update: API unreachable (${r.detail}), using the legacy manifest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
||||||
@@ -592,6 +717,58 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** also signs report uploads (ReportUploader) */
|
||||||
|
internal fun apiHmacKey(): String = decodeUpdateSecret(BuildConfig.UPDATE_API_HMAC_KEY)
|
||||||
|
|
||||||
|
private fun apiConfigured(): Boolean =
|
||||||
|
UpdateApiClient.isConfigured(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY)
|
||||||
|
|
||||||
|
/* the legacy path needs the bucket URL; without it there is nothing to fall back to */
|
||||||
|
private fun legacyConfigured(): Boolean = BuildConfig.UPDATE_BASE_URL.isNotBlank()
|
||||||
|
|
||||||
|
/** Stable random installation id (no personal data); the server stores only its hash */
|
||||||
|
internal fun installId(): String
|
||||||
|
{
|
||||||
|
mPrefs.getString(PREF_INSTALL_ID, null)?.let { return it }
|
||||||
|
val bytes = ByteArray(18).also { java.security.SecureRandom().nextBytes(it) }
|
||||||
|
val id = android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
|
||||||
|
mPrefs.edit().putString(PREF_INSTALL_ID, id).apply()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callApi(): UpdateApiResult
|
||||||
|
{
|
||||||
|
val client = UpdateApiClient(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY, installId())
|
||||||
|
val abi = Build.SUPPORTED_ABIS.firstOrNull()
|
||||||
|
val result = client.check(context.packageName, currentVersionCode(), mPrefs.getString(PREF_UPDATE_CHANNEL, "release") ?: "release",
|
||||||
|
abi, Build.VERSION.SDK_INT, mPrefs.getString(PREF_BETA_TOKEN, null))
|
||||||
|
val policy = when (result)
|
||||||
|
{
|
||||||
|
is UpdateApiResult.Update -> result.policy
|
||||||
|
is UpdateApiResult.NoUpdate -> result.policy
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
/* server hints are kept for the scheduler; the user's own settings stay in charge */
|
||||||
|
policy?.let { mPrefs.edit().putInt(PREF_API_CHECK_HOURS, it.checkHours).putBoolean(PREF_API_WIFI_ONLY, it.wifiOnly).apply() }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An UpdateInfo that sends check() into the "up to date" branch */
|
||||||
|
private fun upToDateMarker() = UpdateInfo(currentVersionCode(), currentVersionName(), "", "", -1L, emptyMap(), fromApi = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presigned file URL lives ~10 minutes while a download may wait for Wi-Fi or charging:
|
||||||
|
* ask the API again when the manifest is old
|
||||||
|
*/
|
||||||
|
private fun freshen(info: UpdateInfo, force: Boolean = false): UpdateInfo
|
||||||
|
{
|
||||||
|
if (!info.fromApi || info.apkUrl.isBlank()) return info
|
||||||
|
val age = System.currentTimeMillis() / 1000 - info.issuedAt
|
||||||
|
if (!force && age in 0..URL_MAX_AGE_SECONDS) return info
|
||||||
|
val r = callApi()
|
||||||
|
return if (r is UpdateApiResult.Update && r.info.versionCode == info.versionCode && r.info.sha256 == info.sha256) r.info.copy(mandatory = r.mandatory) else info
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
||||||
{
|
{
|
||||||
if (notes == null)
|
if (notes == null)
|
||||||
@@ -606,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadApk(info: UpdateInfo): File
|
private fun downloadApk(original: UpdateInfo, retried: Boolean = false): File
|
||||||
{
|
{
|
||||||
|
val info = freshen(original, force = retried)
|
||||||
val dir = File(context.filesDir, UPDATE_DIR)
|
val dir = File(context.filesDir, UPDATE_DIR)
|
||||||
if (!dir.exists() && !dir.mkdirs())
|
if (!dir.exists() && !dir.mkdirs())
|
||||||
{
|
{
|
||||||
@@ -620,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
apk.delete()
|
apk.delete()
|
||||||
|
|
||||||
val conn = openConnection(info.apkUrl)
|
/* a presigned URL already carries its own signature: adding S3 auth headers makes S3 reject it */
|
||||||
|
val conn = openConnection(info.apkUrl, signS3 = !info.fromApi)
|
||||||
conn.readTimeout = 30_000
|
conn.readTimeout = 30_000
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
|
if (code == HttpURLConnection.HTTP_FORBIDDEN && info.fromApi && !retried)
|
||||||
|
{ /* the link expired while waiting: fetch a fresh one once */
|
||||||
|
conn.disconnect()
|
||||||
|
return downloadApk(original, retried = true)
|
||||||
|
}
|
||||||
if (code != HttpURLConnection.HTTP_OK)
|
if (code != HttpURLConnection.HTTP_OK)
|
||||||
{
|
{
|
||||||
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
||||||
}
|
}
|
||||||
val expected = if (info.size > 0) info.size else -1L
|
val expected = if (info.size > 0) info.size else -1L
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
val buffer = ByteArray(64 * 1024)
|
|
||||||
var downloaded = 0L
|
|
||||||
var lastProgress = -1
|
var lastProgress = -1
|
||||||
|
|
||||||
conn.inputStream.use { input ->
|
mDownloadConn = conn
|
||||||
|
val downloaded = conn.inputStream.use { input ->
|
||||||
FileOutputStream(apk).use { output ->
|
FileOutputStream(apk).use { output ->
|
||||||
while (true)
|
copyDownload(input, output, digest, { mCancelRequested }) { done ->
|
||||||
{
|
|
||||||
val len = input.read(buffer)
|
|
||||||
if (len < 0)
|
|
||||||
{
|
|
||||||
break
|
|
||||||
}
|
|
||||||
output.write(buffer, 0, len)
|
|
||||||
digest.update(buffer, 0, len)
|
|
||||||
downloaded += len
|
|
||||||
if (expected > 0)
|
if (expected > 0)
|
||||||
{
|
{
|
||||||
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
|
val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
|
||||||
if (progress != lastProgress)
|
if (progress != lastProgress)
|
||||||
{
|
{
|
||||||
lastProgress = progress
|
lastProgress = progress
|
||||||
@@ -674,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
mDownloadConn = null
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
cancelDownloadProgress()
|
cancelDownloadProgress()
|
||||||
}
|
}
|
||||||
@@ -771,14 +946,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String): HttpURLConnection
|
private fun openConnection(url: String, signS3: Boolean = true): HttpURLConnection
|
||||||
{
|
{
|
||||||
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
||||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
conn.readTimeout = READ_TIMEOUT_MS
|
conn.readTimeout = READ_TIMEOUT_MS
|
||||||
conn.setRequestProperty("Connection", "close")
|
conn.setRequestProperty("Connection", "close")
|
||||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||||
applyS3Auth(conn, url)
|
if (signS3)
|
||||||
|
{
|
||||||
|
applyS3Auth(conn, url)
|
||||||
|
}
|
||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release notes of the app language: the full tag ("zh-tw"), then the language ("zh"),
|
||||||
|
* then Russian, then English. Keys of [notes] are lowercase (see UpdateManager.parseNotes).
|
||||||
|
*/
|
||||||
|
fun pickNotes(notes: Map<String, String>, languageTag: String, language: String): String?
|
||||||
|
{
|
||||||
|
return listOf(languageTag.lowercase(), language.lowercase(), "ru", "en")
|
||||||
|
.firstNotNullOfOrNull { key -> notes[key]?.takeIf { it.isNotBlank() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every non-empty line of the notes is one list item; a leading "-", "*" or "•" is dropped. */
|
||||||
|
fun noteLines(text: String?): List<String>
|
||||||
|
{
|
||||||
|
return text.orEmpty().lines().map { it.trim().trimStart('-', '*', '•').trim() }.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the download into [output] and the digest; [isCancelled] is checked between reads and
|
||||||
|
* when a read fails (cancelling disconnects the connection, which breaks a blocked read).
|
||||||
|
*/
|
||||||
|
internal fun copyDownload(input: InputStream, output: OutputStream, digest: MessageDigest,
|
||||||
|
isCancelled: () -> Boolean, onProgress: (Long) -> Unit): Long
|
||||||
|
{
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
var total = 0L
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (isCancelled())
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
|
val len = try
|
||||||
|
{
|
||||||
|
input.read(buffer)
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
if (isCancelled()) throw CancellationException("update download cancelled") else throw e
|
||||||
|
}
|
||||||
|
if (len < 0)
|
||||||
|
{
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, len)
|
||||||
|
digest.update(buffer, 0, len)
|
||||||
|
total += len
|
||||||
|
onProgress(total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -77,6 +81,7 @@ import org.strongswan.android.R
|
|||||||
import org.strongswan.android.data.LogContentProvider
|
import org.strongswan.android.data.LogContentProvider
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.DiagLog
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
@@ -87,18 +92,49 @@ class LogActivity : AppCompatActivity()
|
|||||||
{
|
{
|
||||||
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
|
/* the active profile goes into the report meta */
|
||||||
|
private var vpn: VpnStateService? = null
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
vpn = (binder as VpnStateService.LocalBinder).service
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
vpn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?)
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
{
|
{
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||||
setContent {
|
setContent {
|
||||||
ShxTheme {
|
ShxTheme {
|
||||||
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages)
|
val report: ReportViewModel = viewModel()
|
||||||
|
/* without the panel API in this build the report goes by email, as before */
|
||||||
|
LogScreen(onBack = { finish() }, onSend = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }, messages = messages)
|
||||||
|
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendLog()
|
override fun onDestroy()
|
||||||
|
{
|
||||||
|
unbindService(connection)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeProfile(): String
|
||||||
|
{
|
||||||
|
val profile = vpn?.profile ?: return ""
|
||||||
|
return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendLog(comment: String)
|
||||||
{
|
{
|
||||||
/* the daemon log plus the lifecycle log; the latter survives process
|
/* the daemon log plus the lifecycle log; the latter survives process
|
||||||
* deaths and documents watchdog/reconnect decisions */
|
* deaths and documents watchdog/reconnect decisions */
|
||||||
@@ -124,6 +160,10 @@ class LogActivity : AppCompatActivity()
|
|||||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
||||||
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||||
intent.type = "text/plain"
|
intent.type = "text/plain"
|
||||||
|
if (comment.isNotBlank())
|
||||||
|
{
|
||||||
|
intent.putExtra(Intent.EXTRA_TEXT, comment)
|
||||||
|
}
|
||||||
if (uris.size > 1)
|
if (uris.size > 1)
|
||||||
{
|
{
|
||||||
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import org.strongswan.android.ui.compose.runDiagnostics
|
||||||
|
import org.strongswan.android.BuildConfig
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
import org.strongswan.android.logic.updates.ReportContent
|
||||||
|
import org.strongswan.android.logic.updates.ReportUploader
|
||||||
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIntro
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/** State of the "Send report" sheet (mockups App8-App10); the upload survives a rotation */
|
||||||
|
class ReportViewModel(app: Application) : AndroidViewModel(app)
|
||||||
|
{
|
||||||
|
sealed class Ui
|
||||||
|
{
|
||||||
|
data object Hidden : Ui()
|
||||||
|
data class Form(val sending: Boolean = false) : Ui()
|
||||||
|
data class Sent(val code: String) : Ui()
|
||||||
|
data class Failed(val failure: ReportContent.Failure) : Ui()
|
||||||
|
}
|
||||||
|
|
||||||
|
var ui by mutableStateOf<Ui>(Ui.Hidden)
|
||||||
|
private set
|
||||||
|
var comment by mutableStateOf("")
|
||||||
|
var attachDiag by mutableStateOf(true)
|
||||||
|
|
||||||
|
/* a swipe may hide the sheet mid-upload: the result then opens it again */
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun open()
|
||||||
|
{
|
||||||
|
ui = Ui.Form(sending = job?.isActive == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
{
|
||||||
|
ui = Ui.Hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(profile: String)
|
||||||
|
{
|
||||||
|
if (job?.isActive == true)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui = Ui.Form(sending = true)
|
||||||
|
val app = getApplication<Application>()
|
||||||
|
val note = comment
|
||||||
|
val diag = attachDiag
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
/* the same checks as the Diagnostics screen (~3 s); a hang must not block the report */
|
||||||
|
val checks = if (!diag) null else withTimeoutOrNull(10_000) {
|
||||||
|
runCatching { runDiagnostics(app) }.getOrNull()?.steps?.map { ReportContent.Check(it.title, it.value, it.ok) }
|
||||||
|
}
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
val charon = ReportContent.todayOnly(
|
||||||
|
ReportContent.readTail(File(app.filesDir, CharonVpnService.LOG_FILE), ReportContent.CHARON_MAX_BYTES * 2),
|
||||||
|
now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH),
|
||||||
|
)
|
||||||
|
val text = ReportContent.build(charon, if (diag) ReportContent.readTail(DiagLog.file(app), ReportContent.DIAG_MAX_BYTES) else null, checks)
|
||||||
|
val meta = ReportContent.meta(
|
||||||
|
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong(), Build.MANUFACTURER, Build.MODEL,
|
||||||
|
Build.VERSION.RELEASE, Build.VERSION.SDK_INT, networkLabel(app), note, profile,
|
||||||
|
)
|
||||||
|
val updates = UpdateManager.getInstance(app)
|
||||||
|
ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta)
|
||||||
|
}
|
||||||
|
when (result)
|
||||||
|
{
|
||||||
|
is ReportUploader.Result.Sent ->
|
||||||
|
{
|
||||||
|
Events.reportSent(app, result.code)
|
||||||
|
comment = ""
|
||||||
|
ui = Ui.Sent(result.code)
|
||||||
|
}
|
||||||
|
is ReportUploader.Result.Failed ->
|
||||||
|
{
|
||||||
|
DiagLog.i("Report", "upload failed: HTTP ${result.httpCode} ${result.detail}")
|
||||||
|
ui = Ui.Failed(ReportContent.failure(result.httpCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** Only the address and the HMAC key are needed (the manifest key is for updates) */
|
||||||
|
fun apiReady(context: Context): Boolean =
|
||||||
|
BuildConfig.UPDATE_API_URL.isNotBlank() && UpdateManager.getInstance(context).apiHmacKey().isNotBlank()
|
||||||
|
|
||||||
|
/** The network under the tunnel: with the VPN up the active network is the VPN itself */
|
||||||
|
private fun networkLabel(context: Context): String
|
||||||
|
{
|
||||||
|
val network = UnderlyingNetwork.resolve(context) ?: return "none"
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none"
|
||||||
|
val caps = try { cm.getNetworkCapabilities(network) } catch (_: Exception) { null } ?: return "none"
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReportSheet(model: ReportViewModel, profile: () -> String, onMail: (String) -> Unit)
|
||||||
|
{
|
||||||
|
val ui = model.ui
|
||||||
|
if (ui == ReportViewModel.Ui.Hidden)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ShxSheet(onDismiss = model::close)
|
||||||
|
{
|
||||||
|
when (ui)
|
||||||
|
{
|
||||||
|
is ReportViewModel.Ui.Form -> ReportForm(model, ui.sending, onSend = { model.send(profile()) }, onMail = { onMail(model.comment) })
|
||||||
|
is ReportViewModel.Ui.Sent -> ReportSent(ui.code, onDone = model::close)
|
||||||
|
is ReportViewModel.Ui.Failed -> ReportFailed(ui.failure, onRetry = model::open, onMail = { onMail(model.comment) })
|
||||||
|
ReportViewModel.Ui.Hidden -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App8 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> Unit, onMail: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
ShxSheetTitle(stringResource(R.string.log_send_report))
|
||||||
|
ShxIntro(stringResource(R.string.report_intro))
|
||||||
|
}
|
||||||
|
ReasonCard(
|
||||||
|
stringResource(R.string.report_contents), c.accent,
|
||||||
|
listOf(R.string.report_item_log, R.string.report_item_device, R.string.report_item_network, R.string.report_item_ips).map { stringResource(it) to null },
|
||||||
|
)
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.report_diag_title), model.attachDiag, { model.attachDiag = it },
|
||||||
|
subtitle = stringResource(R.string.report_diag_sub), enabled = !sending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.report_comment_label),
|
||||||
|
value = model.comment,
|
||||||
|
onValueChange = { model.comment = it.take(1000) },
|
||||||
|
singleLine = false,
|
||||||
|
enabled = !sending,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.report_cancel), model::close, style = ShxButtonStyle.OUTLINE, enabled = !sending, modifier = Modifier.weight(1f))
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(2f))
|
||||||
|
{
|
||||||
|
ShxButton(if (sending) "" else stringResource(R.string.report_send), { if (!sending) onSend() }, modifier = Modifier.fillMaxWidth())
|
||||||
|
if (sending)
|
||||||
|
{
|
||||||
|
CircularProgressIndicator(color = c.onAccent, strokeWidth = 2.5.dp, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sending)
|
||||||
|
{
|
||||||
|
TextLink(stringResource(R.string.report_by_mail), onMail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App9 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportSent(code: String, onDone: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val copied = stringResource(R.string.report_code_copied)
|
||||||
|
ResultHeader(ShxIcons.Check, c.successContainer, c.success, stringResource(R.string.report_sent_title), stringResource(R.string.report_sent_sub))
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp))
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.report_code_label).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Text(code, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, letterSpacing = 0.9.sp), color = c.text)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile).clickable(role = Role.Button) {
|
||||||
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText("report", code))
|
||||||
|
Toast.makeText(context, copied, Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.report_copy_code), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.report_saved_note))
|
||||||
|
ShxButton(stringResource(R.string.report_done), onDone, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App10 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportFailed(failure: ReportContent.Failure, onRetry: () -> Unit, onMail: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val noAnswer = failure == ReportContent.Failure.NO_NETWORK || failure == ReportContent.Failure.SERVER_DOWN
|
||||||
|
ResultHeader(
|
||||||
|
ShxIcons.Alert, c.errorContainer, c.error, stringResource(R.string.report_failed_title),
|
||||||
|
stringResource(if (noAnswer) R.string.report_failed_no_answer else R.string.report_failed_refused),
|
||||||
|
)
|
||||||
|
val reasons = when (failure)
|
||||||
|
{
|
||||||
|
ReportContent.Failure.NO_NETWORK -> listOf(R.string.report_reason_offline to R.string.report_reason_offline_d, R.string.report_reason_server to R.string.report_reason_server_d)
|
||||||
|
ReportContent.Failure.SERVER_DOWN -> listOf(R.string.report_reason_server to R.string.report_reason_server_d)
|
||||||
|
ReportContent.Failure.SIGNATURE -> listOf(R.string.report_reason_clock to R.string.report_reason_clock_d)
|
||||||
|
ReportContent.Failure.TOO_OFTEN -> listOf(R.string.report_reason_often to R.string.report_reason_often_d)
|
||||||
|
ReportContent.Failure.TOO_LARGE -> listOf(R.string.report_reason_large to R.string.report_reason_large_d)
|
||||||
|
ReportContent.Failure.REJECTED -> listOf(R.string.report_reason_rejected to R.string.report_reason_rejected_d)
|
||||||
|
}
|
||||||
|
ReasonCard(stringResource(R.string.report_reasons), c.error, reasons.map { (t, d) -> stringResource(t) to stringResource(d) })
|
||||||
|
ShxNote(stringResource(R.string.report_mail_note))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.report_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.report_by_letter), onMail, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 52dp round icon, 22/800 title, 13 sub (as F_ErrImport) */
|
||||||
|
@Composable
|
||||||
|
private fun ResultHeader(icon: ImageVector, background: Color, tint: Color, title: String, sub: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(background))
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text)
|
||||||
|
Text(sub, style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** White card with a section label and dotted lines: «bold — muted» when [items] carry a second part */
|
||||||
|
@Composable
|
||||||
|
private fun ReasonCard(title: String, dot: Color, items: List<Pair<String, String?>>)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(22.dp)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(title.uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
items.forEach { (head, tail) ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (tail == null)
|
||||||
|
{
|
||||||
|
append(head)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(head) }
|
||||||
|
append(" — ")
|
||||||
|
withStyle(SpanStyle(color = c.textSub)) { append(tail) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextLink(text: String, onClick: () -> Unit)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text, style = ShxType.chip, color = LocalShxColors.current.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
|
|||||||
Box(Modifier.padding(padding)) {
|
Box(Modifier.padding(padding)) {
|
||||||
SettingsScreen(profiles = profiles, showTitle = false)
|
SettingsScreen(profiles = profiles, showTitle = false)
|
||||||
}
|
}
|
||||||
|
org.strongswan.android.ui.compose.UpdateSheetHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolea
|
|||||||
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
|
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||||
val vpn = VpnStateService.getLiveInstance()
|
val vpn = VpnStateService.getLiveInstance()
|
||||||
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
||||||
val gateway = (vpn?.profile)?.gateway
|
val gateway = (vpn?.profile)?.gateway
|
||||||
|
|||||||
+17
-8
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
|||||||
var result by remember { mutableStateOf<CheckResult?>(null) }
|
var result by remember { mutableStateOf<CheckResult?>(null) }
|
||||||
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||||
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||||
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
|
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
|
||||||
if (mode == RouteCheckMode.APP && value.isEmpty())
|
|
||||||
|
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
|
||||||
|
suspend fun loadApps(): List<InstalledApp>
|
||||||
|
{
|
||||||
|
if (apps.isEmpty())
|
||||||
{
|
{
|
||||||
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||||
|
}
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(mode)
|
||||||
|
{
|
||||||
|
if (mode == RouteCheckMode.APP)
|
||||||
|
{
|
||||||
|
loadApps()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
|||||||
{
|
{
|
||||||
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
||||||
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
||||||
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||||
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
||||||
}
|
}
|
||||||
if (result !is CheckResult.BadInput)
|
if (result !is CheckResult.BadInput)
|
||||||
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
|||||||
{
|
{
|
||||||
if (initialQuery.isNotBlank())
|
if (initialQuery.isNotBlank())
|
||||||
{
|
{
|
||||||
if (mode == RouteCheckMode.APP)
|
|
||||||
{
|
|
||||||
delay(300)
|
|
||||||
}
|
|
||||||
run()
|
run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-94
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.logic.updates.UpdateState
|
|
||||||
import org.strongswan.android.ui.compose.design.ShxButton
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
@@ -221,91 +220,6 @@ fun KillSwitchPage(lockdown: Boolean)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update card (F_Update): app icon, version, what is new, progress, Later / Install. */
|
|
||||||
@Composable
|
|
||||||
fun UpdateCard(state: UpdateState, installed: String, onCheck: () -> Unit, onInstall: () -> Unit)
|
|
||||||
{
|
|
||||||
val c = LocalShxColors.current
|
|
||||||
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
|
|
||||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
|
||||||
{
|
|
||||||
val shape = RoundedCornerShape(24.dp)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(16.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
|
|
||||||
{
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
|
|
||||||
{
|
|
||||||
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
if (info != null) "SHX VPN ${info.versionName}" else stringResource(R.string.update_card_uptodate),
|
|
||||||
style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
when
|
|
||||||
{
|
|
||||||
info != null -> stringResource(R.string.update_card_have, installed, formatBytes(info.size))
|
|
||||||
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
|
||||||
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
|
|
||||||
else -> stringResource(R.string.update_card_version, installed)
|
|
||||||
},
|
|
||||||
style = ShxType.sub, color = c.textSub,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (info != null)
|
|
||||||
{
|
|
||||||
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull()
|
|
||||||
if (!notes.isNullOrBlank())
|
|
||||||
{
|
|
||||||
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
|
||||||
notes.split(Regex("(?<=[.;])\\s+")).map { it.trim().trimEnd('.', ';') }.filter { it.isNotEmpty() }.forEach { line ->
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
|
||||||
{
|
|
||||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.accent))
|
|
||||||
Text(line, style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state is UpdateState.Downloading)
|
|
||||||
{
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
|
||||||
{
|
|
||||||
Row(Modifier.fillMaxWidth())
|
|
||||||
{
|
|
||||||
Text(stringResource(R.string.update_card_downloading), style = ShxType.meta, color = c.textSub, modifier = Modifier.weight(1f))
|
|
||||||
Text("${state.progress}%", style = ShxType.meta, color = c.textSub)
|
|
||||||
}
|
|
||||||
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
|
|
||||||
{
|
|
||||||
Box(Modifier.fillMaxWidth(state.progress.coerceIn(0, 100) / 100f).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
|
||||||
{
|
|
||||||
if (info != null)
|
|
||||||
{
|
|
||||||
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
|
||||||
ShxButton(stringResource(R.string.update_card_install), onInstall, modifier = Modifier.weight(2f), enabled = state !is UpdateState.Downloading)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), enabled = state !is UpdateState.Checking)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.update_card_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «Help» (F_Help): two big buttons (check the connection, contact support),
|
* «Help» (F_Help): two big buttons (check the connection, contact support),
|
||||||
* the frequent questions as an accordion, and «About the app».
|
* the frequent questions as an accordion, and «About the app».
|
||||||
@@ -566,14 +480,7 @@ internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit)
|
|||||||
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
|
||||||
Text(stringResource(R.string.install_perm_safe), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
{
|
{
|
||||||
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
|||||||
+2
-172
@@ -105,8 +105,6 @@ import org.strongswan.android.logic.StrongSwanApplication
|
|||||||
import org.strongswan.android.logic.WatchdogReceiver
|
import org.strongswan.android.logic.WatchdogReceiver
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||||
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
||||||
import org.strongswan.android.logic.updates.UpdateError
|
|
||||||
import org.strongswan.android.logic.updates.UpdateInstaller
|
|
||||||
import org.strongswan.android.logic.updates.UpdateManager
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
import org.strongswan.android.logic.updates.UpdateState
|
import org.strongswan.android.logic.updates.UpdateState
|
||||||
import org.strongswan.android.ui.LogActivity
|
import org.strongswan.android.ui.LogActivity
|
||||||
@@ -151,43 +149,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
|
|
||||||
val updateManager = remember { UpdateManager.getInstance(context) }
|
val updateManager = remember { UpdateManager.getInstance(context) }
|
||||||
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
|
||||||
var showInstallPermission by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (UpdateInstaller.canInstall(context))
|
|
||||||
{
|
|
||||||
updateManager.downloadAndInstall()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val startUpdate: () -> Unit = {
|
|
||||||
if (UpdateInstaller.canInstall(context))
|
|
||||||
{
|
|
||||||
updateManager.downloadAndInstall()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{ /* F_UpdateInstall explains the system page first */
|
|
||||||
showInstallPermission = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showInstallPermission)
|
|
||||||
{
|
|
||||||
InstallPermissionSheet(
|
|
||||||
onOpen = {
|
|
||||||
showInstallPermission = false
|
|
||||||
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
|
||||||
},
|
|
||||||
onDismiss = { showInstallPermission = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
||||||
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
|
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
|
||||||
@@ -419,18 +383,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
|
|
||||||
if (page == SettingsPage.UPDATES)
|
if (page == SettingsPage.UPDATES)
|
||||||
{
|
{
|
||||||
UpdateCard(
|
UpdatesPage()
|
||||||
state = updateState,
|
|
||||||
installed = versionName,
|
|
||||||
onCheck = {
|
|
||||||
when (updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
|
||||||
else -> updateManager.checkNow()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onInstall = { showUpdateConfirm = true },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.KILLSWITCH)
|
if (page == SettingsPage.KILLSWITCH)
|
||||||
@@ -445,75 +398,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
|
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.UPDATES)
|
if (page == SettingsPage.ROOT) Text(
|
||||||
{
|
|
||||||
ShxSection(title = stringResource(R.string.update_settings))
|
|
||||||
{
|
|
||||||
var autoUpdate by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
|
|
||||||
}
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = ShxIcons.Refresh,
|
|
||||||
title = stringResource(R.string.update_auto_check_title),
|
|
||||||
subtitle = stringResource(R.string.update_auto_check_summary),
|
|
||||||
checked = autoUpdate,
|
|
||||||
onCheckedChange = {
|
|
||||||
autoUpdate = it
|
|
||||||
prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
var silentUpdates by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
|
|
||||||
}
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = ShxIcons.Refresh,
|
|
||||||
title = stringResource(R.string.update_silent_title),
|
|
||||||
subtitle = stringResource(R.string.update_silent_summary),
|
|
||||||
checked = silentUpdates,
|
|
||||||
onCheckedChange = {
|
|
||||||
silentUpdates = it
|
|
||||||
prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = ShxIcons.Download,
|
|
||||||
title = stringResource(R.string.update_check_title),
|
|
||||||
subtitle = updateStatusText(updateState),
|
|
||||||
onClick = {
|
|
||||||
when (updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
|
||||||
else -> updateManager.checkNow()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val updateInfo = (updateState as? UpdateState.Available)?.info
|
|
||||||
?: (updateState as? UpdateState.Downloading)?.info
|
|
||||||
if (updateInfo != null)
|
|
||||||
{
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = ShxIcons.Download,
|
|
||||||
title = stringResource(R.string.update_download_install),
|
|
||||||
subtitle = when (val state = updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
|
||||||
else -> stringResource(R.string.update_available_fmt, updateInfo.versionName)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (updateState !is UpdateState.Downloading)
|
|
||||||
{
|
|
||||||
showUpdateConfirm = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
|
|
||||||
text = stringResource(R.string.app_version_fmt, versionName),
|
text = stringResource(R.string.app_version_fmt, versionName),
|
||||||
style = ShxType.meta,
|
style = ShxType.meta,
|
||||||
color = LocalShxColors.current.textSub,
|
color = LocalShxColors.current.textSub,
|
||||||
@@ -600,61 +485,6 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showUpdateConfirm && updateState is UpdateState.Available)
|
|
||||||
{
|
|
||||||
val info = (updateState as UpdateState.Available).info
|
|
||||||
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() ?: ""
|
|
||||||
ShxAlertDialog(
|
|
||||||
onDismissRequest = { showUpdateConfirm = false },
|
|
||||||
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
|
|
||||||
text = { if (notes.isNotEmpty()) { Text(notes) } },
|
|
||||||
confirmButton = {
|
|
||||||
ShxDialogButton(primary = true, onClick = {
|
|
||||||
showUpdateConfirm = false
|
|
||||||
startUpdate()
|
|
||||||
}) { Text(stringResource(R.string.update_action)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
ShxDialogButton(onClick = { showUpdateConfirm = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun updateStatusText(state: UpdateState): String
|
|
||||||
{
|
|
||||||
return when (state)
|
|
||||||
{
|
|
||||||
is UpdateState.Checking -> stringResource(R.string.update_checking)
|
|
||||||
is UpdateState.Idle -> stringResource(R.string.update_status_idle)
|
|
||||||
is UpdateState.UpToDate -> stringResource(R.string.update_status_uptodate)
|
|
||||||
is UpdateState.Available ->
|
|
||||||
{
|
|
||||||
if (state.info.size > 0)
|
|
||||||
{
|
|
||||||
stringResource(R.string.update_available_fmt, state.info.versionName) + " • " +
|
|
||||||
stringResource(R.string.update_size_mb_fmt, (state.info.size / (1024 * 1024)).toInt())
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
stringResource(R.string.update_available_fmt, state.info.versionName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
|
||||||
is UpdateState.Failed -> stringResource(
|
|
||||||
when (state.error)
|
|
||||||
{
|
|
||||||
UpdateError.NETWORK -> R.string.update_error_network
|
|
||||||
UpdateError.HASH -> R.string.update_error_hash
|
|
||||||
UpdateError.SIGNATURE -> R.string.update_error_signature
|
|
||||||
UpdateError.INSTALL -> R.string.update_error_install
|
|
||||||
UpdateError.OTHER -> R.string.update_error_generic
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
{
|
{
|
||||||
AddServerSheet(onDismiss = { addSheetOpen = false })
|
AddServerSheet(onDismiss = { addSheetOpen = false })
|
||||||
}
|
}
|
||||||
|
/* update sheets, the mandatory update included, over every tab */
|
||||||
|
UpdateSheetHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+658
@@ -0,0 +1,658 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
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.semantics.Role
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.strongswan.android.BuildConfig
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.updates.UpdateError
|
||||||
|
import org.strongswan.android.logic.updates.UpdateInfo
|
||||||
|
import org.strongswan.android.logic.updates.UpdateInstaller
|
||||||
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
|
import org.strongswan.android.logic.updates.UpdateState
|
||||||
|
import org.strongswan.android.logic.updates.noteLines
|
||||||
|
import org.strongswan.android.logic.updates.pickNotes
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxChoiceCard
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIntro
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
|
||||||
|
/** Which update sheet the user opened: «What is new» (App2) or the install attempt (App3 / App5). */
|
||||||
|
enum class UpdateSheet { REVIEW, ATTEMPT }
|
||||||
|
|
||||||
|
/** Shared between the Updates page and the one sheet host of the activity. */
|
||||||
|
object UpdateSheets
|
||||||
|
{
|
||||||
|
val shown = MutableStateFlow<UpdateSheet?>(null)
|
||||||
|
|
||||||
|
fun show(sheet: UpdateSheet?)
|
||||||
|
{
|
||||||
|
shown.value = sheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All update sheets of an activity (ShxApp and SettingsActivity call it once):
|
||||||
|
* «What is new» (App2), the download (App3), the mandatory update (App4),
|
||||||
|
* the failure (App5) and the install permission (App6).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateSheetHost()
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val manager = remember { UpdateManager.getInstance(context) }
|
||||||
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
|
val sheet by UpdateSheets.shown.collectAsStateWithLifecycle()
|
||||||
|
val mandatory = remember(state) { manager.pendingMandatory() }
|
||||||
|
var askPermission by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val install = {
|
||||||
|
UpdateSheets.show(UpdateSheet.ATTEMPT)
|
||||||
|
manager.downloadAndInstall()
|
||||||
|
}
|
||||||
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult())
|
||||||
|
{
|
||||||
|
if (UpdateInstaller.canInstall(context)) install()
|
||||||
|
else Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
val start: () -> Unit = {
|
||||||
|
if (UpdateInstaller.canInstall(context)) install() else askPermission = true
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit)
|
||||||
|
{ /* a mandatory update found earlier (e.g. before a restart): make sure it is still current */
|
||||||
|
if (manager.pendingMandatory() != null && state !is UpdateState.Available && state !is UpdateState.Downloading)
|
||||||
|
{
|
||||||
|
manager.checkNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state)
|
||||||
|
{ /* the installer took over, or the download was cancelled */
|
||||||
|
if (sheet == UpdateSheet.ATTEMPT && (state is UpdateState.Idle || state is UpdateState.UpToDate))
|
||||||
|
{
|
||||||
|
UpdateSheets.show(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when
|
||||||
|
{
|
||||||
|
askPermission -> InstallPermissionSheet(
|
||||||
|
onOpen = {
|
||||||
|
askPermission = false
|
||||||
|
launcher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
||||||
|
},
|
||||||
|
onDismiss = { askPermission = false },
|
||||||
|
)
|
||||||
|
sheet == UpdateSheet.ATTEMPT -> ShxSheet(onDismiss = { UpdateSheets.show(null) })
|
||||||
|
{
|
||||||
|
when (val s = state)
|
||||||
|
{
|
||||||
|
is UpdateState.Failed -> UpdateFailedContent(s, onRetry = start)
|
||||||
|
else -> UpdateProgressContent(s, onCancel = { manager.cancelDownload() }, onInstall = start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mandatory != null -> MandatoryUpdateSheet(mandatory.first, mandatory.second, onUpdate = start)
|
||||||
|
sheet == UpdateSheet.REVIEW ->
|
||||||
|
{
|
||||||
|
val info = (state as? UpdateState.Available)?.info
|
||||||
|
if (info != null) UpdateReviewSheet(info, onInstall = start, onDismiss = { UpdateSheets.show(null) })
|
||||||
|
else androidx.compose.runtime.SideEffect { UpdateSheets.show(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Updates» page (App1): the available version, automatic mode, channel, safety note, manual check. */
|
||||||
|
@Composable
|
||||||
|
fun UpdatesPage()
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val manager = remember { UpdateManager.getInstance(context) }
|
||||||
|
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
|
||||||
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
|
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
|
||||||
|
val busy = state is UpdateState.Checking || state is UpdateState.Downloading
|
||||||
|
var beta by remember { mutableStateOf(manager.isBetaChannel()) }
|
||||||
|
var askCode by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(Modifier.padding(horizontal = ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
||||||
|
{
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accentContainer).padding(ShxSpacing.lg),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Refresh, contentDescription = null, tint = c.onAccent, modifier = Modifier.size(26.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
when
|
||||||
|
{
|
||||||
|
info != null -> stringResource(R.string.update_available_fmt, info.versionName)
|
||||||
|
state is UpdateState.UpToDate -> stringResource(R.string.update_card_uptodate)
|
||||||
|
else -> "SHX VPN ${BuildConfig.VERSION_NAME}"
|
||||||
|
},
|
||||||
|
style = ShxType.cardHeading, color = c.onAccentContainerStrong,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
when
|
||||||
|
{
|
||||||
|
info != null -> stringResource(R.string.updates_have_size, BuildConfig.VERSION_NAME, sizeText(info.size))
|
||||||
|
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||||
|
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
|
||||||
|
state is UpdateState.UpToDate -> stringResource(R.string.update_card_version, BuildConfig.VERSION_NAME)
|
||||||
|
else -> stringResource(R.string.update_status_idle)
|
||||||
|
},
|
||||||
|
style = ShxType.sub, color = c.onAccentContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info != null)
|
||||||
|
{
|
||||||
|
val downloading = state as? UpdateState.Downloading
|
||||||
|
ShxButton(
|
||||||
|
if (downloading != null) stringResource(R.string.update_downloading_fmt, downloading.progress) else stringResource(R.string.updates_review),
|
||||||
|
{ UpdateSheets.show(if (downloading != null) UpdateSheet.ATTEMPT else UpdateSheet.REVIEW) },
|
||||||
|
height = 52.dp, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.updates_section_auto))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
var autoCheck by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) }
|
||||||
|
var silent by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) }
|
||||||
|
val hours = remember { manager.checkIntervalHours().toInt() }
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.updates_auto_check), autoCheck,
|
||||||
|
{ autoCheck = it; prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() },
|
||||||
|
subtitle = pluralStringResource(R.plurals.updates_auto_check_sub, hours, hours), divider = true,
|
||||||
|
)
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.updates_silent), silent,
|
||||||
|
{ silent = it; prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() },
|
||||||
|
subtitle = stringResource(R.string.updates_silent_sub),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.updates_section_channel))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxChoiceCard(
|
||||||
|
stringResource(R.string.updates_channel_stable), stringResource(R.string.updates_channel_stable_sub), !beta,
|
||||||
|
{
|
||||||
|
if (beta)
|
||||||
|
{
|
||||||
|
beta = false
|
||||||
|
manager.setChannel(beta = false, token = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ShxChoiceCard(
|
||||||
|
stringResource(R.string.updates_channel_beta), stringResource(R.string.updates_channel_beta_sub), beta,
|
||||||
|
{ if (!beta) askCode = true },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.updates_channel_hint), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
|
||||||
|
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||||
|
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.updates_check_now), { manager.checkNow() },
|
||||||
|
style = ShxButtonStyle.SOFT, height = 52.dp, enabled = !busy, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
val last = remember(state) { prefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) }
|
||||||
|
Text(
|
||||||
|
when (val s = state)
|
||||||
|
{
|
||||||
|
is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||||
|
is UpdateState.Failed -> stringResource(updateErrorText(s.error))
|
||||||
|
else -> if (last > 0) stringResource(R.string.updates_last_check, lastCheckText(context, last))
|
||||||
|
else stringResource(R.string.updates_never_checked)
|
||||||
|
},
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub,
|
||||||
|
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (askCode)
|
||||||
|
{
|
||||||
|
BetaCodeSheet(
|
||||||
|
onEnable = { code ->
|
||||||
|
askCode = false
|
||||||
|
beta = true
|
||||||
|
manager.setChannel(beta = true, token = code)
|
||||||
|
},
|
||||||
|
onDismiss = { askCode = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invitation code of the beta channel. */
|
||||||
|
@Composable
|
||||||
|
private fun BetaCodeSheet(onEnable: (String) -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
ShxSheetTitle(stringResource(R.string.updates_beta_title))
|
||||||
|
ShxIntro(stringResource(R.string.updates_beta_text))
|
||||||
|
ShxFormField(label = stringResource(R.string.updates_beta_code), value = code, onValueChange = { code = it }, mono = true)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.updates_beta_enable), { onEnable(code.trim()) }, enabled = code.isNotBlank(), modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «A new version is available» (App2): version, what is new from the release notes, Later / Download and install. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateReviewSheet(info: UpdateInfo, onInstall: () -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val locale = LocalConfiguration.current.locales[0]
|
||||||
|
val lines = noteLines(pickNotes(info.notes, "${locale.language}-${locale.country}", locale.language))
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
UpdateSheetHeader(
|
||||||
|
"SHX VPN ${info.versionName}",
|
||||||
|
stringResource(R.string.updates_review_sub, BuildConfig.VERSION_NAME, sizeText(info.size)),
|
||||||
|
)
|
||||||
|
if (lines.isNotEmpty())
|
||||||
|
{
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(ShxSpacing.lg))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section, color = c.accentText)
|
||||||
|
lines.forEach { Bullet(it, c.accent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdateSafeNote(stringResource(R.string.updates_review_safe))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.cert_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.update_download_install), onInstall, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download (App3): progress and the four steps of the update. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateProgressContent(state: UpdateState, onCancel: () -> Unit, onInstall: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val info = (state as? UpdateState.Downloading)?.info ?: (state as? UpdateState.Available)?.info
|
||||||
|
val progress = (state as? UpdateState.Downloading)?.progress ?: 0
|
||||||
|
val downloading = state is UpdateState.Downloading
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_downloading_title, info?.versionName ?: ""),
|
||||||
|
stringResource(R.string.updates_downloading_sub),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Row(Modifier.fillMaxWidth())
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.update_card_downloading), style = ShxType.chip, color = c.textSoft, modifier = Modifier.weight(1f))
|
||||||
|
if (info != null && info.size > 0)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.updates_progress, progress, sizeText(info.size * progress / 100), sizeText(info.size)),
|
||||||
|
style = ShxType.chip, color = c.textSoft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.cardBorder))
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth(progress / 100f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* steps: the list is known once a version is shown; at 100% the file is being checked */
|
||||||
|
val verifying = downloading && progress >= 100
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 6.dp))
|
||||||
|
{
|
||||||
|
UpdateStep(StepState.DONE, stringResource(R.string.updates_step_list), stringResource(R.string.updates_step_list_sub), divider = true)
|
||||||
|
UpdateStep(
|
||||||
|
if (verifying) StepState.DONE else StepState.RUNNING, stringResource(R.string.updates_step_download),
|
||||||
|
if (info != null && info.size > 0) sizeText(info.size) else stringResource(R.string.update_card_downloading), divider = true,
|
||||||
|
)
|
||||||
|
UpdateStep(if (verifying) StepState.RUNNING else StepState.WAITING, stringResource(R.string.updates_step_verify), stringResource(R.string.updates_step_verify_sub), divider = true)
|
||||||
|
UpdateStep(StepState.WAITING, stringResource(R.string.updates_step_install), stringResource(R.string.updates_step_install_sub), divider = false)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.updates_cancel), onCancel, style = ShxButtonStyle.OUTLINE, enabled = downloading, modifier = Modifier.weight(1f))
|
||||||
|
/* while downloading the app installs on its own; Available here means a background download
|
||||||
|
* finished or failed without an install request, so the button starts it */
|
||||||
|
ShxButton(stringResource(R.string.update_card_install), onInstall, enabled = state is UpdateState.Available, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class StepState { DONE, RUNNING, WAITING }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UpdateStep(step: StepState, title: String, sub: String, divider: Boolean)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column()
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = ShxSpacing.md),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val shape = CircleShape
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(24.dp).clip(shape).then(
|
||||||
|
when (step)
|
||||||
|
{
|
||||||
|
StepState.DONE -> Modifier.background(c.success)
|
||||||
|
StepState.RUNNING -> Modifier.background(c.accentContainer).border(2.dp, c.accent, shape)
|
||||||
|
StepState.WAITING -> Modifier.background(c.card).border(1.dp, c.outline, shape)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
when (step)
|
||||||
|
{
|
||||||
|
StepState.DONE -> Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||||
|
StepState.RUNNING -> Text("…", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||||
|
StepState.WAITING -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (step == StepState.WAITING) c.iconMuted else c.text)
|
||||||
|
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mandatory update (App4): cannot be closed; update now or leave the app. */
|
||||||
|
@Composable
|
||||||
|
private fun MandatoryUpdateSheet(versionName: String, size: Long, onUpdate: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
ShxSheet(onDismiss = {}, dismissible = false)
|
||||||
|
{
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_mandatory_title),
|
||||||
|
stringResource(R.string.updates_mandatory_sub, BuildConfig.VERSION_NAME),
|
||||||
|
icon = ShxIcons.Alert, iconBackground = c.warningContainer, iconTint = c.onWarningContainer, round = true,
|
||||||
|
)
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.updates_mandatory_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Bullet(stringResource(R.string.updates_mandatory_reason), c.warning)
|
||||||
|
Bullet(stringResource(R.string.updates_mandatory_install, versionName, sizeText(size)), c.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.updates_mandatory_locked), tone = ShxTone.WARNING)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.cert_update_now), onUpdate, modifier = Modifier.fillMaxWidth())
|
||||||
|
ShxButton(stringResource(R.string.updates_exit), { activity?.finishAndRemoveTask() }, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update not installed (App5): reasons by error kind, retry, the download page, details for support. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateFailedContent(state: UpdateState.Failed, onRetry: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val checkFailed = state.error == UpdateError.HASH || state.error == UpdateError.SIGNATURE
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_failed_title),
|
||||||
|
stringResource(
|
||||||
|
when (state.error)
|
||||||
|
{
|
||||||
|
UpdateError.HASH, UpdateError.SIGNATURE -> R.string.updates_failed_sub_check
|
||||||
|
UpdateError.NETWORK -> R.string.updates_failed_sub_network
|
||||||
|
UpdateError.INSTALL -> R.string.updates_failed_sub_install
|
||||||
|
UpdateError.OTHER -> R.string.updates_failed_sub_other
|
||||||
|
}
|
||||||
|
),
|
||||||
|
icon = ShxIcons.Shield, iconBackground = c.errorContainer, iconTint = c.error, round = true,
|
||||||
|
)
|
||||||
|
val (title, detail) = when (state.error)
|
||||||
|
{
|
||||||
|
UpdateError.HASH -> R.string.updates_reason_hash to R.string.updates_reason_hash_d
|
||||||
|
UpdateError.SIGNATURE -> R.string.updates_reason_signature to R.string.updates_reason_signature_d
|
||||||
|
UpdateError.NETWORK -> R.string.updates_reason_network to R.string.updates_reason_network_d
|
||||||
|
UpdateError.INSTALL -> R.string.updates_reason_install to R.string.updates_reason_install_d
|
||||||
|
UpdateError.OTHER -> R.string.updates_reason_other to R.string.updates_reason_other_d
|
||||||
|
}
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.updates_failed_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Reason(stringResource(title), stringResource(detail), if (checkFailed) c.error else c.accent)
|
||||||
|
Reason(stringResource(R.string.updates_reason_same), stringResource(R.string.updates_reason_same_d, BuildConfig.VERSION_NAME), c.accent)
|
||||||
|
Reason(stringResource(R.string.updates_reason_later), stringResource(R.string.updates_reason_later_d), c.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.updates_manual_note))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.updates_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.updates_from_site),
|
||||||
|
{ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.UPDATE_DOWNLOAD_PAGE_URL))) } },
|
||||||
|
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.updates_support),
|
||||||
|
style = ShxType.chip, color = c.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(role = Role.Button) { copySupportDetails(context, state) }.padding(horizontal = ShxSpacing.sm, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error kind, message and versions for support; the beta code stays out. */
|
||||||
|
private fun copySupportDetails(context: Context, state: UpdateState.Failed)
|
||||||
|
{
|
||||||
|
val manager = UpdateManager.getInstance(context)
|
||||||
|
val text = buildString {
|
||||||
|
appendLine("SHX VPN update error: ${state.error}")
|
||||||
|
appendLine("Detail: ${state.detail ?: "-"}")
|
||||||
|
appendLine("Installed: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||||
|
appendLine("Channel: ${if (manager.isBetaChannel()) "beta" else "release"}")
|
||||||
|
appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}, ${Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}")
|
||||||
|
}
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("SHX VPN", text))
|
||||||
|
/* Android 13+ confirms the copy itself */
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||||
|
{
|
||||||
|
Toast.makeText(context, R.string.updates_copied, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icon well, sheet title and subtitle of the update sheets. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateSheetHeader(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
iconBackground: Color = LocalShxColors.current.accent,
|
||||||
|
iconTint: Color = Color.White,
|
||||||
|
round: Boolean = false,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val size = if (round) 52.dp else 56.dp
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(if (round) CircleShape else RoundedCornerShape(16.dp)).background(iconBackground))
|
||||||
|
{
|
||||||
|
if (icon != null) Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp))
|
||||||
|
else Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.pageTitle, color = c.text)
|
||||||
|
Text(subtitle, style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green «safe» box with a shield (App1, App2, App6). */
|
||||||
|
@Composable
|
||||||
|
internal fun UpdateSafeNote(text: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = ShxSpacing.md),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||||
|
Text(text, style = ShxType.note, color = c.onSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bullet(text: String, dot: Color)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Title — detail» line of App5. */
|
||||||
|
@Composable
|
||||||
|
private fun Reason(title: String, detail: String, dot: Color)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = c.text)) { append(title) }
|
||||||
|
append(" — ")
|
||||||
|
withStyle(SpanStyle(color = c.textSoft)) { append(detail) }
|
||||||
|
},
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sizeText(bytes: Long): String = if (bytes > 0) formatBytes(bytes) else "—"
|
||||||
|
|
||||||
|
private fun lastCheckText(context: Context, time: Long): String =
|
||||||
|
DateUtils.getRelativeDateTimeString(context, time, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
|
||||||
|
|
||||||
|
internal fun updateErrorText(error: UpdateError): Int = when (error)
|
||||||
|
{
|
||||||
|
UpdateError.NETWORK -> R.string.update_error_network
|
||||||
|
UpdateError.HASH -> R.string.update_error_hash
|
||||||
|
UpdateError.SIGNATURE -> R.string.update_error_signature
|
||||||
|
UpdateError.INSTALL -> R.string.update_error_install
|
||||||
|
UpdateError.OTHER -> R.string.update_error_generic
|
||||||
|
}
|
||||||
+22
-6
@@ -331,7 +331,7 @@ fun ShxButton(
|
|||||||
.background(if (enabled) bg else bg.copy(alpha = 0.38f))
|
.background(if (enabled) bg else bg.copy(alpha = 0.38f))
|
||||||
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
||||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
.padding(horizontal = if (height >= 44.dp) 16.dp else 14.dp),
|
.padding(horizontal = if (height >= 44.dp) 10.dp else 14.dp),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (icon != null)
|
if (icon != null)
|
||||||
@@ -339,7 +339,17 @@ fun ShxButton(
|
|||||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
|
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
Text(text, style = textStyle, color = if (enabled) fg else fg.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
/* two lines on large buttons: on 320dp screens «Скачать и установить» beside «Позже» was cut to «Скачать и установ…» */
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = textStyle,
|
||||||
|
color = if (enabled) fg else fg.copy(alpha = 0.6f),
|
||||||
|
/* a single word must not wrap mid-word («Отмени / ть»): it shrinks instead */
|
||||||
|
maxLines = if (height >= 52.dp && text.trim().contains(' ')) 2 else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = textStyle.fontSize),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,14 +565,18 @@ fun ShxTag(text: String, background: Color, color: Color)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bottom sheet of the mockup: radius 32, 36x4 handle, page background. */
|
/**
|
||||||
|
* Bottom sheet of the mockup: radius 32, 36x4 handle, page background.
|
||||||
|
* [dismissible] = false: swipe, scrim tap and «Back» do not close it (a mandatory update).
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit)
|
||||||
{
|
{
|
||||||
val c = LocalShxColors.current
|
val c = LocalShxColors.current
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (dismissible) onDismiss() },
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }),
|
||||||
|
properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible),
|
||||||
containerColor = c.background,
|
containerColor = c.background,
|
||||||
scrimColor = c.scrim,
|
scrimColor = c.scrim,
|
||||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||||
@@ -571,6 +585,8 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
/* belt and braces for a mandatory sheet: «Back» is swallowed even if the dialog forwards it */
|
||||||
|
androidx.activity.compose.BackHandler(enabled = !dismissible) {}
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||||
|
|||||||
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
{
|
{
|
||||||
active = TRUE;
|
active = TRUE;
|
||||||
this->udp->reprotect(this->udp);
|
this->udp->reprotect(this->udp);
|
||||||
if (this->tcp_active)
|
/* also while lwIP is dormant: this clears the negative cache of
|
||||||
{
|
* failed direct connects, which must not survive a roam */
|
||||||
this->tcp->reprotect(this->tcp);
|
this->tcp->reprotect(this->tcp);
|
||||||
}
|
|
||||||
/* the network changed: whatever was dead for direct connects
|
/* the network changed: whatever was dead for direct connects
|
||||||
* may work again - drop the tunnel fallbacks with the rest of
|
* may work again - drop the tunnel fallbacks with the rest of
|
||||||
* the failure state (tcp->reprotect clears the neg table) */
|
* the failure state (tcp->reprotect clears the neg table) */
|
||||||
@@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
this->bind_lock->unlock(this->bind_lock);
|
this->bind_lock->unlock(this->bind_lock);
|
||||||
|
|
||||||
if (!bound && this->unbound_flushed)
|
if (!bound && this->unbound_flushed)
|
||||||
{ /* TUN still gone and already flushed */
|
{ /* TUN still gone and already flushed: nothing to do until the
|
||||||
|
* re-bind wakes us, so back off like an idle round instead of
|
||||||
|
* polling at the active cadence all through a re-authentication */
|
||||||
drain_queue_nobind(this);
|
drain_queue_nobind(this);
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
poll_ms = ENGINE_POLL_MS;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
poll_ms *= 2;
|
||||||
|
if (poll_ms > ENGINE_IDLE_POLL_MS)
|
||||||
|
{
|
||||||
|
poll_ms = ENGINE_IDLE_POLL_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
{
|
{
|
||||||
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
||||||
this->tcp->check_timers(this->tcp);
|
this->tcp->check_timers(this->tcp);
|
||||||
|
if (!active && this->tcp->flow_count(this->tcp) == 0)
|
||||||
|
{ /* a full idle round with no TCP flow left: let lwIP go dormant
|
||||||
|
* again (the next TCP packet wakes it, its timers catch up),
|
||||||
|
* so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */
|
||||||
|
this->tcp_active = FALSE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{ /* adapt the cadence after the work of this round is known */
|
{ /* adapt the cadence after the work of this round is known */
|
||||||
|
|||||||
@@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void,
|
|||||||
enumerator->destroy(enumerator);
|
enumerator->destroy(enumerator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
METHOD(direct_tcp_t, flow_count, size_t,
|
||||||
|
private_direct_tcp_t *this)
|
||||||
|
{
|
||||||
|
return this->relays->get_count(this->relays);
|
||||||
|
}
|
||||||
|
|
||||||
METHOD(direct_tcp_t, set_mtu, void,
|
METHOD(direct_tcp_t, set_mtu, void,
|
||||||
private_direct_tcp_t *this, uint16_t mtu)
|
private_direct_tcp_t *this, uint16_t mtu)
|
||||||
{
|
{
|
||||||
@@ -1290,6 +1296,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
|
|||||||
.public = {
|
.public = {
|
||||||
.handle_packet = _handle_packet,
|
.handle_packet = _handle_packet,
|
||||||
.check_timers = _check_timers,
|
.check_timers = _check_timers,
|
||||||
|
.flow_count = _flow_count,
|
||||||
.set_mtu = _set_mtu,
|
.set_mtu = _set_mtu,
|
||||||
.reprotect = _reprotect,
|
.reprotect = _reprotect,
|
||||||
.fill_poll = _fill_poll,
|
.fill_poll = _fill_poll,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||||
|
<resources>
|
||||||
|
<string name="report_intro">Der Bericht geht über einen geschützten Kanal an das SHX-Team. Mit dem Berichtscode findet der Support Ihr Protokoll.</string>
|
||||||
|
<string name="report_contents">Inhalt des Berichts</string>
|
||||||
|
<string name="report_item_log">Verbindungsprotokoll von heute</string>
|
||||||
|
<string name="report_item_device">App-Version und Telefonmodell</string>
|
||||||
|
<string name="report_item_network">Netzwerktyp und aktiver Server</string>
|
||||||
|
<string name="report_item_ips">IP-Adressen der Server aus dem Protokoll</string>
|
||||||
|
<string name="report_diag_title">Diagnose anhängen</string>
|
||||||
|
<string name="report_diag_sub">Ergebnisse der Netzwerkprüfung und erweitertes App-Protokoll</string>
|
||||||
|
<string name="report_comment_label">Was ist passiert? (optional)</string>
|
||||||
|
<string name="report_cancel">Abbrechen</string>
|
||||||
|
<string name="report_send">Senden</string>
|
||||||
|
<string name="report_by_mail">Per E-Mail senden</string>
|
||||||
|
<string name="report_sent_title">Bericht gesendet</string>
|
||||||
|
<string name="report_sent_sub">Nennen Sie diesen Code dem Support</string>
|
||||||
|
<string name="report_code_label">Berichtscode</string>
|
||||||
|
<string name="report_copy_code">Code kopieren</string>
|
||||||
|
<string name="report_code_copied">Code kopiert</string>
|
||||||
|
<string name="report_saved_note">Der Code ist unter „Ereignisse“ gespeichert und lässt sich später finden.</string>
|
||||||
|
<string name="report_done">Fertig</string>
|
||||||
|
<string name="report_failed_title">Bericht nicht gesendet</string>
|
||||||
|
<string name="report_failed_no_answer">Der Server hat nicht geantwortet</string>
|
||||||
|
<string name="report_failed_refused">Der Server hat den Bericht abgelehnt</string>
|
||||||
|
<string name="report_reasons">Mögliche Ursachen</string>
|
||||||
|
<string name="report_reason_offline">Kein Internet</string>
|
||||||
|
<string name="report_reason_offline_d">Netzwerk prüfen und wiederholen</string>
|
||||||
|
<string name="report_reason_server">Server nicht erreichbar</string>
|
||||||
|
<string name="report_reason_server_d">in ein paar Minuten wiederholen</string>
|
||||||
|
<string name="report_reason_clock">Telefonuhr geht falsch</string>
|
||||||
|
<string name="report_reason_clock_d">automatische Zeit einschalten und wiederholen</string>
|
||||||
|
<string name="report_reason_often">Zu häufig</string>
|
||||||
|
<string name="report_reason_often_d">in einer Stunde erneut versuchen</string>
|
||||||
|
<string name="report_reason_large">Protokoll zu groß</string>
|
||||||
|
<string name="report_reason_large_d">per E-Mail senden</string>
|
||||||
|
<string name="report_reason_rejected">Anfrage abgelehnt</string>
|
||||||
|
<string name="report_reason_rejected_d">Protokoll per E-Mail senden</string>
|
||||||
|
<string name="report_mail_note">Das Protokoll lässt sich wie bisher auch per E-Mail senden.</string>
|
||||||
|
<string name="report_retry">Wiederholen</string>
|
||||||
|
<string name="report_by_letter">Per E-Mail senden</string>
|
||||||
|
<string name="report_event_title">Bericht gesendet, Code %1$s</string>
|
||||||
|
<string name="report_event_text">Nennen Sie diesen Code dem Support, damit er Ihr Protokoll findet.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- App self-update: the Updates page and the update sheets -->
|
||||||
|
<resources>
|
||||||
|
<string name="updates_have_size">Installiert: %1$s · %2$s</string>
|
||||||
|
<string name="updates_review">Ansehen und installieren</string>
|
||||||
|
<string name="updates_section_auto">Automatisch</string>
|
||||||
|
<string name="updates_auto_check">Selbst prüfen</string>
|
||||||
|
<string name="updates_silent">Still installieren</string>
|
||||||
|
<string name="updates_silent_sub">Über WLAN laden und installieren, wenn das VPN aus ist</string>
|
||||||
|
<string name="updates_section_channel">Kanal</string>
|
||||||
|
<string name="updates_channel_stable">Stabil</string>
|
||||||
|
<string name="updates_channel_stable_sub">Geprüfte Versionen</string>
|
||||||
|
<string name="updates_channel_beta">Beta</string>
|
||||||
|
<string name="updates_channel_beta_sub">Früher, aber mit Fehlern</string>
|
||||||
|
<string name="updates_channel_hint">Die Beta wird mit einem Einladungscode des Administrators freigeschaltet.</string>
|
||||||
|
<string name="updates_beta_title">Einladungscode für die Beta</string>
|
||||||
|
<string name="updates_beta_text">Gib den Code ein, den du vom Administrator erhalten hast. Ohne gültigen Code bleibt die App bei stabilen Versionen.</string>
|
||||||
|
<string name="updates_beta_code">Einladungscode</string>
|
||||||
|
<string name="updates_beta_enable">Beta einschalten</string>
|
||||||
|
<string name="updates_check_now">Jetzt prüfen</string>
|
||||||
|
<string name="updates_last_check">Letzte Prüfung: %1$s</string>
|
||||||
|
<string name="updates_never_checked">Noch nicht geprüft</string>
|
||||||
|
<string name="updates_review_sub">Installiert: %1$s · %2$s · die Datei wird vor der Installation geprüft</string>
|
||||||
|
<string name="updates_review_safe">Die App prüft die Prüfsumme und die SHX-Signatur. Eine gefälschte Datei wird nicht installiert.</string>
|
||||||
|
<string name="updates_downloading_title">%1$s wird geladen</string>
|
||||||
|
<string name="updates_downloading_sub">Du kannst dieses Fenster schließen: Der Download läuft weiter</string>
|
||||||
|
<string name="updates_progress">%1$d%% · %2$s von %3$s</string>
|
||||||
|
<string name="updates_step_list">Versionsliste erhalten</string>
|
||||||
|
<string name="updates_step_list_sub">Über einen geschützten Kanal</string>
|
||||||
|
<string name="updates_step_download">Datei wird geladen</string>
|
||||||
|
<string name="updates_step_verify">Datei wird geprüft</string>
|
||||||
|
<string name="updates_step_verify_sub">Prüfsumme und SHX-Signatur</string>
|
||||||
|
<string name="updates_step_install">Installation</string>
|
||||||
|
<string name="updates_step_install_sub">Android fragt nach einer Bestätigung</string>
|
||||||
|
<string name="updates_cancel">Abbrechen</string>
|
||||||
|
<string name="updates_mandatory_title">SHX VPN muss aktualisiert werden</string>
|
||||||
|
<string name="updates_mandatory_sub">Version %1$s wird nicht mehr unterstützt</string>
|
||||||
|
<string name="updates_mandatory_what">Was das bedeutet</string>
|
||||||
|
<string name="updates_mandatory_reason">Der Server nimmt keine Verbindungen der alten Version mehr an</string>
|
||||||
|
<string name="updates_mandatory_install">Installiere %1$s (%2$s), deine Server und Einstellungen bleiben erhalten</string>
|
||||||
|
<string name="updates_mandatory_locked">Dieses Fenster lässt sich erst nach dem Update schließen.</string>
|
||||||
|
<string name="updates_exit">App beenden</string>
|
||||||
|
<string name="updates_failed_title">Update nicht installiert</string>
|
||||||
|
<string name="updates_failed_sub_check">Die Datei hat die Prüfung nicht bestanden</string>
|
||||||
|
<string name="updates_failed_sub_network">Die Datei wurde nicht geladen</string>
|
||||||
|
<string name="updates_failed_sub_install">Android hat die Datei nicht installiert</string>
|
||||||
|
<string name="updates_failed_sub_other">Etwas ist schiefgelaufen</string>
|
||||||
|
<string name="updates_failed_what">Was passiert ist</string>
|
||||||
|
<string name="updates_reason_hash">Prüfsumme stimmt nicht</string>
|
||||||
|
<string name="updates_reason_hash_d">die Datei wurde unterwegs beschädigt, die Kopie ist gelöscht</string>
|
||||||
|
<string name="updates_reason_signature">Signatur stimmt nicht</string>
|
||||||
|
<string name="updates_reason_signature_d">die Datei ist nicht von SHX signiert, die Kopie ist gelöscht</string>
|
||||||
|
<string name="updates_reason_network">Keine Verbindung zum Update-Server</string>
|
||||||
|
<string name="updates_reason_network_d">der Download wurde unterbrochen</string>
|
||||||
|
<string name="updates_reason_install">Android hat die Installation abgelehnt</string>
|
||||||
|
<string name="updates_reason_install_d">das Installationsprogramm meldete einen Fehler</string>
|
||||||
|
<string name="updates_reason_other">Unerwarteter Fehler</string>
|
||||||
|
<string name="updates_reason_other_d">Details für den Support findest du unten</string>
|
||||||
|
<string name="updates_reason_same">Version unverändert</string>
|
||||||
|
<string name="updates_reason_same_d">du hast weiterhin %1$s</string>
|
||||||
|
<string name="updates_reason_later">Versuche es später erneut</string>
|
||||||
|
<string name="updates_reason_later_d">oder wechsle das Netz: WLAN statt Mobilfunk</string>
|
||||||
|
<string name="updates_manual_note">Du kannst die App manuell über die Download-Seite installieren. Deine Server und Einstellungen bleiben erhalten.</string>
|
||||||
|
<string name="updates_retry">Wiederholen</string>
|
||||||
|
<string name="updates_from_site">Von der Website laden</string>
|
||||||
|
<string name="updates_support">Details für den Support</string>
|
||||||
|
<string name="updates_copied">Kopiert</string>
|
||||||
|
<plurals name="updates_auto_check_sub">
|
||||||
|
<item quantity="one">Alle %d Stunde, während das Telefon lädt</item>
|
||||||
|
<item quantity="other">Alle %d Stunden, während das Telefon lädt</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||||
|
<resources>
|
||||||
|
<string name="report_intro">Raport trafi do zespołu SHX bezpiecznym kanałem. Po kodzie raportu wsparcie znajdzie Twój dziennik.</string>
|
||||||
|
<string name="report_contents">Co trafi do raportu</string>
|
||||||
|
<string name="report_item_log">Dziennik połączenia z dzisiaj</string>
|
||||||
|
<string name="report_item_device">Wersja aplikacji i model telefonu</string>
|
||||||
|
<string name="report_item_network">Typ sieci i aktywny serwer</string>
|
||||||
|
<string name="report_item_ips">Adresy IP serwerów z dziennika</string>
|
||||||
|
<string name="report_diag_title">Dołącz diagnostykę</string>
|
||||||
|
<string name="report_diag_sub">Wyniki sprawdzenia sieci i rozszerzony dziennik aplikacji</string>
|
||||||
|
<string name="report_comment_label">Co się stało (opcjonalnie)</string>
|
||||||
|
<string name="report_cancel">Anuluj</string>
|
||||||
|
<string name="report_send">Wyślij</string>
|
||||||
|
<string name="report_by_mail">Wyślij e-mailem</string>
|
||||||
|
<string name="report_sent_title">Raport wysłany</string>
|
||||||
|
<string name="report_sent_sub">Podaj ten kod wsparciu</string>
|
||||||
|
<string name="report_code_label">Kod raportu</string>
|
||||||
|
<string name="report_copy_code">Kopiuj kod</string>
|
||||||
|
<string name="report_code_copied">Kod skopiowany</string>
|
||||||
|
<string name="report_saved_note">Kod zapisano w „Zdarzeniach”, można go znaleźć później.</string>
|
||||||
|
<string name="report_done">Gotowe</string>
|
||||||
|
<string name="report_failed_title">Raport nie został wysłany</string>
|
||||||
|
<string name="report_failed_no_answer">Serwer nie odpowiedział</string>
|
||||||
|
<string name="report_failed_refused">Serwer odrzucił raport</string>
|
||||||
|
<string name="report_reasons">Możliwe przyczyny</string>
|
||||||
|
<string name="report_reason_offline">Brak internetu</string>
|
||||||
|
<string name="report_reason_offline_d">sprawdź sieć i ponów</string>
|
||||||
|
<string name="report_reason_server">Serwer niedostępny</string>
|
||||||
|
<string name="report_reason_server_d">ponów za kilka minut</string>
|
||||||
|
<string name="report_reason_clock">Zły czas w telefonie</string>
|
||||||
|
<string name="report_reason_clock_d">włącz automatyczny czas i ponów</string>
|
||||||
|
<string name="report_reason_often">Zbyt często</string>
|
||||||
|
<string name="report_reason_often_d">spróbuj za godzinę</string>
|
||||||
|
<string name="report_reason_large">Dziennik jest za duży</string>
|
||||||
|
<string name="report_reason_large_d">wyślij go e-mailem</string>
|
||||||
|
<string name="report_reason_rejected">Żądanie odrzucone</string>
|
||||||
|
<string name="report_reason_rejected_d">wyślij dziennik e-mailem</string>
|
||||||
|
<string name="report_mail_note">Dziennik można też wysłać e-mailem, jak dotąd.</string>
|
||||||
|
<string name="report_retry">Ponów</string>
|
||||||
|
<string name="report_by_letter">Wyślij e-mailem</string>
|
||||||
|
<string name="report_event_title">Raport wysłany, kod %1$s</string>
|
||||||
|
<string name="report_event_text">Podaj ten kod wsparciu, aby znalazło Twój dziennik.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- App self-update: the Updates page and the update sheets -->
|
||||||
|
<resources>
|
||||||
|
<string name="updates_have_size">Masz %1$s · %2$s</string>
|
||||||
|
<string name="updates_review">Zobacz i zainstaluj</string>
|
||||||
|
<string name="updates_section_auto">Automatycznie</string>
|
||||||
|
<string name="updates_auto_check">Sprawdzaj samodzielnie</string>
|
||||||
|
<string name="updates_silent">Instaluj po cichu</string>
|
||||||
|
<string name="updates_silent_sub">Pobierz przez Wi-Fi i zainstaluj, gdy VPN jest wyłączony</string>
|
||||||
|
<string name="updates_section_channel">Kanał</string>
|
||||||
|
<string name="updates_channel_stable">Stabilny</string>
|
||||||
|
<string name="updates_channel_stable_sub">Sprawdzone wersje</string>
|
||||||
|
<string name="updates_channel_beta">Beta</string>
|
||||||
|
<string name="updates_channel_beta_sub">Wcześniej, ale z błędami</string>
|
||||||
|
<string name="updates_channel_hint">Beta jest dostępna z kodem zaproszenia od administratora.</string>
|
||||||
|
<string name="updates_beta_title">Kod zaproszenia do bety</string>
|
||||||
|
<string name="updates_beta_text">Wpisz kod od administratora. Bez prawidłowego kodu aplikacja zostanie na stabilnych wersjach.</string>
|
||||||
|
<string name="updates_beta_code">Kod zaproszenia</string>
|
||||||
|
<string name="updates_beta_enable">Włącz betę</string>
|
||||||
|
<string name="updates_check_now">Sprawdź teraz</string>
|
||||||
|
<string name="updates_last_check">Ostatnie sprawdzenie: %1$s</string>
|
||||||
|
<string name="updates_never_checked">Jeszcze nie sprawdzano</string>
|
||||||
|
<string name="updates_review_sub">Masz %1$s · %2$s · plik sprawdzimy przed instalacją</string>
|
||||||
|
<string name="updates_review_safe">Aplikacja sprawdzi sumę kontrolną i podpis SHX. Podrobiony plik się nie zainstaluje.</string>
|
||||||
|
<string name="updates_downloading_title">Pobieramy %1$s</string>
|
||||||
|
<string name="updates_downloading_sub">Możesz zamknąć to okno: pobieranie będzie trwać</string>
|
||||||
|
<string name="updates_progress">%1$d%% · %2$s z %3$s</string>
|
||||||
|
<string name="updates_step_list">Lista wersji pobrana</string>
|
||||||
|
<string name="updates_step_list_sub">Przez chroniony kanał</string>
|
||||||
|
<string name="updates_step_download">Pobieranie pliku</string>
|
||||||
|
<string name="updates_step_verify">Sprawdzanie pliku</string>
|
||||||
|
<string name="updates_step_verify_sub">Suma kontrolna i podpis SHX</string>
|
||||||
|
<string name="updates_step_install">Instalacja</string>
|
||||||
|
<string name="updates_step_install_sub">Android poprosi o potwierdzenie</string>
|
||||||
|
<string name="updates_cancel">Anuluj</string>
|
||||||
|
<string name="updates_mandatory_title">SHX VPN wymaga aktualizacji</string>
|
||||||
|
<string name="updates_mandatory_sub">Wersja %1$s nie jest już obsługiwana</string>
|
||||||
|
<string name="updates_mandatory_what">Co to oznacza</string>
|
||||||
|
<string name="updates_mandatory_reason">Serwer nie przyjmuje już połączeń ze starej wersji</string>
|
||||||
|
<string name="updates_mandatory_install">Zainstaluj %1$s (%2$s), Twoje serwery i ustawienia zostaną zachowane</string>
|
||||||
|
<string name="updates_mandatory_locked">Tego okna nie można zamknąć, dopóki aplikacja nie zostanie zaktualizowana.</string>
|
||||||
|
<string name="updates_exit">Zamknij aplikację</string>
|
||||||
|
<string name="updates_failed_title">Aktualizacja nie została zainstalowana</string>
|
||||||
|
<string name="updates_failed_sub_check">Plik nie przeszedł weryfikacji</string>
|
||||||
|
<string name="updates_failed_sub_network">Nie udało się pobrać pliku</string>
|
||||||
|
<string name="updates_failed_sub_install">Android nie zainstalował pliku</string>
|
||||||
|
<string name="updates_failed_sub_other">Coś poszło nie tak</string>
|
||||||
|
<string name="updates_failed_what">Co się stało</string>
|
||||||
|
<string name="updates_reason_hash">Suma kontrolna się nie zgadza</string>
|
||||||
|
<string name="updates_reason_hash_d">plik uszkodził się po drodze, kopia została usunięta</string>
|
||||||
|
<string name="updates_reason_signature">Podpis się nie zgadza</string>
|
||||||
|
<string name="updates_reason_signature_d">plik nie jest podpisany przez SHX, kopia została usunięta</string>
|
||||||
|
<string name="updates_reason_network">Brak połączenia z serwerem aktualizacji</string>
|
||||||
|
<string name="updates_reason_network_d">pobieranie zostało przerwane</string>
|
||||||
|
<string name="updates_reason_install">Android odrzucił instalację</string>
|
||||||
|
<string name="updates_reason_install_d">instalator systemowy zwrócił błąd</string>
|
||||||
|
<string name="updates_reason_other">Nieoczekiwany błąd</string>
|
||||||
|
<string name="updates_reason_other_d">szczegóły dla wsparcia znajdziesz poniżej</string>
|
||||||
|
<string name="updates_reason_same">Wersja się nie zmieniła</string>
|
||||||
|
<string name="updates_reason_same_d">nadal masz %1$s</string>
|
||||||
|
<string name="updates_reason_later">Spróbuj później</string>
|
||||||
|
<string name="updates_reason_later_d">lub zmień sieć: Wi-Fi zamiast komórkowej</string>
|
||||||
|
<string name="updates_manual_note">Możesz zainstalować ręcznie ze strony pobierania. Twoje serwery i ustawienia pozostaną bez zmian.</string>
|
||||||
|
<string name="updates_retry">Ponów</string>
|
||||||
|
<string name="updates_from_site">Pobierz ze strony</string>
|
||||||
|
<string name="updates_support">Szczegóły dla wsparcia</string>
|
||||||
|
<string name="updates_copied">Skopiowano</string>
|
||||||
|
<plurals name="updates_auto_check_sub">
|
||||||
|
<item quantity="one">Co %d godzinę, gdy telefon się ładuje</item>
|
||||||
|
<item quantity="few">Co %d godziny, gdy telefon się ładuje</item>
|
||||||
|
<item quantity="many">Co %d godzin, gdy telefon się ładuje</item>
|
||||||
|
<item quantity="other">Co %d godziny, gdy telefon się ładuje</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||||
|
<resources>
|
||||||
|
<string name="report_intro">Отчёт уйдёт команде SHX по защищённому каналу. По коду отчёта поддержка найдёт ваш журнал.</string>
|
||||||
|
<string name="report_contents">Что войдёт в отчёт</string>
|
||||||
|
<string name="report_item_log">Журнал подключения за сегодня</string>
|
||||||
|
<string name="report_item_device">Версия приложения и модель телефона</string>
|
||||||
|
<string name="report_item_network">Тип сети и активный сервер</string>
|
||||||
|
<string name="report_item_ips">IP-адреса серверов из журнала</string>
|
||||||
|
<string name="report_diag_title">Приложить диагностику</string>
|
||||||
|
<string name="report_diag_sub">Результаты проверки сети и расширенный журнал приложения</string>
|
||||||
|
<string name="report_comment_label">Что случилось (необязательно)</string>
|
||||||
|
<string name="report_cancel">Отмена</string>
|
||||||
|
<string name="report_send">Отправить</string>
|
||||||
|
<string name="report_by_mail">Отправить по почте</string>
|
||||||
|
<string name="report_sent_title">Отчёт отправлен</string>
|
||||||
|
<string name="report_sent_sub">Назовите этот код поддержке</string>
|
||||||
|
<string name="report_code_label">Код отчёта</string>
|
||||||
|
<string name="report_copy_code">Скопировать код</string>
|
||||||
|
<string name="report_code_copied">Код скопирован</string>
|
||||||
|
<string name="report_saved_note">Код сохранён в «Событиях», его можно найти позже.</string>
|
||||||
|
<string name="report_done">Готово</string>
|
||||||
|
<string name="report_failed_title">Отчёт не отправлен</string>
|
||||||
|
<string name="report_failed_no_answer">Сервер не ответил</string>
|
||||||
|
<string name="report_failed_refused">Сервер отклонил отчёт</string>
|
||||||
|
<string name="report_reasons">Возможные причины</string>
|
||||||
|
<string name="report_reason_offline">Нет интернета</string>
|
||||||
|
<string name="report_reason_offline_d">проверьте сеть и повторите</string>
|
||||||
|
<string name="report_reason_server">Сервер недоступен</string>
|
||||||
|
<string name="report_reason_server_d">повторите через несколько минут</string>
|
||||||
|
<string name="report_reason_clock">Часы телефона сбиты</string>
|
||||||
|
<string name="report_reason_clock_d">включите автоматическое время и повторите</string>
|
||||||
|
<string name="report_reason_often">Слишком часто</string>
|
||||||
|
<string name="report_reason_often_d">попробуйте через час</string>
|
||||||
|
<string name="report_reason_large">Журнал слишком большой</string>
|
||||||
|
<string name="report_reason_large_d">отправьте его письмом</string>
|
||||||
|
<string name="report_reason_rejected">Запрос отклонён</string>
|
||||||
|
<string name="report_reason_rejected_d">отправьте журнал письмом</string>
|
||||||
|
<string name="report_mail_note">Журнал можно отправить и письмом, как раньше.</string>
|
||||||
|
<string name="report_retry">Повторить</string>
|
||||||
|
<string name="report_by_letter">Отправить письмом</string>
|
||||||
|
<string name="report_event_title">Отчёт отправлен, код %1$s</string>
|
||||||
|
<string name="report_event_text">Назовите этот код поддержке, чтобы она нашла ваш журнал.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- App self-update: the Updates page and the update sheets -->
|
||||||
|
<resources>
|
||||||
|
<string name="updates_have_size">У вас %1$s · %2$s</string>
|
||||||
|
<string name="updates_review">Посмотреть и установить</string>
|
||||||
|
<string name="updates_section_auto">Автоматически</string>
|
||||||
|
<string name="updates_auto_check">Проверять сами</string>
|
||||||
|
<string name="updates_silent">Ставить тихо</string>
|
||||||
|
<string name="updates_silent_sub">Скачать по Wi-Fi и установить, когда VPN выключен</string>
|
||||||
|
<string name="updates_section_channel">Канал</string>
|
||||||
|
<string name="updates_channel_stable">Стабильный</string>
|
||||||
|
<string name="updates_channel_stable_sub">Проверенные версии</string>
|
||||||
|
<string name="updates_channel_beta">Бета</string>
|
||||||
|
<string name="updates_channel_beta_sub">Раньше, но с ошибками</string>
|
||||||
|
<string name="updates_channel_hint">Бета открывается по коду приглашения от администратора.</string>
|
||||||
|
<string name="updates_beta_title">Код приглашения в бету</string>
|
||||||
|
<string name="updates_beta_text">Введите код, который дал администратор. Без верного кода приложение останется на стабильных версиях.</string>
|
||||||
|
<string name="updates_beta_code">Код приглашения</string>
|
||||||
|
<string name="updates_beta_enable">Включить бету</string>
|
||||||
|
<string name="updates_check_now">Проверить сейчас</string>
|
||||||
|
<string name="updates_last_check">Последняя проверка: %1$s</string>
|
||||||
|
<string name="updates_never_checked">Ещё не проверяли</string>
|
||||||
|
<string name="updates_review_sub">У вас %1$s · %2$s · файл проверим перед установкой</string>
|
||||||
|
<string name="updates_review_safe">Приложение сверит контрольную сумму и подпись SHX. Подделанный файл не установится.</string>
|
||||||
|
<string name="updates_downloading_title">Скачиваем %1$s</string>
|
||||||
|
<string name="updates_downloading_sub">Можно закрыть шторку: загрузка продолжится</string>
|
||||||
|
<string name="updates_progress">%1$d%% · %2$s из %3$s</string>
|
||||||
|
<string name="updates_step_list">Список версий получен</string>
|
||||||
|
<string name="updates_step_list_sub">По защищённому каналу</string>
|
||||||
|
<string name="updates_step_download">Скачивание файла</string>
|
||||||
|
<string name="updates_step_verify">Проверка файла</string>
|
||||||
|
<string name="updates_step_verify_sub">Контрольная сумма и подпись SHX</string>
|
||||||
|
<string name="updates_step_install">Установка</string>
|
||||||
|
<string name="updates_step_install_sub">Android спросит подтверждение</string>
|
||||||
|
<string name="updates_cancel">Отменить</string>
|
||||||
|
<string name="updates_mandatory_title">Нужно обновить SHX VPN</string>
|
||||||
|
<string name="updates_mandatory_sub">Версия %1$s больше не поддерживается</string>
|
||||||
|
<string name="updates_mandatory_what">Что это значит</string>
|
||||||
|
<string name="updates_mandatory_reason">Сервер больше не принимает подключения со старой версии</string>
|
||||||
|
<string name="updates_mandatory_install">Установите %1$s (%2$s), ваши серверы и настройки сохранятся</string>
|
||||||
|
<string name="updates_mandatory_locked">Закрыть эту шторку нельзя, пока приложение не обновлено.</string>
|
||||||
|
<string name="updates_exit">Выйти из приложения</string>
|
||||||
|
<string name="updates_failed_title">Обновление не установлено</string>
|
||||||
|
<string name="updates_failed_sub_check">Файл не прошёл проверку</string>
|
||||||
|
<string name="updates_failed_sub_network">Файл не скачался</string>
|
||||||
|
<string name="updates_failed_sub_install">Android не установил файл</string>
|
||||||
|
<string name="updates_failed_sub_other">Что-то пошло не так</string>
|
||||||
|
<string name="updates_failed_what">Что произошло</string>
|
||||||
|
<string name="updates_reason_hash">Контрольная сумма не совпала</string>
|
||||||
|
<string name="updates_reason_hash_d">файл повреждён по дороге, копия удалена</string>
|
||||||
|
<string name="updates_reason_signature">Подпись не совпала</string>
|
||||||
|
<string name="updates_reason_signature_d">файл подписан не SHX, копия удалена</string>
|
||||||
|
<string name="updates_reason_network">Нет связи с сервером обновлений</string>
|
||||||
|
<string name="updates_reason_network_d">загрузка прервалась</string>
|
||||||
|
<string name="updates_reason_install">Android отклонил установку</string>
|
||||||
|
<string name="updates_reason_install_d">системный установщик вернул ошибку</string>
|
||||||
|
<string name="updates_reason_other">Непредвиденная ошибка</string>
|
||||||
|
<string name="updates_reason_other_d">подробности для поддержки — ниже</string>
|
||||||
|
<string name="updates_reason_same">Версия не изменилась</string>
|
||||||
|
<string name="updates_reason_same_d">у вас по-прежнему %1$s</string>
|
||||||
|
<string name="updates_reason_later">Попробуйте позже</string>
|
||||||
|
<string name="updates_reason_later_d">или смените сеть: Wi-Fi вместо мобильной</string>
|
||||||
|
<string name="updates_manual_note">Установить вручную можно со страницы скачивания. Ваши серверы и настройки останутся на месте.</string>
|
||||||
|
<string name="updates_retry">Повторить</string>
|
||||||
|
<string name="updates_from_site">Скачать с сайта</string>
|
||||||
|
<string name="updates_support">Подробности для поддержки</string>
|
||||||
|
<string name="updates_copied">Скопировано</string>
|
||||||
|
<plurals name="updates_auto_check_sub">
|
||||||
|
<item quantity="one">Раз в %d час, когда телефон на зарядке</item>
|
||||||
|
<item quantity="few">Раз в %d часа, когда телефон на зарядке</item>
|
||||||
|
<item quantity="many">Раз в %d часов, когда телефон на зарядке</item>
|
||||||
|
<item quantity="other">Раз в %d часа, когда телефон на зарядке</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||||
|
<resources>
|
||||||
|
<string name="report_intro">Звіт піде команді SHX захищеним каналом. За кодом звіту підтримка знайде ваш журнал.</string>
|
||||||
|
<string name="report_contents">Що увійде у звіт</string>
|
||||||
|
<string name="report_item_log">Журнал підключення за сьогодні</string>
|
||||||
|
<string name="report_item_device">Версія застосунку й модель телефона</string>
|
||||||
|
<string name="report_item_network">Тип мережі й активний сервер</string>
|
||||||
|
<string name="report_item_ips">IP-адреси серверів із журналу</string>
|
||||||
|
<string name="report_diag_title">Додати діагностику</string>
|
||||||
|
<string name="report_diag_sub">Результати перевірки мережі та розширений журнал застосунку</string>
|
||||||
|
<string name="report_comment_label">Що сталося (необов’язково)</string>
|
||||||
|
<string name="report_cancel">Скасувати</string>
|
||||||
|
<string name="report_send">Надіслати</string>
|
||||||
|
<string name="report_by_mail">Надіслати поштою</string>
|
||||||
|
<string name="report_sent_title">Звіт надіслано</string>
|
||||||
|
<string name="report_sent_sub">Назвіть цей код підтримці</string>
|
||||||
|
<string name="report_code_label">Код звіту</string>
|
||||||
|
<string name="report_copy_code">Скопіювати код</string>
|
||||||
|
<string name="report_code_copied">Код скопійовано</string>
|
||||||
|
<string name="report_saved_note">Код збережено в «Подіях», його можна знайти пізніше.</string>
|
||||||
|
<string name="report_done">Готово</string>
|
||||||
|
<string name="report_failed_title">Звіт не надіслано</string>
|
||||||
|
<string name="report_failed_no_answer">Сервер не відповів</string>
|
||||||
|
<string name="report_failed_refused">Сервер відхилив звіт</string>
|
||||||
|
<string name="report_reasons">Можливі причини</string>
|
||||||
|
<string name="report_reason_offline">Немає інтернету</string>
|
||||||
|
<string name="report_reason_offline_d">перевірте мережу й повторіть</string>
|
||||||
|
<string name="report_reason_server">Сервер недоступний</string>
|
||||||
|
<string name="report_reason_server_d">повторіть за кілька хвилин</string>
|
||||||
|
<string name="report_reason_clock">Годинник телефона збитий</string>
|
||||||
|
<string name="report_reason_clock_d">увімкніть автоматичний час і повторіть</string>
|
||||||
|
<string name="report_reason_often">Надто часто</string>
|
||||||
|
<string name="report_reason_often_d">спробуйте за годину</string>
|
||||||
|
<string name="report_reason_large">Журнал завеликий</string>
|
||||||
|
<string name="report_reason_large_d">надішліть його листом</string>
|
||||||
|
<string name="report_reason_rejected">Запит відхилено</string>
|
||||||
|
<string name="report_reason_rejected_d">надішліть журнал листом</string>
|
||||||
|
<string name="report_mail_note">Журнал можна надіслати й листом, як раніше.</string>
|
||||||
|
<string name="report_retry">Повторити</string>
|
||||||
|
<string name="report_by_letter">Надіслати листом</string>
|
||||||
|
<string name="report_event_title">Звіт надіслано, код %1$s</string>
|
||||||
|
<string name="report_event_text">Назвіть цей код підтримці, щоб вона знайшла ваш журнал.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- App self-update: the Updates page and the update sheets -->
|
||||||
|
<resources>
|
||||||
|
<string name="updates_have_size">У вас %1$s · %2$s</string>
|
||||||
|
<string name="updates_review">Переглянути й установити</string>
|
||||||
|
<string name="updates_section_auto">Автоматично</string>
|
||||||
|
<string name="updates_auto_check">Перевіряти самостійно</string>
|
||||||
|
<string name="updates_silent">Установлювати тихо</string>
|
||||||
|
<string name="updates_silent_sub">Завантажити через Wi-Fi й установити, коли VPN вимкнено</string>
|
||||||
|
<string name="updates_section_channel">Канал</string>
|
||||||
|
<string name="updates_channel_stable">Стабільний</string>
|
||||||
|
<string name="updates_channel_stable_sub">Перевірені версії</string>
|
||||||
|
<string name="updates_channel_beta">Бета</string>
|
||||||
|
<string name="updates_channel_beta_sub">Раніше, але з помилками</string>
|
||||||
|
<string name="updates_channel_hint">Бета відкривається за кодом запрошення від адміністратора.</string>
|
||||||
|
<string name="updates_beta_title">Код запрошення до бети</string>
|
||||||
|
<string name="updates_beta_text">Введіть код, який дав адміністратор. Без правильного коду застосунок залишиться на стабільних версіях.</string>
|
||||||
|
<string name="updates_beta_code">Код запрошення</string>
|
||||||
|
<string name="updates_beta_enable">Увімкнути бету</string>
|
||||||
|
<string name="updates_check_now">Перевірити зараз</string>
|
||||||
|
<string name="updates_last_check">Остання перевірка: %1$s</string>
|
||||||
|
<string name="updates_never_checked">Ще не перевіряли</string>
|
||||||
|
<string name="updates_review_sub">У вас %1$s · %2$s · файл перевіримо перед установленням</string>
|
||||||
|
<string name="updates_review_safe">Застосунок звірить контрольну суму й підпис SHX. Підроблений файл не встановиться.</string>
|
||||||
|
<string name="updates_downloading_title">Завантажуємо %1$s</string>
|
||||||
|
<string name="updates_downloading_sub">Можна закрити шторку: завантаження триватиме</string>
|
||||||
|
<string name="updates_progress">%1$d%% · %2$s з %3$s</string>
|
||||||
|
<string name="updates_step_list">Список версій отримано</string>
|
||||||
|
<string name="updates_step_list_sub">Через захищений канал</string>
|
||||||
|
<string name="updates_step_download">Завантаження файлу</string>
|
||||||
|
<string name="updates_step_verify">Перевірка файлу</string>
|
||||||
|
<string name="updates_step_verify_sub">Контрольна сума й підпис SHX</string>
|
||||||
|
<string name="updates_step_install">Установлення</string>
|
||||||
|
<string name="updates_step_install_sub">Android попросить підтвердження</string>
|
||||||
|
<string name="updates_cancel">Скасувати</string>
|
||||||
|
<string name="updates_mandatory_title">Потрібно оновити SHX VPN</string>
|
||||||
|
<string name="updates_mandatory_sub">Версія %1$s більше не підтримується</string>
|
||||||
|
<string name="updates_mandatory_what">Що це означає</string>
|
||||||
|
<string name="updates_mandatory_reason">Сервер більше не приймає підключення зі старої версії</string>
|
||||||
|
<string name="updates_mandatory_install">Установіть %1$s (%2$s), ваші сервери й налаштування збережуться</string>
|
||||||
|
<string name="updates_mandatory_locked">Цю шторку не можна закрити, доки застосунок не оновлено.</string>
|
||||||
|
<string name="updates_exit">Вийти із застосунку</string>
|
||||||
|
<string name="updates_failed_title">Оновлення не встановлено</string>
|
||||||
|
<string name="updates_failed_sub_check">Файл не пройшов перевірку</string>
|
||||||
|
<string name="updates_failed_sub_network">Файл не завантажився</string>
|
||||||
|
<string name="updates_failed_sub_install">Android не встановив файл</string>
|
||||||
|
<string name="updates_failed_sub_other">Щось пішло не так</string>
|
||||||
|
<string name="updates_failed_what">Що сталося</string>
|
||||||
|
<string name="updates_reason_hash">Контрольна сума не збіглася</string>
|
||||||
|
<string name="updates_reason_hash_d">файл пошкоджено дорогою, копію видалено</string>
|
||||||
|
<string name="updates_reason_signature">Підпис не збігся</string>
|
||||||
|
<string name="updates_reason_signature_d">файл підписано не SHX, копію видалено</string>
|
||||||
|
<string name="updates_reason_network">Немає зв’язку із сервером оновлень</string>
|
||||||
|
<string name="updates_reason_network_d">завантаження перервалося</string>
|
||||||
|
<string name="updates_reason_install">Android відхилив установлення</string>
|
||||||
|
<string name="updates_reason_install_d">системний установник повернув помилку</string>
|
||||||
|
<string name="updates_reason_other">Непередбачена помилка</string>
|
||||||
|
<string name="updates_reason_other_d">подробиці для підтримки — нижче</string>
|
||||||
|
<string name="updates_reason_same">Версія не змінилася</string>
|
||||||
|
<string name="updates_reason_same_d">у вас і далі %1$s</string>
|
||||||
|
<string name="updates_reason_later">Спробуйте пізніше</string>
|
||||||
|
<string name="updates_reason_later_d">або змініть мережу: Wi-Fi замість мобільної</string>
|
||||||
|
<string name="updates_manual_note">Установити вручну можна зі сторінки завантаження. Ваші сервери й налаштування залишаться на місці.</string>
|
||||||
|
<string name="updates_retry">Повторити</string>
|
||||||
|
<string name="updates_from_site">Завантажити із сайту</string>
|
||||||
|
<string name="updates_support">Подробиці для підтримки</string>
|
||||||
|
<string name="updates_copied">Скопійовано</string>
|
||||||
|
<plurals name="updates_auto_check_sub">
|
||||||
|
<item quantity="one">Раз на %d годину, коли телефон заряджається</item>
|
||||||
|
<item quantity="few">Раз на %d години, коли телефон заряджається</item>
|
||||||
|
<item quantity="many">Раз на %d годин, коли телефон заряджається</item>
|
||||||
|
<item quantity="other">Раз на %d години, коли телефон заряджається</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||||
|
<resources>
|
||||||
|
<string name="report_intro">报告将通过加密通道发送给 SHX 团队。客服凭报告代码即可找到你的日志。</string>
|
||||||
|
<string name="report_contents">报告包含的内容</string>
|
||||||
|
<string name="report_item_log">今天的连接日志</string>
|
||||||
|
<string name="report_item_device">应用版本和手机型号</string>
|
||||||
|
<string name="report_item_network">网络类型和当前服务器</string>
|
||||||
|
<string name="report_item_ips">日志中的服务器 IP 地址</string>
|
||||||
|
<string name="report_diag_title">附加诊断信息</string>
|
||||||
|
<string name="report_diag_sub">网络检查结果和应用的详细日志</string>
|
||||||
|
<string name="report_comment_label">发生了什么(可选)</string>
|
||||||
|
<string name="report_cancel">取消</string>
|
||||||
|
<string name="report_send">发送</string>
|
||||||
|
<string name="report_by_mail">通过邮件发送</string>
|
||||||
|
<string name="report_sent_title">报告已发送</string>
|
||||||
|
<string name="report_sent_sub">请把此代码告诉客服</string>
|
||||||
|
<string name="report_code_label">报告代码</string>
|
||||||
|
<string name="report_copy_code">复制代码</string>
|
||||||
|
<string name="report_code_copied">代码已复制</string>
|
||||||
|
<string name="report_saved_note">代码已保存在“事件”中,之后可以在那里找到。</string>
|
||||||
|
<string name="report_done">完成</string>
|
||||||
|
<string name="report_failed_title">报告未发送</string>
|
||||||
|
<string name="report_failed_no_answer">服务器没有响应</string>
|
||||||
|
<string name="report_failed_refused">服务器拒绝了报告</string>
|
||||||
|
<string name="report_reasons">可能的原因</string>
|
||||||
|
<string name="report_reason_offline">没有网络</string>
|
||||||
|
<string name="report_reason_offline_d">检查网络后重试</string>
|
||||||
|
<string name="report_reason_server">服务器不可用</string>
|
||||||
|
<string name="report_reason_server_d">几分钟后重试</string>
|
||||||
|
<string name="report_reason_clock">手机时间不准</string>
|
||||||
|
<string name="report_reason_clock_d">开启自动时间后重试</string>
|
||||||
|
<string name="report_reason_often">过于频繁</string>
|
||||||
|
<string name="report_reason_often_d">一小时后再试</string>
|
||||||
|
<string name="report_reason_large">日志过大</string>
|
||||||
|
<string name="report_reason_large_d">请通过邮件发送</string>
|
||||||
|
<string name="report_reason_rejected">请求被拒绝</string>
|
||||||
|
<string name="report_reason_rejected_d">请通过邮件发送日志</string>
|
||||||
|
<string name="report_mail_note">也可以像以前一样通过邮件发送日志。</string>
|
||||||
|
<string name="report_retry">重试</string>
|
||||||
|
<string name="report_by_letter">通过邮件发送</string>
|
||||||
|
<string name="report_event_title">报告已发送,代码 %1$s</string>
|
||||||
|
<string name="report_event_text">把此代码告诉客服,以便找到你的日志。</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- App self-update: the Updates page and the update sheets -->
|
||||||
|
<resources>
|
||||||
|
<string name="updates_have_size">当前 %1$s · %2$s</string>
|
||||||
|
<string name="updates_review">查看并安装</string>
|
||||||
|
<string name="updates_section_auto">自动</string>
|
||||||
|
<string name="updates_auto_check">自动检查</string>
|
||||||
|
<string name="updates_silent">静默安装</string>
|
||||||
|
<string name="updates_silent_sub">通过 Wi-Fi 下载,并在 VPN 关闭时安装</string>
|
||||||
|
<string name="updates_section_channel">渠道</string>
|
||||||
|
<string name="updates_channel_stable">稳定版</string>
|
||||||
|
<string name="updates_channel_stable_sub">经过测试的版本</string>
|
||||||
|
<string name="updates_channel_beta">测试版</string>
|
||||||
|
<string name="updates_channel_beta_sub">更早获得,但可能有错误</string>
|
||||||
|
<string name="updates_channel_hint">测试版需要管理员提供的邀请码。</string>
|
||||||
|
<string name="updates_beta_title">测试版邀请码</string>
|
||||||
|
<string name="updates_beta_text">输入管理员提供的邀请码。没有有效的邀请码时,应用仍使用稳定版。</string>
|
||||||
|
<string name="updates_beta_code">邀请码</string>
|
||||||
|
<string name="updates_beta_enable">启用测试版</string>
|
||||||
|
<string name="updates_check_now">立即检查</string>
|
||||||
|
<string name="updates_last_check">上次检查:%1$s</string>
|
||||||
|
<string name="updates_never_checked">尚未检查</string>
|
||||||
|
<string name="updates_review_sub">当前 %1$s · %2$s · 安装前会校验文件</string>
|
||||||
|
<string name="updates_review_safe">应用会校验校验和与 SHX 签名,伪造的文件无法安装。</string>
|
||||||
|
<string name="updates_downloading_title">正在下载 %1$s</string>
|
||||||
|
<string name="updates_downloading_sub">可以关闭此面板,下载会继续</string>
|
||||||
|
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
|
||||||
|
<string name="updates_step_list">已获取版本列表</string>
|
||||||
|
<string name="updates_step_list_sub">通过受保护的通道</string>
|
||||||
|
<string name="updates_step_download">下载文件</string>
|
||||||
|
<string name="updates_step_verify">校验文件</string>
|
||||||
|
<string name="updates_step_verify_sub">校验和与 SHX 签名</string>
|
||||||
|
<string name="updates_step_install">安装</string>
|
||||||
|
<string name="updates_step_install_sub">Android 会请求确认</string>
|
||||||
|
<string name="updates_cancel">取消</string>
|
||||||
|
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
|
||||||
|
<string name="updates_mandatory_sub">版本 %1$s 已不再受支持</string>
|
||||||
|
<string name="updates_mandatory_what">这意味着什么</string>
|
||||||
|
<string name="updates_mandatory_reason">服务器不再接受旧版本的连接</string>
|
||||||
|
<string name="updates_mandatory_install">安装 %1$s(%2$s),你的服务器和设置会保留</string>
|
||||||
|
<string name="updates_mandatory_locked">应用更新前无法关闭此面板。</string>
|
||||||
|
<string name="updates_exit">退出应用</string>
|
||||||
|
<string name="updates_failed_title">更新未安装</string>
|
||||||
|
<string name="updates_failed_sub_check">文件未通过校验</string>
|
||||||
|
<string name="updates_failed_sub_network">文件未能下载</string>
|
||||||
|
<string name="updates_failed_sub_install">Android 未安装该文件</string>
|
||||||
|
<string name="updates_failed_sub_other">出现问题</string>
|
||||||
|
<string name="updates_failed_what">发生了什么</string>
|
||||||
|
<string name="updates_reason_hash">校验和不匹配</string>
|
||||||
|
<string name="updates_reason_hash_d">文件在传输中损坏,副本已删除</string>
|
||||||
|
<string name="updates_reason_signature">签名不匹配</string>
|
||||||
|
<string name="updates_reason_signature_d">文件不是由 SHX 签名的,副本已删除</string>
|
||||||
|
<string name="updates_reason_network">无法连接更新服务器</string>
|
||||||
|
<string name="updates_reason_network_d">下载已中断</string>
|
||||||
|
<string name="updates_reason_install">Android 拒绝了安装</string>
|
||||||
|
<string name="updates_reason_install_d">系统安装程序返回了错误</string>
|
||||||
|
<string name="updates_reason_other">意外错误</string>
|
||||||
|
<string name="updates_reason_other_d">可在下方复制给支持人员的详细信息</string>
|
||||||
|
<string name="updates_reason_same">版本未变化</string>
|
||||||
|
<string name="updates_reason_same_d">当前仍为 %1$s</string>
|
||||||
|
<string name="updates_reason_later">请稍后再试</string>
|
||||||
|
<string name="updates_reason_later_d">或切换网络:用 Wi-Fi 代替移动数据</string>
|
||||||
|
<string name="updates_manual_note">也可以从下载页面手动安装,你的服务器和设置会保留。</string>
|
||||||
|
<string name="updates_retry">重试</string>
|
||||||
|
<string name="updates_from_site">从网站下载</string>
|
||||||
|
<string name="updates_support">复制给支持人员的详细信息</string>
|
||||||
|
<string name="updates_copied">已复制</string>
|
||||||
|
<plurals name="updates_auto_check_sub">
|
||||||
|
<item quantity="other">手机充电时每 %d 小时检查一次</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||||
|
<resources>
|
||||||
|
<string name="report_intro">報告將透過加密通道傳送給 SHX 團隊。客服憑報告代碼即可找到你的日誌。</string>
|
||||||
|
<string name="report_contents">報告包含的內容</string>
|
||||||
|
<string name="report_item_log">今天的連線日誌</string>
|
||||||
|
<string name="report_item_device">應用程式版本和手機型號</string>
|
||||||
|
<string name="report_item_network">網路類型和目前伺服器</string>
|
||||||
|
<string name="report_item_ips">日誌中的伺服器 IP 位址</string>
|
||||||
|
<string name="report_diag_title">附加診斷資訊</string>
|
||||||
|
<string name="report_diag_sub">網路檢查結果和應用程式的詳細日誌</string>
|
||||||
|
<string name="report_comment_label">發生了什麼(選填)</string>
|
||||||
|
<string name="report_cancel">取消</string>
|
||||||
|
<string name="report_send">傳送</string>
|
||||||
|
<string name="report_by_mail">透過電子郵件傳送</string>
|
||||||
|
<string name="report_sent_title">報告已傳送</string>
|
||||||
|
<string name="report_sent_sub">請把此代碼告訴客服</string>
|
||||||
|
<string name="report_code_label">報告代碼</string>
|
||||||
|
<string name="report_copy_code">複製代碼</string>
|
||||||
|
<string name="report_code_copied">代碼已複製</string>
|
||||||
|
<string name="report_saved_note">代碼已儲存在「事件」中,之後可以在那裡找到。</string>
|
||||||
|
<string name="report_done">完成</string>
|
||||||
|
<string name="report_failed_title">報告未傳送</string>
|
||||||
|
<string name="report_failed_no_answer">伺服器沒有回應</string>
|
||||||
|
<string name="report_failed_refused">伺服器拒絕了報告</string>
|
||||||
|
<string name="report_reasons">可能的原因</string>
|
||||||
|
<string name="report_reason_offline">沒有網路</string>
|
||||||
|
<string name="report_reason_offline_d">檢查網路後重試</string>
|
||||||
|
<string name="report_reason_server">伺服器無法使用</string>
|
||||||
|
<string name="report_reason_server_d">幾分鐘後重試</string>
|
||||||
|
<string name="report_reason_clock">手機時間不準</string>
|
||||||
|
<string name="report_reason_clock_d">開啟自動時間後重試</string>
|
||||||
|
<string name="report_reason_often">過於頻繁</string>
|
||||||
|
<string name="report_reason_often_d">一小時後再試</string>
|
||||||
|
<string name="report_reason_large">日誌過大</string>
|
||||||
|
<string name="report_reason_large_d">請透過電子郵件傳送</string>
|
||||||
|
<string name="report_reason_rejected">要求被拒絕</string>
|
||||||
|
<string name="report_reason_rejected_d">請透過電子郵件傳送日誌</string>
|
||||||
|
<string name="report_mail_note">也可以像以前一樣透過電子郵件傳送日誌。</string>
|
||||||
|
<string name="report_retry">重試</string>
|
||||||
|
<string name="report_by_letter">透過電子郵件傳送</string>
|
||||||
|
<string name="report_event_title">報告已傳送,代碼 %1$s</string>
|
||||||
|
<string name="report_event_text">把此代碼告訴客服,以便找到你的日誌。</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- App self-update: the Updates page and the update sheets -->
|
||||||
|
<resources>
|
||||||
|
<string name="updates_have_size">目前 %1$s · %2$s</string>
|
||||||
|
<string name="updates_review">檢視並安裝</string>
|
||||||
|
<string name="updates_section_auto">自動</string>
|
||||||
|
<string name="updates_auto_check">自動檢查</string>
|
||||||
|
<string name="updates_silent">靜默安裝</string>
|
||||||
|
<string name="updates_silent_sub">透過 Wi-Fi 下載,並在 VPN 關閉時安裝</string>
|
||||||
|
<string name="updates_section_channel">頻道</string>
|
||||||
|
<string name="updates_channel_stable">穩定版</string>
|
||||||
|
<string name="updates_channel_stable_sub">經過測試的版本</string>
|
||||||
|
<string name="updates_channel_beta">測試版</string>
|
||||||
|
<string name="updates_channel_beta_sub">更早取得,但可能有錯誤</string>
|
||||||
|
<string name="updates_channel_hint">測試版需要管理員提供的邀請碼。</string>
|
||||||
|
<string name="updates_beta_title">測試版邀請碼</string>
|
||||||
|
<string name="updates_beta_text">輸入管理員提供的邀請碼。沒有有效的邀請碼時,應用程式仍使用穩定版。</string>
|
||||||
|
<string name="updates_beta_code">邀請碼</string>
|
||||||
|
<string name="updates_beta_enable">啟用測試版</string>
|
||||||
|
<string name="updates_check_now">立即檢查</string>
|
||||||
|
<string name="updates_last_check">上次檢查:%1$s</string>
|
||||||
|
<string name="updates_never_checked">尚未檢查</string>
|
||||||
|
<string name="updates_review_sub">目前 %1$s · %2$s · 安裝前會驗證檔案</string>
|
||||||
|
<string name="updates_review_safe">應用程式會驗證檢查碼與 SHX 簽章,偽造的檔案無法安裝。</string>
|
||||||
|
<string name="updates_downloading_title">正在下載 %1$s</string>
|
||||||
|
<string name="updates_downloading_sub">可以關閉此面板,下載會繼續</string>
|
||||||
|
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
|
||||||
|
<string name="updates_step_list">已取得版本清單</string>
|
||||||
|
<string name="updates_step_list_sub">透過受保護的通道</string>
|
||||||
|
<string name="updates_step_download">下載檔案</string>
|
||||||
|
<string name="updates_step_verify">驗證檔案</string>
|
||||||
|
<string name="updates_step_verify_sub">檢查碼與 SHX 簽章</string>
|
||||||
|
<string name="updates_step_install">安裝</string>
|
||||||
|
<string name="updates_step_install_sub">Android 會要求確認</string>
|
||||||
|
<string name="updates_cancel">取消</string>
|
||||||
|
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
|
||||||
|
<string name="updates_mandatory_sub">版本 %1$s 已不再支援</string>
|
||||||
|
<string name="updates_mandatory_what">這代表什麼</string>
|
||||||
|
<string name="updates_mandatory_reason">伺服器不再接受舊版本的連線</string>
|
||||||
|
<string name="updates_mandatory_install">安裝 %1$s(%2$s),你的伺服器和設定會保留</string>
|
||||||
|
<string name="updates_mandatory_locked">應用程式更新前無法關閉此面板。</string>
|
||||||
|
<string name="updates_exit">結束應用程式</string>
|
||||||
|
<string name="updates_failed_title">更新未安裝</string>
|
||||||
|
<string name="updates_failed_sub_check">檔案未通過驗證</string>
|
||||||
|
<string name="updates_failed_sub_network">檔案未能下載</string>
|
||||||
|
<string name="updates_failed_sub_install">Android 未安裝該檔案</string>
|
||||||
|
<string name="updates_failed_sub_other">發生問題</string>
|
||||||
|
<string name="updates_failed_what">發生了什麼</string>
|
||||||
|
<string name="updates_reason_hash">檢查碼不相符</string>
|
||||||
|
<string name="updates_reason_hash_d">檔案在傳輸中損壞,副本已刪除</string>
|
||||||
|
<string name="updates_reason_signature">簽章不相符</string>
|
||||||
|
<string name="updates_reason_signature_d">檔案不是由 SHX 簽署,副本已刪除</string>
|
||||||
|
<string name="updates_reason_network">無法連線到更新伺服器</string>
|
||||||
|
<string name="updates_reason_network_d">下載已中斷</string>
|
||||||
|
<string name="updates_reason_install">Android 拒絕了安裝</string>
|
||||||
|
<string name="updates_reason_install_d">系統安裝程式傳回了錯誤</string>
|
||||||
|
<string name="updates_reason_other">非預期的錯誤</string>
|
||||||
|
<string name="updates_reason_other_d">可在下方複製給支援人員的詳細資訊</string>
|
||||||
|
<string name="updates_reason_same">版本未變更</string>
|
||||||
|
<string name="updates_reason_same_d">目前仍為 %1$s</string>
|
||||||
|
<string name="updates_reason_later">請稍後再試</string>
|
||||||
|
<string name="updates_reason_later_d">或切換網路:用 Wi-Fi 取代行動數據</string>
|
||||||
|
<string name="updates_manual_note">也可以從下載頁面手動安裝,你的伺服器和設定會保留。</string>
|
||||||
|
<string name="updates_retry">重試</string>
|
||||||
|
<string name="updates_from_site">從網站下載</string>
|
||||||
|
<string name="updates_support">複製給支援人員的詳細資訊</string>
|
||||||
|
<string name="updates_copied">已複製</string>
|
||||||
|
<plurals name="updates_auto_check_sub">
|
||||||
|
<item quantity="other">手機充電時每 %d 小時檢查一次</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||||
|
<resources>
|
||||||
|
<string name="report_intro">The report goes to the SHX team over a secure channel. With the report code, support will find your log.</string>
|
||||||
|
<string name="report_contents">What the report includes</string>
|
||||||
|
<string name="report_item_log">Today’s connection log</string>
|
||||||
|
<string name="report_item_device">App version and phone model</string>
|
||||||
|
<string name="report_item_network">Network type and active server</string>
|
||||||
|
<string name="report_item_ips">Server IP addresses from the log</string>
|
||||||
|
<string name="report_diag_title">Attach diagnostics</string>
|
||||||
|
<string name="report_diag_sub">Network check results and the app’s extended log</string>
|
||||||
|
<string name="report_comment_label">What happened (optional)</string>
|
||||||
|
<string name="report_cancel">Cancel</string>
|
||||||
|
<string name="report_send">Send</string>
|
||||||
|
<string name="report_by_mail">Send by email</string>
|
||||||
|
<string name="report_sent_title">Report sent</string>
|
||||||
|
<string name="report_sent_sub">Tell this code to support</string>
|
||||||
|
<string name="report_code_label">Report code</string>
|
||||||
|
<string name="report_copy_code">Copy code</string>
|
||||||
|
<string name="report_code_copied">Code copied</string>
|
||||||
|
<string name="report_saved_note">The code is saved in “Events”, you can find it there later.</string>
|
||||||
|
<string name="report_done">Done</string>
|
||||||
|
<string name="report_failed_title">Report not sent</string>
|
||||||
|
<string name="report_failed_no_answer">The server did not answer</string>
|
||||||
|
<string name="report_failed_refused">The server refused the report</string>
|
||||||
|
<string name="report_reasons">Possible reasons</string>
|
||||||
|
<string name="report_reason_offline">No internet</string>
|
||||||
|
<string name="report_reason_offline_d">check the network and retry</string>
|
||||||
|
<string name="report_reason_server">Server unavailable</string>
|
||||||
|
<string name="report_reason_server_d">retry in a few minutes</string>
|
||||||
|
<string name="report_reason_clock">Phone clock is off</string>
|
||||||
|
<string name="report_reason_clock_d">turn on automatic time and retry</string>
|
||||||
|
<string name="report_reason_often">Too often</string>
|
||||||
|
<string name="report_reason_often_d">try again in an hour</string>
|
||||||
|
<string name="report_reason_large">Log is too large</string>
|
||||||
|
<string name="report_reason_large_d">send it by email</string>
|
||||||
|
<string name="report_reason_rejected">Request refused</string>
|
||||||
|
<string name="report_reason_rejected_d">send the log by email</string>
|
||||||
|
<string name="report_mail_note">The log can also be sent by email, as before.</string>
|
||||||
|
<string name="report_retry">Retry</string>
|
||||||
|
<string name="report_by_letter">Send by email</string>
|
||||||
|
<string name="report_event_title">Report sent, code %1$s</string>
|
||||||
|
<string name="report_event_text">Tell this code to support so they can find your log.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- App self-update: the Updates page and the update sheets -->
|
||||||
|
<resources>
|
||||||
|
<string name="updates_have_size">You have %1$s · %2$s</string>
|
||||||
|
<string name="updates_review">View and install</string>
|
||||||
|
<string name="updates_section_auto">Automatically</string>
|
||||||
|
<string name="updates_auto_check">Check by itself</string>
|
||||||
|
<string name="updates_silent">Install quietly</string>
|
||||||
|
<string name="updates_silent_sub">Download over Wi-Fi and install while the VPN is off</string>
|
||||||
|
<string name="updates_section_channel">Channel</string>
|
||||||
|
<string name="updates_channel_stable">Stable</string>
|
||||||
|
<string name="updates_channel_stable_sub">Tested versions</string>
|
||||||
|
<string name="updates_channel_beta">Beta</string>
|
||||||
|
<string name="updates_channel_beta_sub">Earlier, but with bugs</string>
|
||||||
|
<string name="updates_channel_hint">Beta opens with an invitation code from the administrator.</string>
|
||||||
|
<string name="updates_beta_title">Beta invitation code</string>
|
||||||
|
<string name="updates_beta_text">Enter the code the administrator gave you. Without a valid code the app stays on stable versions.</string>
|
||||||
|
<string name="updates_beta_code">Invitation code</string>
|
||||||
|
<string name="updates_beta_enable">Turn on beta</string>
|
||||||
|
<string name="updates_check_now">Check now</string>
|
||||||
|
<string name="updates_last_check">Last check: %1$s</string>
|
||||||
|
<string name="updates_never_checked">Not checked yet</string>
|
||||||
|
<string name="updates_review_sub">You have %1$s · %2$s · the file is checked before installing</string>
|
||||||
|
<string name="updates_review_safe">The app checks the checksum and the SHX signature. A forged file will not install.</string>
|
||||||
|
<string name="updates_downloading_title">Downloading %1$s</string>
|
||||||
|
<string name="updates_downloading_sub">You can close this sheet: the download goes on</string>
|
||||||
|
<string name="updates_progress">%1$d%% · %2$s of %3$s</string>
|
||||||
|
<string name="updates_step_list">Version list received</string>
|
||||||
|
<string name="updates_step_list_sub">Over a protected channel</string>
|
||||||
|
<string name="updates_step_download">Downloading the file</string>
|
||||||
|
<string name="updates_step_verify">Checking the file</string>
|
||||||
|
<string name="updates_step_verify_sub">Checksum and SHX signature</string>
|
||||||
|
<string name="updates_step_install">Installation</string>
|
||||||
|
<string name="updates_step_install_sub">Android will ask for confirmation</string>
|
||||||
|
<string name="updates_cancel">Cancel</string>
|
||||||
|
<string name="updates_mandatory_title">SHX VPN needs an update</string>
|
||||||
|
<string name="updates_mandatory_sub">Version %1$s is no longer supported</string>
|
||||||
|
<string name="updates_mandatory_what">What this means</string>
|
||||||
|
<string name="updates_mandatory_reason">The server no longer accepts connections from the old version</string>
|
||||||
|
<string name="updates_mandatory_install">Install %1$s (%2$s): your servers and settings stay</string>
|
||||||
|
<string name="updates_mandatory_locked">This sheet can’t be closed until the app is updated.</string>
|
||||||
|
<string name="updates_exit">Exit the app</string>
|
||||||
|
<string name="updates_failed_title">Update not installed</string>
|
||||||
|
<string name="updates_failed_sub_check">The file did not pass the check</string>
|
||||||
|
<string name="updates_failed_sub_network">The file did not download</string>
|
||||||
|
<string name="updates_failed_sub_install">Android did not install the file</string>
|
||||||
|
<string name="updates_failed_sub_other">Something went wrong</string>
|
||||||
|
<string name="updates_failed_what">What happened</string>
|
||||||
|
<string name="updates_reason_hash">Checksum mismatch</string>
|
||||||
|
<string name="updates_reason_hash_d">the file was damaged on the way, the copy is deleted</string>
|
||||||
|
<string name="updates_reason_signature">Signature mismatch</string>
|
||||||
|
<string name="updates_reason_signature_d">the file is not signed by SHX, the copy is deleted</string>
|
||||||
|
<string name="updates_reason_network">No connection to the update server</string>
|
||||||
|
<string name="updates_reason_network_d">the download was interrupted</string>
|
||||||
|
<string name="updates_reason_install">Android rejected the installation</string>
|
||||||
|
<string name="updates_reason_install_d">the system installer returned an error</string>
|
||||||
|
<string name="updates_reason_other">Unexpected error</string>
|
||||||
|
<string name="updates_reason_other_d">the details for support are below</string>
|
||||||
|
<string name="updates_reason_same">Version unchanged</string>
|
||||||
|
<string name="updates_reason_same_d">you still have %1$s</string>
|
||||||
|
<string name="updates_reason_later">Try again later</string>
|
||||||
|
<string name="updates_reason_later_d">or switch networks: Wi-Fi instead of mobile data</string>
|
||||||
|
<string name="updates_manual_note">You can install it manually from the download page. Your servers and settings stay in place.</string>
|
||||||
|
<string name="updates_retry">Retry</string>
|
||||||
|
<string name="updates_from_site">Download from the site</string>
|
||||||
|
<string name="updates_support">Details for support</string>
|
||||||
|
<string name="updates_copied">Copied</string>
|
||||||
|
<plurals name="updates_auto_check_sub">
|
||||||
|
<item quantity="one">Every %d hour while the phone is charging</item>
|
||||||
|
<item quantity="other">Every %d hours while the phone is charging</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ScheduledJobQueueTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
fun firstJobArmsTheAlarm()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
assertThat(q.add("a", 10_000)).isTrue()
|
||||||
|
assertThat(q.armedAt).isEqualTo(10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun laterJobDoesNotRearm()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
q.add("a", 10_000)
|
||||||
|
assertThat(q.add("b", 50_000)).isFalse()
|
||||||
|
assertThat(q.armedAt).isEqualTo(10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun jobJustBeforeTheArmedAlarmReusesIt()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
q.add("a", 10_000)
|
||||||
|
assertThat(q.add("b", 8_500)).isFalse()
|
||||||
|
assertThat(q.armedAt).isEqualTo(10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearlyEarlierJobRearms()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
q.add("a", 10_000)
|
||||||
|
assertThat(q.add("b", 5_000)).isTrue()
|
||||||
|
assertThat(q.armedAt).isEqualTo(5_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun popDueRunsJobsInOrderIncludingTheCoalescingWindow()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
q.add("c", 11_900)
|
||||||
|
q.add("a", 10_000)
|
||||||
|
q.add("b", 10_500)
|
||||||
|
q.add("late", 30_000)
|
||||||
|
assertThat(q.popDue(10_000)).containsExactly("a", "b", "c")
|
||||||
|
assertThat(q.nextAlarm()).isEqualTo(30_000)
|
||||||
|
assertThat(q.armedAt).isEqualTo(30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun earlyOrSpuriousWakeupKeepsTheQueue()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
q.add("a", 10_000)
|
||||||
|
assertThat(q.popDue(5_000)).isEmpty()
|
||||||
|
assertThat(q.nextAlarm()).isEqualTo(10_000)
|
||||||
|
assertThat(q.armedAt).isEqualTo(10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyQueueLeavesNothingArmed()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
q.add("a", 10_000)
|
||||||
|
assertThat(q.popDue(10_000)).containsExactly("a")
|
||||||
|
assertThat(q.nextAlarm()).isEqualTo(ScheduledJobQueue.NONE)
|
||||||
|
assertThat(q.armedAt).isEqualTo(ScheduledJobQueue.NONE)
|
||||||
|
assertThat(q.add("b", 20_000)).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearForgetsTheArmedAlarm()
|
||||||
|
{
|
||||||
|
val q = ScheduledJobQueue(2000)
|
||||||
|
q.add("a", 10_000)
|
||||||
|
q.clear()
|
||||||
|
assertThat(q.size).isEqualTo(0)
|
||||||
|
assertThat(q.add("b", 40_000)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
import org.strongswan.android.logic.stats.TrafficHistory
|
||||||
|
|
||||||
|
class TrafficHistoryTest
|
||||||
|
{
|
||||||
|
private val every = TrafficHistory.SAVE_EVERY_MS
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleanHistoryIsNeverWritten()
|
||||||
|
{
|
||||||
|
assertThat(TrafficHistory.saveDue(false, 10 * every, 0, true)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changedHistoryWaitsForTheInterval()
|
||||||
|
{
|
||||||
|
assertThat(TrafficHistory.saveDue(true, every - 1, 0, false)).isFalse()
|
||||||
|
assertThat(TrafficHistory.saveDue(true, every, 0, false)).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayChangeWritesAtOnce()
|
||||||
|
{
|
||||||
|
assertThat(TrafficHistory.saveDue(true, 1000, 900, true)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ReportContentTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
fun checksGoBetweenTheLogAndDiagAsAJsonArray()
|
||||||
|
{
|
||||||
|
val checks = listOf(
|
||||||
|
ReportContent.Check("Интернет", "Wi-Fi", true),
|
||||||
|
ReportContent.Check("Сервер \"nl1\"", "нет ответа\n15 с", false),
|
||||||
|
ReportContent.Check("DNS", "проверяется", null),
|
||||||
|
)
|
||||||
|
val text = ReportContent.build("a\n", "d\n", checks)
|
||||||
|
assertThat(text).isEqualTo(
|
||||||
|
"=== charon.log ===\na\n\n=== checks.json ===\n" +
|
||||||
|
"[{\"title\":\"Интернет\",\"value\":\"Wi-Fi\",\"ok\":true}," +
|
||||||
|
"{\"title\":\"Сервер \\\"nl1\\\"\",\"value\":\"нет ответа\\n15 с\",\"ok\":false}," +
|
||||||
|
"{\"title\":\"DNS\",\"value\":\"проверяется\",\"ok\":null}]\n" +
|
||||||
|
"\n=== diag.log ===\nd\n"
|
||||||
|
)
|
||||||
|
assertThat(ReportContent.build("a\n", null, emptyList())).doesNotContain("checks.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun apiBaseAddsTheApiPrefixOnlyWhenMissing()
|
||||||
|
{
|
||||||
|
/* the proxy routes only /api/v1/ and the HMAC covers the full path */
|
||||||
|
assertThat(ReportUploader.apiBase("https://get.shnt.top")).isEqualTo("https://get.shnt.top/api/v1")
|
||||||
|
assertThat(ReportUploader.apiBase("https://get.shnt.top/")).isEqualTo("https://get.shnt.top/api/v1")
|
||||||
|
assertThat(ReportUploader.apiBase("https://get.shnt.top/api/v1/")).isEqualTo("https://get.shnt.top/api/v1")
|
||||||
|
assertThat(java.net.URL(ReportUploader.apiBase("http://127.0.0.1:4000") + "/reports").path).isEqualTo("/api/v1/reports")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseResponseAcceptsOnlyA201WithAValidCode()
|
||||||
|
{
|
||||||
|
assertThat(ReportUploader.parseResponse(201, """{"code":"SHX-7F3K2"}""")).isEqualTo(ReportUploader.Result.Sent("SHX-7F3K2"))
|
||||||
|
val broken = ReportUploader.parseResponse(201, "<html>proxy</html>")
|
||||||
|
assertThat(broken).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||||
|
assertThat((broken as ReportUploader.Result.Failed).httpCode).isEqualTo(201)
|
||||||
|
assertThat(ReportUploader.parseResponse(201, """{"code":"nope"}""")).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||||
|
val limited = ReportUploader.parseResponse(429, """{"error":{"code":"rate_limited"}}""") as ReportUploader.Result.Failed
|
||||||
|
assertThat(limited.httpCode).isEqualTo(429)
|
||||||
|
assertThat(limited.detail).contains("rate_limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failureMapsServerAnswersToReasons()
|
||||||
|
{
|
||||||
|
assertThat(ReportContent.failure(0)).isEqualTo(ReportContent.Failure.NO_NETWORK)
|
||||||
|
assertThat(ReportContent.failure(401)).isEqualTo(ReportContent.Failure.SIGNATURE)
|
||||||
|
assertThat(ReportContent.failure(413)).isEqualTo(ReportContent.Failure.TOO_LARGE)
|
||||||
|
assertThat(ReportContent.failure(429)).isEqualTo(ReportContent.Failure.TOO_OFTEN)
|
||||||
|
assertThat(ReportContent.failure(502)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
|
||||||
|
assertThat(ReportContent.failure(404)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
|
||||||
|
assertThat(ReportContent.failure(400)).isEqualTo(ReportContent.Failure.REJECTED)
|
||||||
|
assertThat(ReportContent.failure(201)).isEqualTo(ReportContent.Failure.REJECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun todayOnlyKeepsTodaysLinesWithTheirContinuations()
|
||||||
|
{
|
||||||
|
val log = """
|
||||||
|
Nov 7 23:59:58 06[IKE] old line
|
||||||
|
old continuation
|
||||||
|
Nov 8 00:00:01 06[IKE] initiating IKE_SA
|
||||||
|
payload continuation
|
||||||
|
Nov 8 14:02:30 07[IKE] CHILD_SA established
|
||||||
|
""".trimIndent()
|
||||||
|
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(
|
||||||
|
"Nov 8 00:00:01 06[IKE] initiating IKE_SA\n payload continuation\nNov 8 14:02:30 07[IKE] CHILD_SA established\n"
|
||||||
|
)
|
||||||
|
assertThat(ReportContent.todayOnly("Dec 18 10:00:00 01[IKE] two-digit day\n", 12, 18)).contains("two-digit day")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun todayOnlyFallsBackToTheWholeLogWhenNothingIsFromToday()
|
||||||
|
{
|
||||||
|
val log = "Nov 7 10:00:00 06[IKE] yesterday\n"
|
||||||
|
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(log)
|
||||||
|
/* a cut log starts with an undated mark: it alone must not count as "today" */
|
||||||
|
val cut = ReportContent.CUT_MARK + log
|
||||||
|
assertThat(ReportContent.todayOnly(cut, 11, 8)).isEqualTo(cut)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tailBytesCutsTheStartAtALineBoundaryAndKeepsCyrillicWhole()
|
||||||
|
{
|
||||||
|
val text = (1..200).joinToString("") { "строка $it: нет ответа\n" }
|
||||||
|
val cut = ReportContent.tailBytes(text, 1000)
|
||||||
|
assertThat(cut).startsWith(ReportContent.CUT_MARK)
|
||||||
|
val body = cut.removePrefix(ReportContent.CUT_MARK)
|
||||||
|
assertThat(body.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(1000)
|
||||||
|
assertThat(body).startsWith("строка ").endsWith("строка 200: нет ответа\n")
|
||||||
|
assertThat(body).doesNotContain("�")
|
||||||
|
assertThat(ReportContent.tailBytes("short\n", 1000)).isEqualTo("short\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tailBytesOfOneHugeLineDoesNotSplitACharacter()
|
||||||
|
{
|
||||||
|
val cut = ReportContent.tailBytes("ж".repeat(1000), 101).removePrefix(ReportContent.CUT_MARK)
|
||||||
|
assertThat(cut).doesNotContain("�")
|
||||||
|
assertThat(cut).isEqualTo("ж".repeat(50))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readTailDropsThePartialFirstLine()
|
||||||
|
{
|
||||||
|
val file = File.createTempFile("charon", ".log")
|
||||||
|
try
|
||||||
|
{
|
||||||
|
file.writeText("first line\nsecond line\nthird line\n")
|
||||||
|
assertThat(ReportContent.readTail(file, 1000)).isEqualTo("first line\nsecond line\nthird line\n")
|
||||||
|
assertThat(ReportContent.readTail(file, 15)).isEqualTo(ReportContent.CUT_MARK + "third line\n")
|
||||||
|
assertThat(ReportContent.readTail(File(file.path + ".missing"), 100)).isEmpty()
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildAddsDiagOnlyWhenAskedAndStaysWithinTheLimits()
|
||||||
|
{
|
||||||
|
val plain = ReportContent.build("Nov 8 14:02:31 08[IKE] tunnel up\n", null)
|
||||||
|
assertThat(plain).isEqualTo("=== charon.log ===\nNov 8 14:02:31 08[IKE] tunnel up\n")
|
||||||
|
val withDiag = ReportContent.build("", "11-08 14:02:00.000 Watchdog: ok\n")
|
||||||
|
assertThat(withDiag).contains("=== charon.log ===\n(empty)").contains("=== diag.log ===\n11-08 14:02:00.000 Watchdog: ok")
|
||||||
|
val huge = "x".repeat(99) + "\n"
|
||||||
|
val big = ReportContent.build(huge.repeat(40_000), huge.repeat(10_000))
|
||||||
|
assertThat(big.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(ReportContent.CHARON_MAX_BYTES + ReportContent.DIAG_MAX_BYTES + 200)
|
||||||
|
/* what the panel unpacks for the report card (5 MB cap) */
|
||||||
|
assertThat(big.length).isLessThan(5 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metaDescribesThePhoneAndPassesTheServerSchema()
|
||||||
|
{
|
||||||
|
val meta = ReportContent.meta("2.19.2", 170, "Google", "Pixel 7", "14", 34, "Wi-Fi", " Не подключается в метро ", "nl1 · nl1.shx.one")
|
||||||
|
assertThat(meta.device).isEqualTo("Google Pixel 7")
|
||||||
|
assertThat(ReportContent.meta("2.19.2", 170, "samsung", "SAMSUNG SM-S911B", "14", 34, "mobile", "", "").device).isEqualTo("SAMSUNG SM-S911B")
|
||||||
|
assertThat(meta.os).isEqualTo("Android 14 (API 34)")
|
||||||
|
assertThat(meta.comment).isEqualTo("Не подключается в метро")
|
||||||
|
val json = JSONObject(ReportUploader.metaJson(meta))
|
||||||
|
/* ReportMetaSchema: source is "log" | "diagnostics", versionCode an integer */
|
||||||
|
assertThat(json.getString("source")).isEqualTo("log")
|
||||||
|
assertThat(json.getInt("versionCode")).isEqualTo(170)
|
||||||
|
assertThat(json.getString("network")).isEqualTo("Wi-Fi")
|
||||||
|
assertThat(json.getString("profile")).isEqualTo("nl1 · nl1.shx.one")
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
|
class ReportUploaderTest
|
||||||
|
{
|
||||||
|
private val meta = ReportUploader.Meta("2.19.1", 169, "Pixel 7", "Android 14", "LTE", "log", "Не подключается", "nl1")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gzipRoundTripsTheLogAndStartsWithTheGzipMagic()
|
||||||
|
{
|
||||||
|
val gz = ReportUploader.gzip("09:38:11 IKE_SA_INIT → nl1 :4500\n")
|
||||||
|
assertThat(gz[0]).isEqualTo(0x1f.toByte())
|
||||||
|
assertThat(gz[1]).isEqualTo(0x8b.toByte())
|
||||||
|
assertThat(GZIPInputStream(ByteArrayInputStream(gz)).readBytes().decodeToString()).isEqualTo("09:38:11 IKE_SA_INIT → nl1 :4500\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metaJsonCarriesTheFieldsTheServerValidatesAndTruncatesLongOnes()
|
||||||
|
{
|
||||||
|
val json = JSONObject(ReportUploader.metaJson(meta.copy(comment = "x".repeat(2000), device = "d".repeat(500))))
|
||||||
|
assertThat(json.getString("versionName")).isEqualTo("2.19.1")
|
||||||
|
assertThat(json.getInt("versionCode")).isEqualTo(169)
|
||||||
|
assertThat(json.getString("comment")).hasSize(1000)
|
||||||
|
assertThat(json.getString("device")).hasSize(120)
|
||||||
|
assertThat(json.getString("source")).isEqualTo("log")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipartBodyHasTheLogAndMetaPartsInTheExpectedShape()
|
||||||
|
{
|
||||||
|
val body = ReportUploader.multipartBody(byteArrayOf(0x1f, 0x8b.toByte(), 1, 2), "{\"a\":1}").decodeToString(throwOnInvalidSequence = false)
|
||||||
|
assertThat(body).contains("name=\"log\"; filename=\"log.gz\"")
|
||||||
|
assertThat(body).contains("name=\"meta\"")
|
||||||
|
assertThat(body).contains("{\"a\":1}")
|
||||||
|
/* the meta part must stay a plain field (see multipartBody) */
|
||||||
|
assertThat(body.substringAfter("name=\"meta\"").substringBefore("{")).doesNotContain("Content-Type")
|
||||||
|
assertThat(body).endsWith("--${ReportUploader.BOUNDARY}--\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOversizedReportIsRefusedBeforeAnyRequest()
|
||||||
|
{
|
||||||
|
/* 62 symbols are ~6 bits each: 4 MB of text stays above 2 MB after gzip */
|
||||||
|
val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||||
|
val huge = java.util.Random(1).let { r -> buildString { repeat(4 * 1024 * 1024) { append(alphabet[r.nextInt(alphabet.size)]) } } }
|
||||||
|
val result = ReportUploader("https://api.example/api/v1", "k", "install-0001").send(huge, meta)
|
||||||
|
assertThat(result).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||||
|
assertThat((result as ReportUploader.Result.Failed).httpCode).isEqualTo(413)
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the client against the shared vectors of the web panel
|
||||||
|
* (packages/contracts/vectors/signing.json). If the server contract changes,
|
||||||
|
* the vectors are regenerated there and copied to src/test/resources.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class UpdateApiTest
|
||||||
|
{
|
||||||
|
private val vectors = JSONObject(
|
||||||
|
javaClass.classLoader!!.getResourceAsStream("shx-signing-vectors.json")!!.use { it.readBytes().decodeToString() })
|
||||||
|
private val publicKeyRaw = vectors.getJSONObject("ed25519").getString("publicKeyRawBase64")
|
||||||
|
private val manifest = vectors.getJSONObject("manifest")
|
||||||
|
private val hmacKey = vectors.getJSONObject("request").getString("hmacKey")
|
||||||
|
|
||||||
|
private fun fields(o: JSONObject) = UpdateSigning.Fields(
|
||||||
|
update = o.getBoolean("update"),
|
||||||
|
mandatory = o.getBoolean("mandatory"),
|
||||||
|
versionCode = o.optLong("versionCode", 0L),
|
||||||
|
versionName = o.optString("versionName", ""),
|
||||||
|
sha256 = o.optString("sha256", ""),
|
||||||
|
size = o.optLong("size", 0L),
|
||||||
|
apkUrl = o.optString("apkUrl", ""),
|
||||||
|
issuedAt = o.getLong("issuedAt"),
|
||||||
|
checkHours = o.getInt("checkHours"),
|
||||||
|
wifiOnly = o.getBoolean("wifiOnly"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun responseJson(v: JSONObject, patch: (JSONObject) -> Unit = {}): String
|
||||||
|
{
|
||||||
|
val f = v.getJSONObject("fields")
|
||||||
|
val body = JSONObject(f.toString())
|
||||||
|
body.put("signature", v.getString("signatureBase64"))
|
||||||
|
if (f.getBoolean("update"))
|
||||||
|
{
|
||||||
|
body.put("notes", JSONObject().put("ru", "Исправления").put("en", "Fixes"))
|
||||||
|
}
|
||||||
|
patch(body)
|
||||||
|
return body.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeHttp(private val response: UpdateHttp.Response) : UpdateHttp
|
||||||
|
{
|
||||||
|
var lastUrl: String? = null
|
||||||
|
var lastHeaders: Map<String, String> = emptyMap()
|
||||||
|
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
|
||||||
|
{
|
||||||
|
lastUrl = url
|
||||||
|
lastHeaders = headers
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(http: UpdateHttp, nowSeconds: Long = manifest.getJSONObject("fields").getLong("issuedAt")) =
|
||||||
|
UpdateApiClient("https://api.example/api/v1", hmacKey, publicKeyRaw, "install-0001", http) { nowSeconds * 1000 }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun manifestSigningInputMatchesTheServerVector()
|
||||||
|
{
|
||||||
|
assertThat(UpdateSigning.manifestInput(fields(manifest.getJSONObject("fields")))).isEqualTo(manifest.getString("signingInput"))
|
||||||
|
val none = vectors.getJSONObject("noUpdate")
|
||||||
|
assertThat(UpdateSigning.manifestInput(fields(none.getJSONObject("fields")))).isEqualTo(none.getString("signingInput"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ed25519SignatureFromTheServerVerifiesAndTamperingIsRejected()
|
||||||
|
{
|
||||||
|
val key = Base64.Default.decode(publicKeyRaw)
|
||||||
|
val input = manifest.getString("signingInput")
|
||||||
|
val signature = manifest.getString("signatureBase64")
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(key, input, signature)).isTrue()
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(key, input + "x", signature)).isFalse()
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(key, input.replace("250003", "250004"), signature)).isFalse()
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(ByteArray(32), input, signature)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestSignatureMatchesTheServerVector()
|
||||||
|
{
|
||||||
|
val r = vectors.getJSONObject("request")
|
||||||
|
val input = UpdateSigning.requestInput(r.getString("method"), r.getString("path"), r.getString("timestamp").toLong())
|
||||||
|
assertThat(input).isEqualTo(r.getString("signingInput"))
|
||||||
|
assertThat(UpdateSigning.hmacBase64(hmacKey, input)).isEqualTo(r.getString("signatureBase64"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBareHostGetsTheApiPrefix()
|
||||||
|
{
|
||||||
|
/* UPDATE_API_URL=https://get.shnt.top (no /api/v1) used to hit /updates/check -> 404 -> silent legacy fallback */
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||||
|
val bare = UpdateApiClient("https://api.example/", hmacKey, publicKeyRaw, "install-0001", http) {
|
||||||
|
manifest.getJSONObject("fields").getLong("issuedAt") * 1000
|
||||||
|
}
|
||||||
|
assertThat(bare.check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)).isInstanceOf(UpdateApiResult.Update::class.java)
|
||||||
|
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientReturnsTheOfferedUpdateAndSendsASignedRequest()
|
||||||
|
{
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||||
|
val result = client(http).check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)
|
||||||
|
assertThat(result).isInstanceOf(UpdateApiResult.Update::class.java)
|
||||||
|
val update = result as UpdateApiResult.Update
|
||||||
|
assertThat(update.info.versionName).isEqualTo("2.5.0-beta.3")
|
||||||
|
assertThat(update.info.versionCode).isEqualTo(250003L)
|
||||||
|
assertThat(update.info.fromApi).isTrue()
|
||||||
|
assertThat(update.info.notes["ru"]).isEqualTo("Исправления")
|
||||||
|
assertThat(update.policy).isEqualTo(UpdatePolicy(12, true))
|
||||||
|
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?package=one.shx.strongswan.ext&versionCode=168")
|
||||||
|
assertThat(http.lastHeaders["X-Install-Id"]).isEqualTo("install-0001")
|
||||||
|
val ts = http.lastHeaders["X-Timestamp"]!!.toLong()
|
||||||
|
assertThat(http.lastHeaders["X-Signature"]).isEqualTo(
|
||||||
|
UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", "/api/v1/updates/check", ts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientAcceptsASignedNoUpdateAnswer()
|
||||||
|
{
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(vectors.getJSONObject("noUpdate"))))
|
||||||
|
val result = client(http).check("one.shx.strongswan.ext", 169, "release", null, 34, null)
|
||||||
|
assertThat(result).isInstanceOf(UpdateApiResult.NoUpdate::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tamperedFieldsAreRejectedAsASignatureFailureAndNeverFallBack()
|
||||||
|
{
|
||||||
|
for (field in listOf("sha256", "apkUrl", "versionCode", "versionName", "size", "mandatory", "checkHours", "wifiOnly"))
|
||||||
|
{
|
||||||
|
val body = responseJson(manifest) { o ->
|
||||||
|
when (field)
|
||||||
|
{
|
||||||
|
"mandatory", "wifiOnly" -> o.put(field, !o.getBoolean(field))
|
||||||
|
"versionCode", "size", "checkHours" -> o.put(field, o.getLong(field) + 1)
|
||||||
|
"sha256" -> o.put(field, "cd".repeat(32))
|
||||||
|
else -> o.put(field, o.getString(field) + "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = client(FakeHttp(UpdateHttp.Response(200, body))).check("one.shx.strongswan.ext", 1, "release", null, 34, null)
|
||||||
|
assertThat(result).describedAs(field).isInstanceOf(UpdateApiResult.Failure::class.java)
|
||||||
|
assertThat((result as UpdateApiResult.Failure).kind).describedAs(field).isEqualTo(UpdateError.SIGNATURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aStaleManifestIsRejectedEvenWithAValidSignature()
|
||||||
|
{
|
||||||
|
val issued = manifest.getJSONObject("fields").getLong("issuedAt")
|
||||||
|
val result = client(FakeHttp(UpdateHttp.Response(200, responseJson(manifest))), nowSeconds = issued + UpdateApiClient.MAX_SKEW_SECONDS + 60)
|
||||||
|
.check("one.shx.strongswan.ext", 1, "release", null, 34, null)
|
||||||
|
assertThat((result as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWrongPublicKeyFailsAndServerErrorsAreClassified()
|
||||||
|
{
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||||
|
val wrongKey = UpdateApiClient("https://api.example/api/v1", hmacKey, Base64.Default.encode(ByteArray(32) { 7 }), "install-0001", http) { 1760438401_000L }
|
||||||
|
assertThat((wrongKey.check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
|
||||||
|
// 404/5xx: сеть или сервер, допускается запасной путь; 401/429: нет
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(503, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(404, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(401, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(429, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theClientIsConfiguredOnlyWithAllThreeValues()
|
||||||
|
{
|
||||||
|
assertThat(UpdateApiClient.isConfigured("https://api", "key", "pub")).isTrue()
|
||||||
|
assertThat(UpdateApiClient.isConfigured("", "key", "pub")).isFalse()
|
||||||
|
assertThat(UpdateApiClient.isConfigured("https://api", "", "pub")).isFalse()
|
||||||
|
assertThat(UpdateApiClient.isConfigured("https://api", "key", "")).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
class UpdateNotesTest
|
||||||
|
{
|
||||||
|
private val notes = mapOf("ru" to "Русский", "en" to "English", "zh-tw" to "繁體", "zh" to "中文")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun picksAppLanguageThenRussianThenEnglish()
|
||||||
|
{
|
||||||
|
assertThat(pickNotes(notes, "en-US", "en")).isEqualTo("English")
|
||||||
|
assertThat(pickNotes(notes, "zh-TW", "zh")).isEqualTo("繁體")
|
||||||
|
assertThat(pickNotes(notes, "zh-CN", "zh")).isEqualTo("中文")
|
||||||
|
assertThat(pickNotes(notes, "de-DE", "de")).isEqualTo("Русский")
|
||||||
|
assertThat(pickNotes(mapOf("en" to "English", "ru" to " "), "de-DE", "de")).isEqualTo("English")
|
||||||
|
assertThat(pickNotes(emptyMap(), "ru-RU", "ru")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyNonEmptyLineIsAnItem()
|
||||||
|
{
|
||||||
|
assertThat(noteLines("Серверы\n\n - История трафика \r\n• Исправлен сбой\n")).containsExactly("Серверы", "История трафика", "Исправлен сбой")
|
||||||
|
assertThat(noteLines("Одна строка. Без переносов.")).containsExactly("Одна строка. Без переносов.")
|
||||||
|
assertThat(noteLines(null)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun copiesWithDigestAndProgress()
|
||||||
|
{
|
||||||
|
val data = ByteArray(200_000) { it.toByte() }
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
var last = 0L
|
||||||
|
val total = copyDownload(ByteArrayInputStream(data), out, digest, { false }) { last = it }
|
||||||
|
assertThat(total).isEqualTo(data.size.toLong())
|
||||||
|
assertThat(last).isEqualTo(data.size.toLong())
|
||||||
|
assertThat(out.toByteArray()).isEqualTo(data)
|
||||||
|
assertThat(digest.digest()).isEqualTo(MessageDigest.getInstance("SHA-256").digest(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelStopsBetweenReads()
|
||||||
|
{
|
||||||
|
var cancelled = false
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
assertThatThrownBy {
|
||||||
|
copyDownload(ByteArrayInputStream(ByteArray(300_000)), out, MessageDigest.getInstance("SHA-256"), { cancelled }) { cancelled = true }
|
||||||
|
}.isInstanceOf(CancellationException::class.java)
|
||||||
|
/* one 64 KiB chunk, then the loop stopped */
|
||||||
|
assertThat(out.size()).isEqualTo(64 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedReadAfterCancelIsACancellation()
|
||||||
|
{
|
||||||
|
var cancelled = false
|
||||||
|
/* what disconnect() does to a blocked read */
|
||||||
|
val broken = object : InputStream()
|
||||||
|
{
|
||||||
|
override fun read(): Int = throw IOException("socket closed")
|
||||||
|
override fun read(b: ByteArray, off: Int, len: Int): Int
|
||||||
|
{
|
||||||
|
cancelled = true
|
||||||
|
throw IOException("socket closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThatThrownBy {
|
||||||
|
copyDownload(broken, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { cancelled }) {}
|
||||||
|
}.isInstanceOf(CancellationException::class.java)
|
||||||
|
/* without a cancel the network error stays a network error */
|
||||||
|
assertThatThrownBy {
|
||||||
|
copyDownload(object : InputStream() { override fun read(): Int = throw IOException("reset") }, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { false }) {}
|
||||||
|
}.isInstanceOf(IOException::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"note": "ТЕСТОВЫЙ ключ, только для проверки формата подписи.",
|
||||||
|
"ed25519": {
|
||||||
|
"privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA9Mac/nzlc6O6s6/5p7z3BfaggTulVsGYSLJyiG9/H8\n-----END PRIVATE KEY-----\n",
|
||||||
|
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY=\n-----END PUBLIC KEY-----\n",
|
||||||
|
"publicKeyRawBase64": "SE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY="
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"fields": {
|
||||||
|
"update": true,
|
||||||
|
"mandatory": false,
|
||||||
|
"versionCode": 250003,
|
||||||
|
"versionName": "2.5.0-beta.3",
|
||||||
|
"checkHours": 12,
|
||||||
|
"wifiOnly": true,
|
||||||
|
"sha256": "abababababababababababababababababababababababababababababababab",
|
||||||
|
"size": 38900000,
|
||||||
|
"apkUrl": "https://s3.example/app.apk?sig=1",
|
||||||
|
"issuedAt": 1760438401
|
||||||
|
},
|
||||||
|
"signingInput": "shx-manifest-v1\n1\n0\n250003\n2.5.0-beta.3\nabababababababababababababababababababababababababababababababab\n38900000\nhttps://s3.example/app.apk?sig=1\n1760438401\n12\n1",
|
||||||
|
"signatureBase64": "KkPqAQX4DwqhgNWdJecOePHsnPzOGCYAGNG25mQganJGQiD2MTl9YSrcHHeO5hoGeLoOa4rEU40rV++YtODGCw=="
|
||||||
|
},
|
||||||
|
"noUpdate": {
|
||||||
|
"fields": {
|
||||||
|
"update": false,
|
||||||
|
"mandatory": false,
|
||||||
|
"issuedAt": 1760438401,
|
||||||
|
"checkHours": 12,
|
||||||
|
"wifiOnly": true
|
||||||
|
},
|
||||||
|
"signingInput": "shx-manifest-v1\n0\n0\n0\n\n\n0\n\n1760438401\n12\n1",
|
||||||
|
"signatureBase64": "8Q5+fb7t6ot5iXYBPbv9CCspNzJOEVmQqHLh/6k2lNNtMxeyuTfbldKaKFzQV4orknVKijbf5ChgYDlC5pNvDQ=="
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"hmacKey": "test-hmac-key",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/updates/check",
|
||||||
|
"timestamp": "1760438400",
|
||||||
|
"signingInput": "GET|/api/v1/updates/check|1760438400",
|
||||||
|
"signatureBase64": "m6eQoNLWfDApDH2E1/q3aW/qG06t01/2zvxL1RD+Ckw="
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SHX VPN для Android
|
# SHX VPN для Android
|
||||||
|
|
||||||
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.1 (versionCode 166)**.
|
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
|
||||||
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
||||||
|
|
||||||
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
||||||
|
|||||||
@@ -139,3 +139,44 @@
|
|||||||
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
|
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
|
||||||
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
|
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
|
||||||
- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`.
|
- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`.
|
||||||
|
|
||||||
|
## 7. Версия 2.19.0: надёжность и лишняя работа (по результатам разбора багов)
|
||||||
|
|
||||||
|
**Стабильность соединения**
|
||||||
|
- `Scheduler` (будильники keepalive и rekey): `ELAPSED_REALTIME_WAKEUP` вместо `RTC_WAKEUP` — сдвиг часов (NTP, ручная смена, пояс)
|
||||||
|
больше не уводит срабатывание на часы вперёд. Очередь вынесена в `ScheduledJobQueue` (тесты): задания в окне 2 с делят один будильник
|
||||||
|
(раньше срабатывает не более чем на 2 с, поздно — никогда; charon ставит задания дольше 3 с). Каждый сэкономленный будильник — слот в квоте
|
||||||
|
allow-while-idle в Doze. Интервалы и keepalive 45 с не менялись. В `diag.log` один раз пишется, точные ли будильники.
|
||||||
|
- `BootReceiver` (`BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`): после перезагрузки и смены времени заново взводит будильник пауз
|
||||||
|
(раньше пауза после перезагрузки не заканчивалась никогда) и будильник окон сценариев, правила решают заново. Туннель сам не стартует;
|
||||||
|
профиль без сохранённого пароля по-прежнему не запускается.
|
||||||
|
- `ScenarioReceiver` ждёт привязки `VpnStateService` (до 8 с) через `goAsync`; раньше срабатывание свежего процесса терялось. `restoreIfApplied`
|
||||||
|
стирает запись о применённом сценарии только когда сервис есть.
|
||||||
|
- Будильник окон сценариев: точный, если разрешено (как у паузы).
|
||||||
|
- Сетевые сценарии (Wi-Fi, мобильная сеть, роуминг) слушают сеть и без включённых правил сетей (`AutoConnectManager.scenariosChanged`).
|
||||||
|
- Watchdog, поднявший туннель авто-сессии в свежем процессе, помечает сессию авто (`markAutoSessionRestored`): туннель паркуется на доверенной
|
||||||
|
Wi-Fi, а не работает всю ночь. Постоянный флаг не вводился сознательно: устаревший флаг запарковал бы туннель, запущенный вручную.
|
||||||
|
- `NetworkManager`: набор сетей вместо счётчика (повтор `onAvailable` или пропуск `onLost` не сдвигают состояние); подряд идущие одинаковые
|
||||||
|
события схлопываются в одно.
|
||||||
|
|
||||||
|
**Расход ресурсов**
|
||||||
|
- `direct_engine.c`: `tcp_active` сбрасывается после холостого раунда без TCP-потоков (`flow_count` в `direct_tcp.c` объявлен, но не был
|
||||||
|
реализован — теперь реализован), холостой интервал снова растягивается до 4 с; ветка «TUN не привязан» наращивает интервал, а не опрашивает
|
||||||
|
4 раза в секунду; `reprotect` очищает отрицательный кэш и при спящем lwIP.
|
||||||
|
- `TrafficHistory`: история в памяти, на диск раз в 5 мин, при остановке и смене дня; с погашенным экраном опрос раз в минуту; виджет обновляется
|
||||||
|
только при включённом экране. Теряется не более 5 мин статистики при убийстве процесса.
|
||||||
|
|
||||||
|
**Сознательно не делали (нет выигрыша или он не стоит риска)**
|
||||||
|
- Проверка здоровья уже растягивает паузы до штатного интервала и выключена по умолчанию, отдельная «остановка на мёртвом туннеле» не нужна.
|
||||||
|
- Проверка белых списков: цепочка идёт только пока белый список обнаружен, шаг не больше 5 мин; удлинение при погашенном экране отложило бы
|
||||||
|
возврат туннеля на минуты ради долей процента.
|
||||||
|
- Сеть без процесса (`registerNetworkCallback` на `PendingIntent`) — гипотеза не проверена на устройстве.
|
||||||
|
- `UnderlyingNetworkTracker` и SSID для сценариев из кэша `NetworkMonitor` — нужен лог с реальных сетей.
|
||||||
|
|
||||||
|
**Известное ограничение:** без «Будильников и напоминаний» keepalive в глубоком Doze остаётся под квотой; склейка заданий лишь освобождает слоты.
|
||||||
|
|
||||||
|
**Найдено при smoke-тесте 2.19.0:** `CharonVpnService` при старте по `Intent` без пароля (watchdog, конец паузы, `FgsStartRetry`) затирал сохранённый в профиле пароль значением `null`;
|
||||||
|
туннель после смерти процесса не возвращался («Password unavailable»). Теперь пароль из `Intent` подставляется, только если он там есть.
|
||||||
|
Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного
|
||||||
|
будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс,
|
||||||
|
`ScenarioReceiver` ждёт сервис и возвращает прежнее состояние VPN. Без реального сервера не проверены keepalive/rekey, NAT-таймауты и энергопотребление.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Загрузка собранного APK в панель SHX VPN с компьютера сборки. Нужен только Node 18+, без зависимостей.
|
||||||
|
//
|
||||||
|
// node upload-apk.mjs app-release.apk --type phone --channel beta [--publish] [--rollout 100] [--notes "Что нового"]
|
||||||
|
//
|
||||||
|
// Окружение:
|
||||||
|
// SHX_URL адрес консоли, например https://apk.shnt.top
|
||||||
|
// SHX_TOKEN токен CI из «Настройки → Токены» (upload — только черновик, publish — можно публиковать)
|
||||||
|
//
|
||||||
|
// versionName и versionCode берутся из output-metadata.json, который Gradle кладёт рядом с APK,
|
||||||
|
// либо из --version-name / --version-code.
|
||||||
|
//
|
||||||
|
// Шаги: билет на загрузку → файл напрямую в S3 по presigned PUT → сервер проверяет SHA-256 и оформляет версию.
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
|
const { values: o, positionals } = parseArgs({
|
||||||
|
allowPositionals: true,
|
||||||
|
options: {
|
||||||
|
type: { type: "string" },
|
||||||
|
channel: { type: "string", default: "beta" },
|
||||||
|
"version-name": { type: "string" },
|
||||||
|
"version-code": { type: "string" },
|
||||||
|
notes: { type: "string", default: "" },
|
||||||
|
"notes-en": { type: "string", default: "" },
|
||||||
|
publish: { type: "boolean", default: false },
|
||||||
|
rollout: { type: "string", default: "100" },
|
||||||
|
help: { type: "boolean", short: "h", default: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const die = (msg) => { console.error("✗ " + msg); process.exit(1); };
|
||||||
|
if (o.help || positionals.length !== 1) {
|
||||||
|
console.log("node upload-apk.mjs <файл.apk> --type <id типа> [--channel beta|release] [--publish] [--rollout 1-100] [--notes текст] [--version-name 2.20.0 --version-code 220]");
|
||||||
|
process.exit(o.help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows: переменные, заданные через [Environment]::SetEnvironmentVariable(..., "User"), видны только новым
|
||||||
|
// процессам. Если терминал (или Gradle daemon) открыт раньше, читаем их из реестра пользователя.
|
||||||
|
function userEnv(name) {
|
||||||
|
if (process.env[name]) return process.env[name];
|
||||||
|
if (process.platform !== "win32") return "";
|
||||||
|
try {
|
||||||
|
const out = execFileSync("reg", ["query", "HKCU\\Environment", "/v", name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
||||||
|
return /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(out)?.[1]?.trim() ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const base = userEnv("SHX_URL").replace(/\/$/, "");
|
||||||
|
const token = userEnv("SHX_TOKEN");
|
||||||
|
if (!base) die("Задайте SHX_URL (адрес консоли)");
|
||||||
|
if (!token) die("Задайте SHX_TOKEN (Настройки → Токены в консоли)");
|
||||||
|
if (!o.type) die("Укажите --type (id типа приложения из раздела «Типы приложений»)");
|
||||||
|
if (!["beta", "release"].includes(o.channel)) die("--channel: beta или release");
|
||||||
|
const rolloutPct = Number(o.rollout);
|
||||||
|
if (!Number.isInteger(rolloutPct) || rolloutPct < 1 || rolloutPct > 100) die("--rollout: целое от 1 до 100");
|
||||||
|
|
||||||
|
const file = positionals[0];
|
||||||
|
if (!existsSync(file)) die("Нет файла " + file);
|
||||||
|
|
||||||
|
let versionName = o["version-name"];
|
||||||
|
let versionCode = o["version-code"] ? Number(o["version-code"]) : undefined;
|
||||||
|
const meta = join(dirname(file), "output-metadata.json");
|
||||||
|
if ((!versionName || !versionCode) && existsSync(meta)) {
|
||||||
|
const el = JSON.parse(readFileSync(meta, "utf8")).elements?.[0];
|
||||||
|
versionName ??= el?.versionName;
|
||||||
|
versionCode ??= el?.versionCode;
|
||||||
|
}
|
||||||
|
if (!versionName || !Number.isInteger(versionCode)) die("Не удалось определить версию: передайте --version-name и --version-code");
|
||||||
|
|
||||||
|
const bytes = readFileSync(file);
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
const mb = (statSync(file).size / 1048576).toFixed(1);
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const r = await fetch(base + path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) die(`${path}: ${r.status} ${data?.error?.message ?? r.statusText}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`→ ${o.type} / ${o.channel} / ${versionName} (${versionCode}), ${mb} МБ, sha256 ${sha256.slice(0, 12)}…`);
|
||||||
|
|
||||||
|
const ticket = await api("/api/admin/versions/uploads", { typeId: o.type, channel: o.channel, versionName, versionCode });
|
||||||
|
if (bytes.length > ticket.maxBytes) die("Файл больше допустимого размера");
|
||||||
|
|
||||||
|
const put = await fetch(ticket.url, { method: ticket.method, headers: ticket.headers, body: bytes });
|
||||||
|
if (!put.ok) die(`Загрузка в хранилище: ${put.status} ${await put.text().catch(() => "")}`.slice(0, 300));
|
||||||
|
console.log("✓ файл в хранилище");
|
||||||
|
|
||||||
|
const v = await api("/api/admin/versions", {
|
||||||
|
uploadId: ticket.uploadId,
|
||||||
|
sha256,
|
||||||
|
notes: { ru: o.notes, en: o["notes-en"] },
|
||||||
|
publish: o.publish,
|
||||||
|
rolloutPct,
|
||||||
|
});
|
||||||
|
console.log(`✓ версия оформлена: ${v.versionName}, статус ${v.status}${o.publish ? `, раскатка ${rolloutPct}%` : " (черновик: опубликуйте в консоли)"}`);
|
||||||
|
console.log(` ${base}/versions/${v.id}`);
|
||||||
Reference in new issue
Block a user