Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ee051ab2 | ||
|
|
0e35a794c9 | ||
|
|
5ffb5a2dbb | ||
|
|
ae06a58013 | ||
|
|
9109eb8460 | ||
|
|
1ef8c983c7 | ||
|
|
072674d1a8 | ||
|
|
228f9f6004 | ||
|
|
6756d2fca5 | ||
|
|
5998c0fc46 | ||
|
|
f5b15a0394 | ||
|
|
38e5fd84bb |
No files matched your search
@@ -40,3 +40,21 @@
|
||||
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
||||
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
||||
- Автообновление приложения: `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
|
||||
targetSdk = 36
|
||||
|
||||
versionCode 165
|
||||
versionName "2.18.0"
|
||||
versionCode 172
|
||||
versionName "2.19.4"
|
||||
|
||||
/* Base URL of the update storage, trailing slash included */
|
||||
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_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 {
|
||||
ndkBuild {
|
||||
arguments '-j' + Runtime.runtime.availableProcessors()
|
||||
@@ -175,6 +186,8 @@ dependencies {
|
||||
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
||||
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'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
@@ -182,6 +195,8 @@ dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.assertj:assertj-core:3.27.6'
|
||||
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:runner:1.7.0'
|
||||
@@ -189,3 +204,32 @@ dependencies {
|
||||
androidTestImplementation composeBom
|
||||
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 {
|
||||
<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. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<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_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
@@ -339,6 +340,17 @@
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
+13
-3
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile keeps the data source it was loaded from, which may be closed
|
||||
* by the time the profile is updated or deleted, so reopen on demand.
|
||||
*/
|
||||
private SQLiteDatabase database()
|
||||
{
|
||||
open();
|
||||
return mDatabase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
public VpnProfile insertProfile(VpnProfile profile)
|
||||
{
|
||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||
long insertId = mDatabase.insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
||||
long insertId = database().insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
||||
if (insertId == -1)
|
||||
{
|
||||
return null;
|
||||
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
{
|
||||
final UUID uuid = profile.getUUID();
|
||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||
return mDatabase.update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
return database().update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteVpnProfile(VpnProfile profile)
|
||||
{
|
||||
final UUID uuid = profile.getUUID();
|
||||
return mDatabase.delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
return database().delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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);
|
||||
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 */
|
||||
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
||||
if (port > 0)
|
||||
|
||||
+18
-5
@@ -25,6 +25,7 @@ import android.net.Network;
|
||||
import android.net.NetworkRequest;
|
||||
import android.os.Build;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
|
||||
public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
private volatile boolean mRegistered;
|
||||
private ConnectivityManager.NetworkCallback mCallback;
|
||||
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<>();
|
||||
|
||||
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
|
||||
* callback registration */
|
||||
mConnectedNetworks += 1;
|
||||
mConnectedNetworks.add(network);
|
||||
mEvents.addLast(true);
|
||||
NetworkManager.this.notifyAll();
|
||||
}
|
||||
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
synchronized (NetworkManager.this)
|
||||
{
|
||||
/* in particular mobile connections are disconnected overlapping with WiFi */
|
||||
mConnectedNetworks -= 1;
|
||||
mEvents.addLast(mConnectedNetworks > 0);
|
||||
mConnectedNetworks.remove(network);
|
||||
mEvents.addLast(!mConnectedNetworks.isEmpty());
|
||||
NetworkManager.this.notifyAll();
|
||||
}
|
||||
}
|
||||
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
|
||||
public void Register()
|
||||
{
|
||||
mEvents.clear();
|
||||
synchronized (this)
|
||||
{
|
||||
mEvents.clear();
|
||||
mConnectedNetworks.clear();
|
||||
}
|
||||
mRegistered = true;
|
||||
mEventNotifier = new Thread(this);
|
||||
mEventNotifier.start();
|
||||
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
break;
|
||||
}
|
||||
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 */
|
||||
networkChanged(!connected);
|
||||
|
||||
@@ -229,6 +229,32 @@ object PauseManager
|
||||
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)
|
||||
{
|
||||
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.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
|
||||
private final String TAG = Scheduler.class.getSimpleName();
|
||||
private final Context mContext;
|
||||
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")
|
||||
public Scheduler(Context context)
|
||||
{
|
||||
mContext = context;
|
||||
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
mJobs = new PriorityQueue<>();
|
||||
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
|
||||
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(EXECUTE_JOB);
|
||||
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
|
||||
* alarms instead of crashing with a SecurityException (connectivity then
|
||||
* 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)
|
||||
private void scheduleAlarm(long time)
|
||||
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
|
||||
{
|
||||
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
|
||||
{
|
||||
if (exact)
|
||||
{
|
||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
else
|
||||
{
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||
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)
|
||||
{
|
||||
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
|
||||
mJobs.add(job);
|
||||
|
||||
if (job == mJobs.peek())
|
||||
{ /* update the alarm if the job has to be executed before all others */
|
||||
scheduleAlarm(job.Time);
|
||||
long time = SystemClock.elapsedRealtime() + ms;
|
||||
if (mJobs.add(id, time))
|
||||
{ /* the job is due before the armed alarm: move the alarm */
|
||||
scheduleAlarm(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent)
|
||||
{
|
||||
ArrayList<ScheduledJob> jobs = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> jobs;
|
||||
|
||||
synchronized (this)
|
||||
{
|
||||
ScheduledJob job = mJobs.peek();
|
||||
while (job != null)
|
||||
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
|
||||
long next = mJobs.nextAlarm();
|
||||
if (next != ScheduledJobQueue.NONE)
|
||||
{
|
||||
if (job.Time > now)
|
||||
{
|
||||
break;
|
||||
}
|
||||
jobs.add(mJobs.remove());
|
||||
job = mJobs.peek();
|
||||
}
|
||||
if (job != null)
|
||||
{
|
||||
scheduleAlarm(job.Time);
|
||||
scheduleAlarm(next);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
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;
|
||||
case RESTART:
|
||||
{
|
||||
boolean autoRestart = uuid == null;
|
||||
if (uuid == null)
|
||||
{
|
||||
uuid = resolveAutoProfile(context, prefs);
|
||||
@@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
* once, which is harmless (retry flag set, same profile). */
|
||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||
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);
|
||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||
|
||||
+42
-6
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
|
||||
private var currentNetwork: NetworkKind? = null
|
||||
/* marks the session this manager started itself */
|
||||
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) */
|
||||
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()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (!config.enabled)
|
||||
if (!config.enabled && !scenariosNeedNetwork())
|
||||
{
|
||||
return
|
||||
}
|
||||
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
autoSession.set(false)
|
||||
scenariosChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
autoSession.set(false)
|
||||
scenariosChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
|
||||
override fun stateChanged()
|
||||
{
|
||||
val state = service?.state ?: return
|
||||
if (state == VpnStateService.State.DISABLED)
|
||||
if (state != VpnStateService.State.DISABLED)
|
||||
{
|
||||
awaitingRestoredTunnel = false
|
||||
}
|
||||
else if (!awaitingRestoredTunnel)
|
||||
{
|
||||
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.
|
||||
*/
|
||||
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
|
||||
|
||||
private fun evaluate()
|
||||
{
|
||||
if (!running.get())
|
||||
{
|
||||
if (!running.get() || !config.enabled)
|
||||
{ /* running only for the scenarios: they decide, not the network rules */
|
||||
return
|
||||
}
|
||||
val svc = service ?: return
|
||||
|
||||
+71
-1
@@ -30,6 +30,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
|
||||
private var tunnelAttempts = 0
|
||||
private var fastPollShifts = 0
|
||||
private var powerState = PowerState()
|
||||
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
|
||||
private var lastCycleInbound = -1L
|
||||
private var lastProbeAt = 0L
|
||||
|
||||
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||
private val powerReceiver = object : BroadcastReceiver()
|
||||
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
|
||||
return
|
||||
}
|
||||
val gateway = svc.profile?.gateway
|
||||
val targets = config.targets
|
||||
val adapted = adaptToPowerState(config, powerState)
|
||||
val nowMs = SystemClock.elapsedRealtime()
|
||||
/* passive liveness: payload that came back through the tunnel since the
|
||||
* previous cycle proves it works without waking the radio for probes */
|
||||
val inbound = tunnelInboundBytes()
|
||||
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
|
||||
lastCycleInbound = inbound
|
||||
if (trafficProvesAlive(
|
||||
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
|
||||
inboundDelta, nowMs - lastProbeAt,
|
||||
))
|
||||
{
|
||||
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
|
||||
return
|
||||
}
|
||||
lastProbeAt = nowMs
|
||||
/* screen off: one target is enough (the confirmation probe uses it too) */
|
||||
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
|
||||
{
|
||||
setOf(config.targets.first())
|
||||
}
|
||||
else
|
||||
{
|
||||
config.targets
|
||||
}
|
||||
val mode = config.serverCheckMode
|
||||
val timeout = config.timeoutMs
|
||||
|
||||
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
|
||||
tunnelAttempts = 0
|
||||
fastPollShifts = 0
|
||||
pendingDecision = null
|
||||
lastCycleInbound = -1L
|
||||
lastProbeAt = 0L
|
||||
}
|
||||
|
||||
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
|
||||
private fun tunnelInboundBytes(): Long
|
||||
{
|
||||
return try
|
||||
{
|
||||
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerVpnCallback()
|
||||
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
|
||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
||||
|
||||
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
|
||||
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
|
||||
|
||||
/** Even with traffic flowing a real probe runs at least this often. */
|
||||
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Pure function: whether the cycle may skip its probes because the
|
||||
* tunnel carried inbound data since the previous cycle. Only in
|
||||
* adaptive mode, never in aggressive mode, never while a failure is
|
||||
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
|
||||
* Outbound-only traffic proves nothing, so it does not count.
|
||||
*/
|
||||
fun trafficProvesAlive(
|
||||
adapted: HealthConfig,
|
||||
failedAttempts: Int,
|
||||
fastPollShifts: Int,
|
||||
decisionPending: Boolean,
|
||||
inboundDelta: Long,
|
||||
sinceLastProbeMs: Long,
|
||||
): Boolean =
|
||||
adapted.adaptiveMode && !adapted.aggressiveMode &&
|
||||
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
|
||||
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
|
||||
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
|
||||
|
||||
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||
|
||||
|
||||
@@ -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. */
|
||||
@JvmStatic
|
||||
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
||||
|
||||
+15
@@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context)
|
||||
break
|
||||
}
|
||||
delay(SITE_REFRESH_MS)
|
||||
/* nobody looks at the screen: resolving only wakes the radio and
|
||||
* CPU, so wait for it to come on (re-checked every few minutes),
|
||||
* but never let the answers get older than the cap */
|
||||
var waited = SITE_REFRESH_MS
|
||||
while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn())
|
||||
{
|
||||
delay(SITE_SCREEN_RECHECK_MS)
|
||||
waited += SITE_SCREEN_RECHECK_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true
|
||||
|
||||
/**
|
||||
* Like [updateConfig], but force prefixes apply on the fly through the
|
||||
* atomic set swap of the userspace router - no reconnect needed.
|
||||
@@ -833,6 +845,9 @@ class RoutingManager private constructor(private val context: Context)
|
||||
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
const val SITE_REFRESH_MS = 30L * 60 * 1000
|
||||
/* with the screen off the refresh waits for it, up to this age */
|
||||
private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000
|
||||
private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000
|
||||
|
||||
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||
const val CONFIG_VERSION = 2
|
||||
|
||||
+129
-13
@@ -21,13 +21,16 @@ import android.app.usage.UsageStatsManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
@@ -161,6 +164,12 @@ object ScenarioEngine
|
||||
private const val TAG = "Scenarios"
|
||||
private const val FILE_NAME = "scenarios.json"
|
||||
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 */
|
||||
private const val SLOW_AFTER_POLLS = 15
|
||||
private const val SLOW_POLL_MS = 8000L
|
||||
private const val PREF_APPLIED = "pref_scenario_applied"
|
||||
private const val PREF_WAS_ON = "pref_scenario_was_on"
|
||||
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
|
||||
@@ -174,6 +183,7 @@ object ScenarioEngine
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var appContext: Context? = null
|
||||
private var polling = false
|
||||
private var unchangedPolls = 0
|
||||
private var lastForeground: String? = null
|
||||
|
||||
fun flow(context: Context): StateFlow<List<Scenario>>
|
||||
@@ -188,9 +198,20 @@ object ScenarioEngine
|
||||
_list.value = list
|
||||
persist(context)
|
||||
start(context)
|
||||
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
|
||||
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. */
|
||||
@JvmStatic
|
||||
fun start(context: Context)
|
||||
@@ -203,36 +224,118 @@ object ScenarioEngine
|
||||
if (needPoll && !polling)
|
||||
{
|
||||
polling = true
|
||||
handler.post(poll)
|
||||
registerScreenReceiver(app)
|
||||
if (isScreenOn(app))
|
||||
{
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
else if (!needPoll)
|
||||
{
|
||||
polling = false
|
||||
handler.removeCallbacks(poll)
|
||||
unregisterScreenReceiver(app)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(context: Context): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
|
||||
/* the foreground app only changes while the screen is on: no polling (and no
|
||||
* wake-ups for it) while it is off, one immediate poll when it comes back */
|
||||
private val screenReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
handler.removeCallbacks(poll)
|
||||
if (polling && intent.action == Intent.ACTION_SCREEN_ON)
|
||||
{
|
||||
unchangedPolls = 0
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var screenReceiverRegistered = false
|
||||
|
||||
private fun registerScreenReceiver(app: Context)
|
||||
{
|
||||
if (screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
runCatching { app.registerReceiver(screenReceiver, filter, null, handler) }
|
||||
.onSuccess { screenReceiverRegistered = true }
|
||||
}
|
||||
|
||||
private fun unregisterScreenReceiver(app: Context)
|
||||
{
|
||||
if (!screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
runCatching { app.unregisterReceiver(screenReceiver) }
|
||||
screenReceiverRegistered = false
|
||||
}
|
||||
|
||||
private val poll = object : Runnable
|
||||
{
|
||||
override fun run()
|
||||
{
|
||||
val app = appContext ?: return
|
||||
if (!polling)
|
||||
{
|
||||
if (!polling || !isScreenOn(app))
|
||||
{ /* resumed by the screen-on broadcast */
|
||||
return
|
||||
}
|
||||
val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
if (screenOn)
|
||||
val fg = foregroundApp(app)
|
||||
if (fg != null && fg != lastForeground)
|
||||
{
|
||||
val fg = foregroundApp(app)
|
||||
if (fg != null && fg != lastForeground)
|
||||
lastForeground = fg
|
||||
unchangedPolls = 0
|
||||
evaluate(app)
|
||||
}
|
||||
else
|
||||
{
|
||||
unchangedPolls++
|
||||
}
|
||||
handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
lastForeground = fg
|
||||
evaluate(app)
|
||||
}
|
||||
finally
|
||||
{
|
||||
done?.run()
|
||||
}
|
||||
}
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
handler.post(check)
|
||||
}
|
||||
|
||||
/** Network, whitelist or alarm: decide again. */
|
||||
@@ -377,10 +480,12 @@ object ScenarioEngine
|
||||
{
|
||||
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 wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
||||
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)")
|
||||
if (PauseManager.isPaused(context))
|
||||
{
|
||||
@@ -508,12 +613,22 @@ object ScenarioEngine
|
||||
{
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
{ /* revoked in between: an inexact alarm is still better than none */
|
||||
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
|
||||
override fun onReceive(context: Context, intent: Intent?)
|
||||
{
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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
|
||||
* 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
|
||||
* 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 FILE_NAME = "traffic.json"
|
||||
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 val executor = Executors.newSingleThreadScheduledExecutor()
|
||||
private var task: ScheduledFuture<*>? = null
|
||||
private var lastVpn = -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
|
||||
private val days = LinkedHashMap<String, LongArray>()
|
||||
private var firstDay: String? = null
|
||||
@@ -86,11 +98,38 @@ object TrafficHistory
|
||||
val running = task ?: return
|
||||
running.cancel(false)
|
||||
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 vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
||||
/* direct bytes only exist with the userspace router (s[3]) */
|
||||
@@ -121,9 +160,16 @@ object TrafficHistory
|
||||
firstDay = key
|
||||
}
|
||||
prune()
|
||||
save(context)
|
||||
dirty = true
|
||||
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
|
||||
{
|
||||
save(context)
|
||||
}
|
||||
_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. */
|
||||
@@ -205,6 +251,9 @@ object TrafficHistory
|
||||
file.writeText(json.toString())
|
||||
tmp.delete()
|
||||
}
|
||||
dirty = false
|
||||
lastSavedAt = SystemClock.elapsedRealtime()
|
||||
lastSavedDay = dayKey(System.currentTimeMillis())
|
||||
}
|
||||
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.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** Categories of update failures, mapped to localized messages by the UI */
|
||||
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
||||
@@ -76,6 +77,10 @@ data class UpdateInfo(
|
||||
val sha256: String,
|
||||
val size: Long,
|
||||
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
|
||||
@@ -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_NOTIFICATION_ID = 1001
|
||||
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
|
||||
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 */
|
||||
@Volatile
|
||||
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 val mConnection = object : ServiceConnection
|
||||
@@ -191,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
|
||||
{
|
||||
cleanup()
|
||||
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()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
/* background checks only run while charging - zero battery impact */
|
||||
@@ -224,7 +246,7 @@ class UpdateManager private constructor(private val context: Context)
|
||||
return null
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -244,6 +266,57 @@ class UpdateManager private constructor(private val context: Context)
|
||||
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
|
||||
private fun check(): Job?
|
||||
{
|
||||
@@ -257,7 +330,12 @@ class UpdateManager private constructor(private val context: Context)
|
||||
mInstallPending = false
|
||||
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())
|
||||
{
|
||||
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")
|
||||
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)")
|
||||
return
|
||||
@@ -309,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
|
||||
{
|
||||
return
|
||||
}
|
||||
mCancelRequested = false
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (mCancelRequested)
|
||||
{
|
||||
throw CancellationException("update download cancelled")
|
||||
}
|
||||
if (isVpnDown() && canInstallSilently())
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
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 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)
|
||||
.setContentTitle(title)
|
||||
.setContentText("$progress%")
|
||||
@@ -416,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
|
||||
.setStyle(Notification.ProgressStyle()
|
||||
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
||||
.setProgress(progress))
|
||||
.setRequestPromotedOngoing(true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.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
|
||||
{
|
||||
@@ -519,16 +615,25 @@ class UpdateManager private constructor(private val context: Context)
|
||||
check()
|
||||
return
|
||||
}
|
||||
mCancelRequested = false
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (mCancelRequested)
|
||||
{
|
||||
throw CancellationException("update download cancelled")
|
||||
}
|
||||
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
catch (e: CancellationException)
|
||||
{
|
||||
onDownloadCancelled()
|
||||
}
|
||||
catch (e: UpdateException)
|
||||
{
|
||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||
@@ -560,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
|
||||
|
||||
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
|
||||
{
|
||||
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>
|
||||
{
|
||||
if (notes == null)
|
||||
@@ -606,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
|
||||
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)
|
||||
if (!dir.exists() && !dir.mkdirs())
|
||||
{
|
||||
@@ -620,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
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
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
||||
}
|
||||
val expected = if (info.size > 0) info.size else -1L
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var downloaded = 0L
|
||||
var lastProgress = -1
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
mDownloadConn = conn
|
||||
val downloaded = conn.inputStream.use { input ->
|
||||
FileOutputStream(apk).use { output ->
|
||||
while (true)
|
||||
{
|
||||
val len = input.read(buffer)
|
||||
if (len < 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
output.write(buffer, 0, len)
|
||||
digest.update(buffer, 0, len)
|
||||
downloaded += len
|
||||
copyDownload(input, output, digest, { mCancelRequested }) { done ->
|
||||
if (expected > 0)
|
||||
{
|
||||
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
|
||||
val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
|
||||
if (progress != lastProgress)
|
||||
{
|
||||
lastProgress = progress
|
||||
@@ -674,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
finally
|
||||
{
|
||||
mDownloadConn = null
|
||||
conn.disconnect()
|
||||
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
|
||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
conn.readTimeout = READ_TIMEOUT_MS
|
||||
conn.setRequestProperty("Connection", "close")
|
||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||
applyS3Auth(conn, url)
|
||||
if (signS3)
|
||||
{
|
||||
applyS3Auth(conn, url)
|
||||
}
|
||||
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
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
@@ -77,6 +81,7 @@ import org.strongswan.android.R
|
||||
import org.strongswan.android.data.LogContentProvider
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
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.theme.ShxTheme
|
||||
import java.io.BufferedReader
|
||||
@@ -87,18 +92,49 @@ class LogActivity : AppCompatActivity()
|
||||
{
|
||||
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?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||
setContent {
|
||||
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
|
||||
* 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_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||
intent.type = "text/plain"
|
||||
if (comment.isNotBlank())
|
||||
{
|
||||
intent.putExtra(Intent.EXTRA_TEXT, comment)
|
||||
}
|
||||
if (uris.size > 1)
|
||||
{
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -71,6 +71,7 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
@@ -193,19 +194,24 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
|
||||
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
||||
/* the live state of the tunnel and a direct ping of the server */
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
|
||||
/* only while the page is on screen */
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rtt by produceState<Long?>(null, profile.gateway) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
val rtt by produceState<Long?>(null, profile.gateway, owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
val active = vpn?.second == uuid
|
||||
|
||||
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
|
||||
Box(Modifier.padding(padding)) {
|
||||
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 }
|
||||
}
|
||||
|
||||
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||
internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||
val vpn = VpnStateService.getLiveInstance()
|
||||
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
||||
val gateway = (vpn?.profile)?.gateway
|
||||
|
||||
+12
-1
@@ -199,7 +199,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
{
|
||||
synchronized(underlyingNetworks)
|
||||
{
|
||||
underlyingNetworks[network] = capabilities
|
||||
val previous = underlyingNetworks.put(network, capabilities)
|
||||
/* fired on every signal-strength tick: the published state (network
|
||||
* kinds, SSID) only depends on the transports, and reading the SSID
|
||||
* is a system call, so unchanged transports end here */
|
||||
if (previous != null && sameTransports(previous, capabilities))
|
||||
{
|
||||
return
|
||||
}
|
||||
publishNetworks()
|
||||
}
|
||||
}
|
||||
@@ -214,6 +221,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
}
|
||||
}
|
||||
|
||||
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|
||||
private fun publishNetworks()
|
||||
{
|
||||
_networkAvailable.value = underlyingNetworks.isNotEmpty()
|
||||
|
||||
+17
-8
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
var result by remember { mutableStateOf<CheckResult?>(null) }
|
||||
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
|
||||
if (mode == RouteCheckMode.APP && value.isEmpty())
|
||||
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
|
||||
|
||||
/** 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.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
|
||||
}
|
||||
if (result !is CheckResult.BadInput)
|
||||
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
{
|
||||
if (initialQuery.isNotBlank())
|
||||
{
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
delay(300)
|
||||
}
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-94
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.ShxButtonStyle
|
||||
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),
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
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)
|
||||
}
|
||||
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
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.connectivity.WhiteListManager
|
||||
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.UpdateState
|
||||
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 updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
||||
var showInstallPermission by remember { mutableStateOf(false) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
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) }
|
||||
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
|
||||
@@ -419,18 +383,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
|
||||
if (page == SettingsPage.UPDATES)
|
||||
{
|
||||
UpdateCard(
|
||||
state = updateState,
|
||||
installed = versionName,
|
||||
onCheck = {
|
||||
when (updateState)
|
||||
{
|
||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
||||
else -> updateManager.checkNow()
|
||||
}
|
||||
},
|
||||
onInstall = { showUpdateConfirm = true },
|
||||
)
|
||||
UpdatesPage()
|
||||
}
|
||||
|
||||
if (page == SettingsPage.KILLSWITCH)
|
||||
@@ -445,75 +398,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
|
||||
}
|
||||
|
||||
if (page == SettingsPage.UPDATES)
|
||||
{
|
||||
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(
|
||||
if (page == SettingsPage.ROOT) Text(
|
||||
text = stringResource(R.string.app_version_fmt, versionName),
|
||||
style = ShxType.meta,
|
||||
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 })
|
||||
}
|
||||
/* 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))
|
||||
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
||||
.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)
|
||||
@@ -339,7 +339,17 @@ fun ShxButton(
|
||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.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
|
||||
fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
||||
fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
onDismissRequest = { if (dismissible) onDismiss() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }),
|
||||
properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible),
|
||||
containerColor = c.background,
|
||||
scrimColor = c.scrim,
|
||||
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(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||
|
||||
+12
-5
@@ -40,6 +40,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
@@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this)
|
||||
internal fun rememberNow(): Long
|
||||
{
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit)
|
||||
{
|
||||
while (true)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
LaunchedEffect(lifecycle)
|
||||
{ /* no ticking while the app is in the background */
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
|
||||
{
|
||||
now = System.currentTimeMillis()
|
||||
delay(1000L - now % 1000L)
|
||||
while (true)
|
||||
{
|
||||
now = System.currentTimeMillis()
|
||||
delay(1000L - now % 1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
return now
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
*/
|
||||
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "android_jni.h"
|
||||
@@ -70,6 +73,12 @@ struct private_android_service_t {
|
||||
*/
|
||||
int tunfd;
|
||||
|
||||
/**
|
||||
* eventfd that wakes the TUN reader whenever tunfd is replaced or closed
|
||||
* (-1 if it could not be created, then the reader falls back to a 1 s poll)
|
||||
*/
|
||||
int wakefd;
|
||||
|
||||
/**
|
||||
* MTU of TUN device
|
||||
*/
|
||||
@@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet)
|
||||
deliver_plain(ctx, packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake the TUN reader so it picks up a replaced or closed tunfd. Call it
|
||||
* after this->tunfd was changed (the eventfd counter stays readable until the
|
||||
* reader drains it, so a wake-up before the reader polls is not lost).
|
||||
*/
|
||||
static void wake_reader(private_android_service_t *this)
|
||||
{
|
||||
uint64_t one = 1;
|
||||
|
||||
if (this->wakefd >= 0 && write(this->wakefd, &one, sizeof(one)) < 0)
|
||||
{
|
||||
DBG1(DBG_DMN, "waking the TUN reader failed: %s", strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job handling outbound plaintext packets
|
||||
*/
|
||||
@@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
||||
{
|
||||
ip_packet_t *packet;
|
||||
chunk_t raw;
|
||||
fd_set set;
|
||||
struct pollfd fds[2];
|
||||
ssize_t len;
|
||||
int tunfd;
|
||||
int tunfd, wakefd, nfds = 1, timeout;
|
||||
bool old, dns_proxy;
|
||||
timeval_t tv = {
|
||||
/* check every second if tunfd is still valid */
|
||||
.tv_sec = 1,
|
||||
};
|
||||
|
||||
FD_ZERO(&set);
|
||||
|
||||
this->lock->read_lock(this->lock);
|
||||
if (this->tunfd < 0)
|
||||
@@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
||||
return JOB_REQUEUE_NONE;
|
||||
}
|
||||
tunfd = this->tunfd;
|
||||
FD_SET(tunfd, &set);
|
||||
wakefd = this->wakefd;
|
||||
/* cache this while we have the lock */
|
||||
dns_proxy = this->use_dns_proxy;
|
||||
this->lock->unlock(this->lock);
|
||||
|
||||
fds[0].fd = tunfd;
|
||||
fds[0].events = POLLIN;
|
||||
fds[0].revents = 0;
|
||||
if (wakefd >= 0)
|
||||
{ /* a replaced or closed tunfd wakes us, so the timeout is only a
|
||||
* safety net; without the eventfd re-check every second as before */
|
||||
fds[1].fd = wakefd;
|
||||
fds[1].events = POLLIN;
|
||||
fds[1].revents = 0;
|
||||
nfds = 2;
|
||||
timeout = 60000;
|
||||
}
|
||||
else
|
||||
{
|
||||
timeout = 1000;
|
||||
}
|
||||
|
||||
old = thread_cancelability(TRUE);
|
||||
len = select(tunfd + 1, &set, NULL, NULL, &tv);
|
||||
len = poll(fds, nfds, timeout);
|
||||
thread_cancelability(old);
|
||||
|
||||
if (len < 0)
|
||||
{
|
||||
if (errno == EBADF)
|
||||
{ /* the TUN device got closed just before calling select(), retry */
|
||||
{ /* the TUN device got closed just before calling poll(), retry */
|
||||
return JOB_REQUEUE_FAIR;
|
||||
}
|
||||
DBG1(DBG_DMN, "select on TUN device failed: %s", strerror(errno));
|
||||
DBG1(DBG_DMN, "poll on TUN device failed: %s", strerror(errno));
|
||||
return JOB_REQUEUE_NONE;
|
||||
}
|
||||
else if (len == 0)
|
||||
{ /* timeout, check again right away */
|
||||
return JOB_REQUEUE_DIRECT;
|
||||
}
|
||||
if (nfds == 2 && (fds[1].revents & POLLIN))
|
||||
{ /* tunfd was replaced or closed: drain the counter and start over with
|
||||
* the current fd (pending TUN data stays, poll is level-triggered) */
|
||||
uint64_t cnt;
|
||||
|
||||
if (read(wakefd, &cnt, sizeof(cnt)) < 0 && errno != EAGAIN)
|
||||
{
|
||||
DBG1(DBG_DMN, "reading the wake-up fd failed: %s", strerror(errno));
|
||||
}
|
||||
return JOB_REQUEUE_DIRECT;
|
||||
}
|
||||
if (fds[0].revents & POLLNVAL)
|
||||
{ /* the fd was closed while polling, retry with the current one */
|
||||
return JOB_REQUEUE_FAIR;
|
||||
}
|
||||
|
||||
raw = chunk_alloc(this->mtu);
|
||||
len = read(tunfd, raw.ptr, raw.len);
|
||||
@@ -375,6 +425,8 @@ static bool setup_tun_device(private_android_service_t *this,
|
||||
}
|
||||
this->tunfd = tunfd;
|
||||
this->lock->unlock(this->lock);
|
||||
/* a reader polling the previous fd must switch to the new one */
|
||||
wake_reader(this);
|
||||
|
||||
DBG1(DBG_DMN, "successfully created TUN device");
|
||||
|
||||
@@ -422,6 +474,7 @@ static bool setup_tun_device_without_dns(private_android_service_t *this)
|
||||
}
|
||||
this->tunfd = tunfd;
|
||||
this->lock->unlock(this->lock);
|
||||
wake_reader(this);
|
||||
|
||||
/* the fd changed, re-bind the userspace router to the new TUN */
|
||||
direct_engine_set_tun(this, engine_write_packet, this->mtu);
|
||||
@@ -446,6 +499,8 @@ static void close_tun_device(private_android_service_t *this)
|
||||
tunfd = this->tunfd;
|
||||
this->tunfd = -1;
|
||||
this->lock->unlock(this->lock);
|
||||
/* let the reader notice tunfd < 0 and quit instead of polling a dead fd */
|
||||
wake_reader(this);
|
||||
|
||||
/* drop the remembered binding too, so a lazily created engine does not
|
||||
* attach to the dead TUN, and flush the engine if it exists */
|
||||
@@ -977,6 +1032,10 @@ METHOD(android_service_t, destroy, void,
|
||||
close_tun_device(this);
|
||||
this->dns_proxy->destroy(this->dns_proxy);
|
||||
this->lock->destroy(this->lock);
|
||||
if (this->wakefd >= 0)
|
||||
{
|
||||
close(this->wakefd);
|
||||
}
|
||||
this->settings->destroy(this->settings);
|
||||
free(this);
|
||||
}
|
||||
@@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds,
|
||||
.settings = settings,
|
||||
.creds = creds,
|
||||
.tunfd = -1,
|
||||
.wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC),
|
||||
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
|
||||
);
|
||||
/* only allow queries for the VPN gateway */
|
||||
|
||||
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
|
||||
{
|
||||
active = TRUE;
|
||||
this->udp->reprotect(this->udp);
|
||||
if (this->tcp_active)
|
||||
{
|
||||
this->tcp->reprotect(this->tcp);
|
||||
}
|
||||
/* 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);
|
||||
/* the network changed: whatever was dead for direct connects
|
||||
* may work again - drop the tunnel fallbacks with the rest of
|
||||
* 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);
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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->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 */
|
||||
|
||||
@@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void,
|
||||
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,
|
||||
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 = {
|
||||
.handle_packet = _handle_packet,
|
||||
.check_timers = _check_timers,
|
||||
.flow_count = _flow_count,
|
||||
.set_mtu = _set_mtu,
|
||||
.reprotect = _reprotect,
|
||||
.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()
|
||||
}
|
||||
}
|
||||
+26
@@ -242,4 +242,30 @@ class HealthCheckManagerTest
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundTrafficSkipsProbesInAdaptiveMode()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, 60_000)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWithoutInboundOrAfterCap()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 100, 60_000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, -1, 60_000)).isFalse()
|
||||
assertThat(
|
||||
HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, HealthCheckManager.PASSIVE_MAX_PROBE_AGE_MS)
|
||||
).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWhileFailureIsObservedOrNotAdaptive()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 1, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 2, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, true, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(adaptiveMode = false), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(aggressiveMode = true), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.0 (versionCode 165)**.
|
||||
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
|
||||
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
||||
|
||||
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# План оптимизации энергопотребления SHX VPN (Android)
|
||||
|
||||
Основа — разбор кода версии 2.18.0 (165). Пункты, помеченные «проверить», в коде не подтверждены и требуют замера
|
||||
или чтения кода перед правкой. Цель: меньше пробуждений CPU и радио при подключённом туннеле с погашенным экраном.
|
||||
|
||||
## 1. Что уже сделано (не трогать без замеров)
|
||||
|
||||
- `HealthCheckManager`: адаптивный режим (экран выключен → интервал не меньше 60 с, экономия заряда или низкий заряд → 300 с, без быстрых опросов),
|
||||
экспоненциальный бэкофф при сбое, без будильников и wakelock'ов.
|
||||
- `WatchdogReceiver`: будильники растягиваются — «припаркован» 5 мин / 30 мин (wakeup), «соединён» 5 мин / 60 мин.
|
||||
- `HomeViewModel`: опрос трафика (1 с) и проб (10 с) только пока видна вкладка «Главная» и VPN соединён (`repeatOnLifecycle` + `whileHomeConnected`).
|
||||
- `CharonVpnService`: диагностика маршрутов раз в 5 мин только в расширенном режиме; уведомление не чаще раза в 5 с (`NOTIFICATION_MIN_INTERVAL_MS`).
|
||||
- Фоновая работа: `WorkManager` (обновления 12 ч — с условиями сети и зарядки, профили 24 ч, итоги недели 7 дн.), wakelock'ов в коде нет.
|
||||
- `DiagLog`: писатель открыт, ротация по счётчику байт, а не по `stat` на каждую строку.
|
||||
|
||||
## 1a. Главный упор: лишняя работа CPU
|
||||
|
||||
Приоритет всего плана — убрать работу, которую телефон делает без пользы (пробуждения, холостые циклы, повторные вычисления).
|
||||
Критерий: меньше CPU-времени и пробуждений в час на подключённом туннеле, при погашенном экране — близко к нулю сверх IKE/ESP.
|
||||
|
||||
**Подтверждено по коду:**
|
||||
- **Холостое пробуждение раз в секунду в нативе.** `backend/android_service.c`: цикл чтения TUN делает `select()` с таймаутом 1 с
|
||||
(«check every second if tunfd is still valid») и по таймауту сразу возвращается в очередь заданий — поток просыпается каждую секунду
|
||||
даже без трафика. Заменить на ожидание без таймера: `poll()` по TUN и `eventfd`/pipe, который будит закрытие TUN (или таймаут 30–60 с как страховка).
|
||||
Это самый дешёвый и самый бесспорный выигрыш: один поток, ~3600 пробуждений в час → единицы.
|
||||
- Движок прямых потоков (`routing/direct_engine.c`) уже адаптивен: простой растягивает `poll` до 4 с (1 с, пока есть TCP-потоки).
|
||||
Остаётся: не вызывать `tcp->check_timers` / `sys_check_timeouts` и не пересобирать `pollfd` в холостых раундах, довести холостой интервал
|
||||
до 10–30 с при погашенном экране (будить через `wake`-fd при новом пакете — он уже есть).
|
||||
- Java: `ScenarioEngine.poll` (4 с, IPC в `UsageStatsManager`), `rememberNow()` (1 с), опрос трафика 1 с — только при видимой «Главной», это нормально.
|
||||
- `RoutingManager.refreshSites` каждые 30 мин резолвит все домены — разовая нагрузка на радио и CPU, но без пользы при погашенном экране.
|
||||
- `DiagLog`: каждая строка — `format.format(Date())` + `toByteArray` + `flush()` (системный вызов). При частых событиях сети это лишний CPU и запись во флеш.
|
||||
|
||||
**Проверить замером (гипотезы):**
|
||||
- сколько CPU съедает разбор/поиск по базе адресов РФ (`RuIpListManager`, `IPRangeSet`) при загрузке и обновлении, и не пересобирается ли набор без изменений
|
||||
(сравнивать хэш до применения, не вызывать `pushUserspaceRouting` без реального диффа);
|
||||
- Compose: лишние рекомпозиции «Главной» (счётчик трафика раз в секунду трогает большое дерево?) — Layout Inspector / recomposition counts;
|
||||
- число одновременных `NetworkCallback` (см. C) — каждое событие сети запускает дублирующую работу в семи местах.
|
||||
|
||||
## 2. Фаза 0 — замер базовой линии (до любых правок)
|
||||
|
||||
Без цифр правки ниже — гипотезы. Сценарии (эмулятор не годится, нужен реальный телефон):
|
||||
|
||||
1. Ночь: VPN соединён, экран выключен, Wi-Fi, 8 ч. То же на мобильной сети.
|
||||
2. Те же условия с включёнными: проверкой здоровья, сценарием «приложение открыто», сайтами в правилах, «сайты РФ напрямую».
|
||||
3. День: экран включён, вкладки «Главная» / «Маршруты» / «Диагностика».
|
||||
|
||||
Инструменты: `adb shell dumpsys batterystats --reset` → прогон → `dumpsys batterystats one.shx.strongswan.ext`,
|
||||
`dumpsys alarm` (число и тип будильников пакета), `dumpsys jobscheduler`, Perfetto (пробуждения CPU, радио), Battery Historian.
|
||||
Фиксировать: mAh за час, число wakeup-будильников в час, время активного радио, CPU нативного роутера.
|
||||
|
||||
## 3. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта)
|
||||
|
||||
### A. Проверка здоровья: пассивные сигналы вместо проб (сделано в этой ветке)
|
||||
`HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы
|
||||
через туннель — каждая держит радио. Идеи:
|
||||
- пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив);
|
||||
- при выключенном экране: один таргет вместо трёх, интервал 5 мин;
|
||||
- не стартовать цикл вообще, пока устройство в Doze — не делалось: корутинные таймеры и так не будят спящий CPU.
|
||||
|
||||
Реализовано: в адаптивном режиме цикл пропускает пробы, если за интервал через туннель пришло ≥ 4 КБ входящих данных
|
||||
(`CharonVpnService.queryTraffic()`), но реальная проба идёт не реже раза в 5 минут; при наблюдаемом сбое, ожидающем решении,
|
||||
агрессивном режиме или неизвестном счётчике пропуска нет. При выключенном экране проверяется один адрес вместо трёх.
|
||||
Сама проверка здоровья включается пользователем (`HealthConfig.enabled = false` по умолчанию), поэтому выигрыш — для тех, кто её включил.
|
||||
|
||||
### B. Keepalive и будильники strongSwan — проверено, код не менялся
|
||||
Умолчание уже экономное: `charonservice.c` ставит `charon.keep_alive = 45` с (`ANDROID_KEEPALIVE_INTERVAL`, у upstream-strongSwan 20 с)
|
||||
и DPD-запас 20 с; профильный `nat_keepalive` (10–120 с) его переопределяет. Keepalive уходит только когда исходящего трафика не было.
|
||||
Поднимать 45 с выше без замеров нельзя: UDP-таймауты NAT операторов бывают 30–60 с, туннель «жив», но молчит — это прямое нарушение условия раздела 5.
|
||||
Что остаётся: замерить число будильников (`dumpsys alarm`) за ночь и, если вклад велик, добавить в профиль **необязательную** настройку,
|
||||
не меняя умолчание; рекомендовать значения по результатам теста на конкретной сети пользователя.
|
||||
|
||||
### C. Сетевые колбэки — единый хаб отклонён, исправлен один шумный
|
||||
Разбор показал, что хаб не нужен: `NetworkMonitor` и `WhiteListManager` уже отсекают тики уровня сигнала в `onCapabilitiesChanged`,
|
||||
`PauseManager` регистрируется только на время паузы, проверка здоровья — только при включённой функции и соединённом VPN,
|
||||
а `NetworkManager` и `UnderlyingNetworkTracker` в `CharonVpnService` — часть самого туннеля (трогать нельзя по разделу 5).
|
||||
Выигрыш от объединения — единицы вызовов Binder на смену сети при заметном риске для автоподключения и белых списков.
|
||||
Исправлено единственное реальное место: `HomeViewModel.onCapabilitiesChanged` на каждый тик сигнала Wi-Fi заново читал SSID (системный вызов);
|
||||
теперь состояние пересчитывается только при смене транспорта.
|
||||
|
||||
### D. Опрос переднего приложения в сценариях (средний эффект, низкая сложность)
|
||||
`ScenarioEngine.poll` — каждые 4 с `UsageStatsManager.queryEvents` (IPC), пока есть сценарий с триггером «приложение».
|
||||
При выключенном экране выборка пропускается, но `postDelayed` продолжает тикать.
|
||||
- остановить цикл по `ACTION_SCREEN_OFF`, запускать по `ACTION_SCREEN_ON`;
|
||||
- увеличить интервал до 8–10 с, если foreground-приложение не менялось несколько циклов.
|
||||
|
||||
### E. Обновление адресов сайтов из правил (средний эффект, низкая сложность)
|
||||
`RoutingManager.refreshSites` каждые 30 мин резолвит все домены правил, пока соединён VPN, независимо от экрана.
|
||||
- пока экран выключен — не резолвить; обновить один раз при `ACTION_SCREEN_ON`, если прошло больше `SITE_REFRESH_MS`;
|
||||
- резолвить только домены, чьи TTL истекли.
|
||||
|
||||
### F. UI-циклы после ухода с экрана (низкий эффект, низкая сложность)
|
||||
Проверить, что все циклы отвязаны от жизненного цикла (через `repeatOnLifecycle(STARTED)`):
|
||||
`RoutingScreen` (5 с, уже на `repeatOnLifecycle`), `DiagnosticsScreen` (5 с), `ProfilesScreen` и `ServerActivity` (30 с, 1,5 с),
|
||||
`RecentAppsScreen` (10 с), `LogActivity` (800 мс), `rememberNow()` в `HomeStatus.kt` (тик каждую секунду, пока composable жив).
|
||||
Отдельно: `rememberNow()` должен тикать только если на экране есть обратный отсчёт паузы или время подключения.
|
||||
|
||||
### G. Нативный роутер «сайты РФ напрямую» (неизвестный эффект, замер)
|
||||
Пользовательский роутер (`getRoutingStats`, relay UDP/TCP) обрабатывает пакеты в процессе. Замерить CPU-время потоков
|
||||
в Perfetto в режимах «Стабильно» и «Гибко» на типовой нагрузке (видео, мессенджер). Если вклад заметный — смотреть размеры очередей,
|
||||
размер буферов, пакетную обработку и спин-ожидание в нативной части (`app/src/main/jni`).
|
||||
|
||||
### H. Мелочи
|
||||
- `DiagLog`: flush на каждую строку нужен для живучести; при выключенном экране можно понизить подробность (watchdog, network-change)
|
||||
или не логировать повторы.
|
||||
- Проверить `WhiteListManager` (перепроверки `recheckJob`, `refreshIfStale`) на периодические пробы при выключенном экране.
|
||||
- Подсказка пользователю на экране «Батарея» (`BatteryActivity`): показывать, какие функции (проверка здоровья, сценарии по приложению)
|
||||
заметно расходуют заряд и как их смягчить.
|
||||
|
||||
## 4. Порядок работ
|
||||
|
||||
1. Фаза 0: базовая линия на реальном телефоне (раздел 2); для CPU добавить `adb shell top -H -p <pid>` / Perfetto по потокам (счёт пробуждений потока чтения TUN).
|
||||
2. Быстрые правки с низким риском: секундный `select` TUN (раздел 1a), D, E, F, `DiagLog` — один релиз, сравнить с базой.
|
||||
3. A (пассивная проверка здоровья) и B (keepalive) — отдельные релизы, каждый со своим замером; B только после проверки на сетях операторов.
|
||||
4. C (хаб колбэков) — рефакторинг, после A/B; регрессия по сценариям смены сети (Wi-Fi ↔ мобильная, роуминг, белые списки, пауза).
|
||||
5. G — по результатам Perfetto.
|
||||
6. После каждой правки: тот же прогон из фазы 0; принимается, если mAh/ч и число будильников не растут, а надёжность не падает.
|
||||
|
||||
## 5. Жёсткое условие: туннель не должен падать в фоне
|
||||
|
||||
Экономия не оправдывает потери туннеля при свёрнутом приложении, погашенном экране или Doze. Правила для каждой правки:
|
||||
|
||||
- **Не трогать** то, что держит процесс и туннель живыми: foreground-сервис `CharonVpnService` (`startForegroundSafe`), будильники `Scheduler`
|
||||
(NAT-keepalive, rekey), `FgsStartRetry`, страховочные будильники `WatchdogReceiver`, DPD charon. Их интервалы не растягивать «заодно».
|
||||
- **Растягивать можно только** вторичные проверки поверх IKE DPD (пробы здоровья, опросы UI/сценариев, диагностика, DNS сайтов).
|
||||
Любое растяжение обязано иметь быстрый выход по событию: смена сети, `ACTION_SCREEN_ON`, потеря валидации VPN-сети (`vpnCallback`).
|
||||
- **TUN `select` (1а):** секундный таймаут сейчас ещё и ловит закрытие TUN (`EBADF`). Замена обязана будить поток при закрытии
|
||||
(`eventfd` пишется в `close`/реконнекте/реавторизации) — иначе поток зависнет на закрытом дескрипторе, и туннель не переподнимется.
|
||||
Страховочный таймаут оставить (30–60 с), не убирать совсем.
|
||||
- **Keepalive (B):** менять умолчание только после проверки на реальных операторах; слишком большой интервал — NAT закрывает соединение, туннель «жив», но молчит.
|
||||
- **Пробы здоровья (A):** пассивный сигнал «трафик идёт» не должен подавлять проверку, если исходящий трафик есть, а входящего нет.
|
||||
- **Приёмка каждого релиза** (кроме замеров): ночь с погашенным экраном — туннель не разорван, входящий пуш/пинг проходит утром без ручных действий;
|
||||
`dumpsys deviceidle force-idle` → выход из Doze → трафик идёт; смена Wi-Fi ↔ мобильная сеть при погашенном экране; убийство процесса
|
||||
(`adb shell am kill`) → watchdog поднимает туннель; `adb logcat -b crash` чист. Не проходит хотя бы один пункт — правка откатывается.
|
||||
|
||||
## 6. Риски и ограничения
|
||||
|
||||
- Надёжность туннеля важнее экономии: правки A, B, E не должны ухудшать восстановление после смены сети и Doze
|
||||
(смоделировать `adb shell dumpsys deviceidle force-idle`, отключение и возврат сети, подмену SSID).
|
||||
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
|
||||
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
|
||||
- Строки 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