Compare commits
23
Commits
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: docs
|
||||||
|
description: Looks up API references and library documentation. Read-only.
|
||||||
|
model: claude-haiku-5-5
|
||||||
|
tools: Read, Grep, Glob, WebFetch, WebSearch
|
||||||
|
---
|
||||||
|
Find the relevant API reference and return signatures and short quotes with sources. Never modify anything.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: grep
|
||||||
|
description: Finds call sites and usages of a symbol. Read-only.
|
||||||
|
model: claude-haiku-5-5
|
||||||
|
tools: Read, Grep, Glob
|
||||||
|
---
|
||||||
|
List every call site of the given symbol as file:line with a short context. Never modify anything.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: search
|
||||||
|
description: Repo mapping. Finds files, modules and directory structure. Read-only.
|
||||||
|
model: claude-haiku-5-5
|
||||||
|
tools: Read, Glob, Grep
|
||||||
|
---
|
||||||
|
Map the repository: locate relevant files, modules and entry points. Return paths and one-line summaries. Never modify anything.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-sonnet-5-5"
|
||||||
|
}
|
||||||
@@ -40,3 +40,21 @@
|
|||||||
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
||||||
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
||||||
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
||||||
|
|
||||||
|
## Android-приложение: выкладка через панель SHX VPN
|
||||||
|
|
||||||
|
Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель
|
||||||
|
`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку.
|
||||||
|
|
||||||
|
- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree):
|
||||||
|
`./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`.
|
||||||
|
- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`.
|
||||||
|
Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=<id>`.
|
||||||
|
- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что
|
||||||
|
`NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии).
|
||||||
|
- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра,
|
||||||
|
если процесс запущен раньше). Не печатать значение токена.
|
||||||
|
- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает.
|
||||||
|
- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом).
|
||||||
|
- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и
|
||||||
|
требует отдельного подтверждения.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@AGENTS.md
|
||||||
|
|
||||||
|
# Advisor rule
|
||||||
|
Ping the advisor (Opus 5.5) before locking any plan, as soon as any error repeats a second time, and before the final task handoff.
|
||||||
@@ -45,8 +45,8 @@ android {
|
|||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode 166
|
versionCode 177
|
||||||
versionName "2.18.1"
|
versionName "2.19.9"
|
||||||
|
|
||||||
/* Base URL of the update storage, trailing slash included */
|
/* Base URL of the update storage, trailing slash included */
|
||||||
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||||
@@ -74,6 +74,17 @@ android {
|
|||||||
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
||||||
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
||||||
|
|
||||||
|
/* SHX update API (web panel). When UPDATE_API_URL, UPDATE_API_PUBLIC_KEY and UPDATE_API_HMAC_KEY are all set,
|
||||||
|
the app checks for updates there; the bucket path above stays as a fallback while the API is unreachable.
|
||||||
|
UPDATE_API_PUBLIC_KEY is the raw Ed25519 public key (base64, 32 bytes) shown on the panel's "API updates" page;
|
||||||
|
the HMAC key is only a guard against casual requests (an APK can always be unpacked), integrity rests on the
|
||||||
|
manifest signature and on the SHA-256 plus APK signature checks. */
|
||||||
|
buildConfigField "String", "UPDATE_API_URL", "\"${envOr('UPDATE_API_URL', '')}\""
|
||||||
|
buildConfigField "String", "UPDATE_API_PUBLIC_KEY", "\"${envOr('UPDATE_API_PUBLIC_KEY', '')}\""
|
||||||
|
buildConfigField "String", "UPDATE_API_HMAC_KEY", "\"${xorB64(envOr('UPDATE_API_HMAC_KEY', ''))}\""
|
||||||
|
/* public download page, opened by «Download from the site» when an update fails */
|
||||||
|
buildConfigField "String", "UPDATE_DOWNLOAD_PAGE_URL", "\"${envOr('UPDATE_DOWNLOAD_PAGE_URL', 'https://get.shnt.top/get/universal')}\""
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
arguments '-j' + Runtime.runtime.availableProcessors()
|
arguments '-j' + Runtime.runtime.availableProcessors()
|
||||||
@@ -175,6 +186,8 @@ dependencies {
|
|||||||
implementation 'androidx.activity:activity-compose:1.11.0'
|
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||||
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
||||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
|
/* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */
|
||||||
|
implementation 'net.i2p.crypto:eddsa:0.3.0'
|
||||||
|
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
@@ -182,6 +195,8 @@ dependencies {
|
|||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.assertj:assertj-core:3.27.6'
|
testImplementation 'org.assertj:assertj-core:3.27.6'
|
||||||
testImplementation 'org.mockito:mockito-core:5.20.0'
|
testImplementation 'org.mockito:mockito-core:5.20.0'
|
||||||
|
/* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */
|
||||||
|
testImplementation 'org.json:json:20240303'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||||
@@ -189,3 +204,32 @@ dependencies {
|
|||||||
androidTestImplementation composeBom
|
androidTestImplementation composeBom
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step.
|
||||||
|
./gradlew uploadShx -> draft in the beta channel, type phone
|
||||||
|
./gradlew uploadShx -PshxPublish -PshxRollout=25
|
||||||
|
./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..."
|
||||||
|
Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens).
|
||||||
|
Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */
|
||||||
|
tasks.register('uploadShx', Exec) {
|
||||||
|
group = 'publishing'
|
||||||
|
description = 'Build the release APK and upload it to the SHX VPN panel'
|
||||||
|
dependsOn 'assembleRelease'
|
||||||
|
def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk')
|
||||||
|
def script = rootProject.file('scripts/upload-apk.mjs')
|
||||||
|
doFirst {
|
||||||
|
def apk = apkFile.get().asFile
|
||||||
|
if (!apk.exists()) {
|
||||||
|
throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)")
|
||||||
|
}
|
||||||
|
def args = ['node', script.absolutePath, apk.absolutePath,
|
||||||
|
'--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(),
|
||||||
|
'--channel', (findProperty('shxChannel') ?: 'beta').toString()]
|
||||||
|
def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString()
|
||||||
|
if (notes) args += ['--notes', notes]
|
||||||
|
if (project.hasProperty('shxPublish')) {
|
||||||
|
args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()]
|
||||||
|
}
|
||||||
|
commandLine args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,3 +64,6 @@
|
|||||||
-keep class * extends androidx.room.RoomDatabase {
|
-keep class * extends androidx.room.RoomDatabase {
|
||||||
<init>();
|
<init>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
|
||||||
|
-dontwarn sun.security.x509.**
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
@@ -339,6 +340,17 @@
|
|||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.BootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
+13
-3
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
return this;
|
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
|
@Override
|
||||||
public void close()
|
public void close()
|
||||||
{
|
{
|
||||||
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
public VpnProfile insertProfile(VpnProfile profile)
|
public VpnProfile insertProfile(VpnProfile profile)
|
||||||
{
|
{
|
||||||
ContentValues values = ContentValuesFromVpnProfile(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)
|
if (insertId == -1)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
{
|
{
|
||||||
final UUID uuid = profile.getUUID();
|
final UUID uuid = profile.getUUID();
|
||||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
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
|
@Override
|
||||||
public boolean deleteVpnProfile(VpnProfile profile)
|
public boolean deleteVpnProfile(VpnProfile profile)
|
||||||
{
|
{
|
||||||
final UUID uuid = profile.getUUID();
|
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
|
@Override
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* 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.Context
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.events.EventAction
|
||||||
|
import org.strongswan.android.logic.events.EventCategory
|
||||||
|
import org.strongswan.android.logic.events.EventKind
|
||||||
|
import org.strongswan.android.logic.events.EventLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Block connections without VPN» as the system reports it. Only the running
|
||||||
|
* VpnService can ask (Android 10+), so the last answer is kept for the UI.
|
||||||
|
* When it was on and is off now, the user gets a warning.
|
||||||
|
*/
|
||||||
|
object AlwaysOnVpn
|
||||||
|
{
|
||||||
|
private const val PREF_LOCKDOWN = "pref_vpn_lockdown"
|
||||||
|
private const val EVENT_KEY = "lockdown-off"
|
||||||
|
|
||||||
|
/** Last value seen by the service; false before the first connection or below Android 10. */
|
||||||
|
@JvmStatic
|
||||||
|
fun isLockdown(context: Context): Boolean =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).getBoolean(PREF_LOCKDOWN, false)
|
||||||
|
|
||||||
|
/** Called by the service when the tunnel is up. */
|
||||||
|
@JvmStatic
|
||||||
|
fun update(service: VpnService)
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lockdown = try
|
||||||
|
{
|
||||||
|
service.isLockdownEnabled
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(service)
|
||||||
|
val was = prefs.getBoolean(PREF_LOCKDOWN, false)
|
||||||
|
if (lockdown != was)
|
||||||
|
{
|
||||||
|
prefs.edit().putBoolean(PREF_LOCKDOWN, lockdown).apply()
|
||||||
|
DiagLog.i("AlwaysOnVpn", "lockdown ${if (lockdown) "on" else "off"} (always-on ${service.isAlwaysOn})")
|
||||||
|
}
|
||||||
|
val events = EventLog.getInstance(service)
|
||||||
|
if (lockdown)
|
||||||
|
{
|
||||||
|
events.resolve(EVENT_KEY)
|
||||||
|
}
|
||||||
|
else if (was)
|
||||||
|
{
|
||||||
|
events.record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, service.getString(R.string.event_lockdown_off_title),
|
||||||
|
service.getString(R.string.event_lockdown_off_text), EventAction.OPEN_VPN_SETTINGS, EVENT_KEY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
-2
@@ -92,6 +92,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.SortedSet;
|
import java.util.SortedSet;
|
||||||
|
import java.util.TimeZone;
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -171,6 +172,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
private volatile boolean mProfileUpdated;
|
private volatile boolean mProfileUpdated;
|
||||||
private volatile boolean mTerminate;
|
private volatile boolean mTerminate;
|
||||||
private volatile boolean mIsDisconnecting;
|
private volatile boolean mIsDisconnecting;
|
||||||
|
/* why-did-the-SA-go-down diagnostics (plan 8.1); no effect on behaviour */
|
||||||
|
private final DailyCounter mSaDropCounter = new DailyCounter();
|
||||||
|
private volatile long mSaUpSince;
|
||||||
|
private volatile long mLastNetworkChange;
|
||||||
|
private volatile boolean mRetransmitTimeout;
|
||||||
|
private volatile boolean mReauth;
|
||||||
private volatile boolean mShowNotification;
|
private volatile boolean mShowNotification;
|
||||||
/* start of the current CONNECTED session for the notification chronometer */
|
/* start of the current CONNECTED session for the notification chronometer */
|
||||||
private volatile long mConnectedSince;
|
private volatile long mConnectedSince;
|
||||||
@@ -219,6 +226,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
static final int STATE_UNREACHABLE_ERROR = 6;
|
static final int STATE_UNREACHABLE_ERROR = 6;
|
||||||
static final int STATE_CERTIFICATE_UNAVAILABLE = 7;
|
static final int STATE_CERTIFICATE_UNAVAILABLE = 7;
|
||||||
static final int STATE_GENERIC_ERROR = 8;
|
static final int STATE_GENERIC_ERROR = 8;
|
||||||
|
static final int STATE_RETRANSMIT_TIMEOUT = 9;
|
||||||
|
static final int STATE_REAUTH = 10;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(Intent intent, int flags, int startId)
|
public int onStartCommand(Intent intent, int flags, int startId)
|
||||||
@@ -269,7 +278,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
VpnProfile profile = loadProfile(uuid);
|
VpnProfile profile = loadProfile(uuid);
|
||||||
if (profile != null)
|
if (profile != null)
|
||||||
{
|
{
|
||||||
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD));
|
String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
|
||||||
|
if (password != null)
|
||||||
|
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
|
||||||
|
* password: keep the one saved with the profile instead of
|
||||||
|
* wiping it, or the tunnel never comes back («password unavailable») */
|
||||||
|
profile.setPassword(password);
|
||||||
|
}
|
||||||
/* a backup port of F_Profile, for this attempt only */
|
/* a backup port of F_Profile, for this attempt only */
|
||||||
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
||||||
if (port > 0)
|
if (port > 0)
|
||||||
@@ -940,11 +955,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
if (state == State.CONNECTED)
|
if (state == State.CONNECTED)
|
||||||
{
|
{
|
||||||
TrafficHistory.start(this);
|
TrafficHistory.start(this);
|
||||||
|
AlwaysOnVpn.update(this);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
TrafficHistory.stop(this);
|
TrafficHistory.stop(this);
|
||||||
}
|
}
|
||||||
|
if (state == State.DISABLED)
|
||||||
|
{ /* the local DoH/DoT resolver lives only with a tunnel */
|
||||||
|
setDnsRedirect(null, 0);
|
||||||
|
org.strongswan.android.logic.protection.SecureDns.stop("disconnected");
|
||||||
|
}
|
||||||
synchronized (mServiceLock)
|
synchronized (mServiceLock)
|
||||||
{
|
{
|
||||||
if (mService != null)
|
if (mService != null)
|
||||||
@@ -1019,12 +1040,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
switch (status)
|
switch (status)
|
||||||
{
|
{
|
||||||
case STATE_CHILD_SA_DOWN:
|
case STATE_CHILD_SA_DOWN:
|
||||||
|
logSaDown();
|
||||||
if (!mIsDisconnecting)
|
if (!mIsDisconnecting)
|
||||||
{
|
{
|
||||||
setState(State.CONNECTING);
|
setState(State.CONNECTING);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case STATE_CHILD_SA_UP:
|
case STATE_CHILD_SA_UP:
|
||||||
|
mSaUpSince = SystemClock.elapsedRealtime();
|
||||||
|
mRetransmitTimeout = false;
|
||||||
|
mReauth = false;
|
||||||
setState(State.CONNECTED);
|
setState(State.CONNECTED);
|
||||||
scheduleDnsDiagnostics();
|
scheduleDnsDiagnostics();
|
||||||
scheduleRouteDiagnostics();
|
scheduleRouteDiagnostics();
|
||||||
@@ -1053,12 +1078,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
case STATE_GENERIC_ERROR:
|
case STATE_GENERIC_ERROR:
|
||||||
setErrorDisconnect(ErrorState.GENERIC_ERROR);
|
setErrorDisconnect(ErrorState.GENERIC_ERROR);
|
||||||
break;
|
break;
|
||||||
|
case STATE_RETRANSMIT_TIMEOUT:
|
||||||
|
mRetransmitTimeout = true;
|
||||||
|
break;
|
||||||
|
case STATE_REAUTH:
|
||||||
|
mReauth = true;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
Log.e(TAG, "Unknown status code received");
|
Log.e(TAG, "Unknown status code received");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes why an established SA went down to the diagnostic log (plan 8.1).
|
||||||
|
* Does not touch the connection state.
|
||||||
|
*/
|
||||||
|
private void logSaDown()
|
||||||
|
{
|
||||||
|
long up = mSaUpSince;
|
||||||
|
boolean timeout = mRetransmitTimeout;
|
||||||
|
boolean reauth = mReauth;
|
||||||
|
mRetransmitTimeout = false;
|
||||||
|
mReauth = false;
|
||||||
|
if (up == 0)
|
||||||
|
{ /* no SA was up: a failed first connect is logged elsewhere */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mSaUpSince = 0;
|
||||||
|
long now = SystemClock.elapsedRealtime();
|
||||||
|
long netAgo = mLastNetworkChange == 0 ? -1 : now - mLastNetworkChange;
|
||||||
|
SaClose why = SaClose.classify(mIsDisconnecting, reauth, timeout, netAgo);
|
||||||
|
String count = "";
|
||||||
|
if (why.getDrop())
|
||||||
|
{
|
||||||
|
long wall = System.currentTimeMillis();
|
||||||
|
count = ", drop #" + mSaDropCounter.next(wall, TimeZone.getDefault().getOffset(wall)) +
|
||||||
|
" today (resets with the process)";
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "SA down: " + why.getLabel() + ", was up " + ((now - up) / 1000) + "s, network change " +
|
||||||
|
(netAgo < 0 ? "none" : (netAgo / 1000) + "s ago") + count +
|
||||||
|
(why == SaClose.OTHER ? " (no retransmit-timeout alert; MOBIKE path probing gives up without one)" : ""));
|
||||||
|
}
|
||||||
|
|
||||||
/** Delay before the DNS chain diagnostics run, so validation settles first. */
|
/** Delay before the DNS chain diagnostics run, so validation settles first. */
|
||||||
private static final long DNS_DIAG_DELAY_MS = 10_000;
|
private static final long DNS_DIAG_DELAY_MS = 10_000;
|
||||||
private static final String DNS_DIAG_PROBE = "one.one.one.one";
|
private static final String DNS_DIAG_PROBE = "one.one.one.one";
|
||||||
@@ -1072,6 +1134,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sDnsDiagAt = SystemClock.elapsedRealtime();
|
||||||
mDiagExecutor.execute(() -> {
|
mDiagExecutor.execute(() -> {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1129,6 +1192,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
&& vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
&& vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
||||||
+ ", dns=" + (lp != null ? lp.getDnsServers() : "?")
|
+ ", dns=" + (lp != null ? lp.getDnsServers() : "?")
|
||||||
+ ", routes=" + (lp != null ? lp.getRoutes().size() : "?"));
|
+ ", routes=" + (lp != null ? lp.getRoutes().size() : "?"));
|
||||||
|
if (lp != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
{ /* whether Android validated the strict private DNS server over the tunnel */
|
||||||
|
DiagLog.i(TAG, "dns diag: vpn privateDns active=" + lp.isPrivateDnsActive()
|
||||||
|
+ ", name=" + lp.getPrivateDnsServerName());
|
||||||
|
}
|
||||||
if (underlying != null)
|
if (underlying != null)
|
||||||
{
|
{
|
||||||
NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying);
|
NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying);
|
||||||
@@ -1181,8 +1249,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a flapping tunnel must not repeat the probes (three lookups and a TCP
|
||||||
|
* connect per DoT address) on every reconnect: the radio pays for each */
|
||||||
|
private static final long DNS_DIAG_MIN_GAP_MS = 10 * 60 * 1000;
|
||||||
|
private static volatile long sDnsDiagAt;
|
||||||
|
|
||||||
private void scheduleDnsDiagnostics()
|
private void scheduleDnsDiagnostics()
|
||||||
{
|
{
|
||||||
|
if (sDnsDiagAt != 0 && SystemClock.elapsedRealtime() - sDnsDiagAt < DNS_DIAG_MIN_GAP_MS)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
mHandler.removeCallbacks(mDnsDiagRunnable);
|
mHandler.removeCallbacks(mDnsDiagRunnable);
|
||||||
mHandler.postDelayed(mDnsDiagRunnable, DNS_DIAG_DELAY_MS);
|
mHandler.postDelayed(mDnsDiagRunnable, DNS_DIAG_DELAY_MS);
|
||||||
}
|
}
|
||||||
@@ -1688,6 +1765,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
*/
|
*/
|
||||||
public native void initiate(String config);
|
public native void initiate(String config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted DNS: UDP/53 to vip (IPv4, 4 bytes) goes to 127.0.0.1:port;
|
||||||
|
* null or 0 turns it off.
|
||||||
|
*/
|
||||||
|
public native void setDnsRedirect(byte[] vip, int port);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sum inbound/outbound CHILD_SA bytes, provided by libandroidbridge.so.
|
* Sum inbound/outbound CHILD_SA bytes, provided by libandroidbridge.so.
|
||||||
* Static so it can be polled from the UI.
|
* Static so it can be polled from the UI.
|
||||||
@@ -1872,6 +1955,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mApplied = current;
|
mApplied = current;
|
||||||
|
mLastNetworkChange = SystemClock.elapsedRealtime();
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||||
{
|
{
|
||||||
@@ -2311,6 +2395,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
*/
|
*/
|
||||||
public class BuilderCache
|
public class BuilderCache
|
||||||
{
|
{
|
||||||
|
/* the user's DoH/DoT resolver when it answered the probe, else null */
|
||||||
|
private org.strongswan.android.logic.protection.SecureDnsConfig mSecureDns;
|
||||||
private final List<IPRange> mAddresses = new ArrayList<>();
|
private final List<IPRange> mAddresses = new ArrayList<>();
|
||||||
private final List<IPRange> mRoutesIPv4 = new ArrayList<>();
|
private final List<IPRange> mRoutesIPv4 = new ArrayList<>();
|
||||||
private final List<IPRange> mRoutesIPv6 = new ArrayList<>();
|
private final List<IPRange> mRoutesIPv6 = new ArrayList<>();
|
||||||
@@ -2411,6 +2497,19 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
sAppliedGeoExclusionsV6 = mGeoExcludedV6;
|
sAppliedGeoExclusionsV6 = mGeoExcludedV6;
|
||||||
|
|
||||||
|
|
||||||
|
/* F_Protection: encrypted DNS (own DoH/DoT) is used only if it answers
|
||||||
|
* now; otherwise the profile's DNS, never a silent dead resolver */
|
||||||
|
org.strongswan.android.logic.protection.SecureDnsConfig secure =
|
||||||
|
org.strongswan.android.logic.protection.Protection.secureDns(CharonVpnService.this);
|
||||||
|
if (secure != null)
|
||||||
|
{
|
||||||
|
mSecureDns = org.strongswan.android.logic.protection.SecureDns.ensureReady(secure) ? secure : null;
|
||||||
|
org.strongswan.android.logic.events.Events.secureDnsResult(CharonVpnService.this, mSecureDns != null);
|
||||||
|
if ("hostname".equals(Settings.Global.getString(getContentResolver(), "private_dns_mode")))
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "strict private DNS is on: the system resolver bypasses the tunnel DNS, own DoH/DoT has no effect");
|
||||||
|
}
|
||||||
|
}
|
||||||
/* F_Protection: a chosen resolver (filtering or not) wins over the profile's */
|
/* F_Protection: a chosen resolver (filtering or not) wins over the profile's */
|
||||||
java.util.List<String> chosenDns = org.strongswan.android.logic.protection.Protection.dnsServers(CharonVpnService.this);
|
java.util.List<String> chosenDns = org.strongswan.android.logic.protection.Protection.dnsServers(CharonVpnService.this);
|
||||||
String dnsServers = chosenDns != null ? android.text.TextUtils.join(" ", chosenDns) : profile.getDnsServers();
|
String dnsServers = chosenDns != null ? android.text.TextUtils.join(" ", chosenDns) : profile.getDnsServers();
|
||||||
@@ -2590,7 +2689,26 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
{
|
{
|
||||||
builder.addAddress(address.getFrom(), address.getPrefix());
|
builder.addAddress(address.getFrom(), address.getPrefix());
|
||||||
}
|
}
|
||||||
if (includeDns)
|
/* encrypted DNS needs an IPv4 address in the tunnel to reach the
|
||||||
|
* virtual DNS address; otherwise the plain servers stay */
|
||||||
|
boolean secureDns = mSecureDns != null && mIPv4Seen;
|
||||||
|
if (mSecureDns != null && !mIPv4Seen)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "no IPv4 address in the tunnel, own DoH/DoT not used");
|
||||||
|
}
|
||||||
|
int securePort = secureDns ? org.strongswan.android.logic.protection.SecureDns.start(mSecureDns) : 0;
|
||||||
|
secureDns = securePort > 0;
|
||||||
|
if (!secureDns)
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.protection.SecureDns.stop("not used for this tunnel");
|
||||||
|
}
|
||||||
|
setDnsRedirect(secureDns ? org.strongswan.android.logic.protection.SecureDns.VIP_BYTES : null, securePort);
|
||||||
|
if (includeDns && secureDns)
|
||||||
|
{ /* the only server: any plain one would let the resolver bypass DoH/DoT */
|
||||||
|
builder.addDnsServer(org.strongswan.android.logic.protection.SecureDns.VIP);
|
||||||
|
builder.addRoute(org.strongswan.android.logic.protection.SecureDns.VIP, 32);
|
||||||
|
}
|
||||||
|
else if (includeDns)
|
||||||
{
|
{
|
||||||
if (mDnsServers.isEmpty())
|
if (mDnsServers.isEmpty())
|
||||||
{ /* neither the profile nor the server provided DNS servers:
|
{ /* neither the profile nor the server provided DNS servers:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* 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.app.ActivityManager
|
||||||
|
import android.app.ApplicationExitInfo
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.events.EventAction
|
||||||
|
import org.strongswan.android.logic.events.EventCategory
|
||||||
|
import org.strongswan.android.logic.events.EventKind
|
||||||
|
import org.strongswan.android.logic.events.EventLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notices that the previous process died by a crash and leaves an event with
|
||||||
|
* «Send a report». Android 11+ keeps the exit reasons itself, which also
|
||||||
|
* covers native crashes of the in-process daemon and ANRs; older versions only
|
||||||
|
* see Java exceptions, marked by the uncaught exception handler.
|
||||||
|
*/
|
||||||
|
object CrashWatch
|
||||||
|
{
|
||||||
|
private const val TAG = "CrashWatch"
|
||||||
|
private const val PREF_SEEN = "pref_crash_seen_at"
|
||||||
|
private const val PREF_JAVA_CRASH = "pref_java_crash_at"
|
||||||
|
const val EVENT_KEY = "crash"
|
||||||
|
|
||||||
|
/** From the uncaught exception handler: synchronous, the process dies right after. */
|
||||||
|
@JvmStatic
|
||||||
|
fun markJavaCrash(context: Context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putLong(PREF_JAVA_CRASH, System.currentTimeMillis()).commit()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At process start, off the main thread. */
|
||||||
|
@JvmStatic
|
||||||
|
fun check(context: Context)
|
||||||
|
{
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
var at = prefs.getLong(PREF_JAVA_CRASH, 0L)
|
||||||
|
var what = "java exception"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val am = context.getSystemService(ActivityManager::class.java)
|
||||||
|
am?.getHistoricalProcessExitReasons(context.packageName, 0, 10)
|
||||||
|
?.filter { it.reason in setOf(ApplicationExitInfo.REASON_CRASH, ApplicationExitInfo.REASON_CRASH_NATIVE, ApplicationExitInfo.REASON_ANR) }
|
||||||
|
?.maxByOrNull { it.timestamp }
|
||||||
|
?.takeIf { it.timestamp > at }
|
||||||
|
?.let {
|
||||||
|
at = it.timestamp
|
||||||
|
what = "${reasonName(it.reason)}: ${it.description ?: ""}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "exit reasons unavailable: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val seen = prefs.getLong(PREF_SEEN, -1L)
|
||||||
|
if (seen < 0L)
|
||||||
|
{ /* first start with this feature: older history is not news */
|
||||||
|
prefs.edit().putLong(PREF_SEEN, maxOf(at, System.currentTimeMillis())).apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (at <= seen)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.edit().putLong(PREF_SEEN, at).apply()
|
||||||
|
DiagLog.i(TAG, "previous process crashed at $at ($what)")
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.WARN, EventCategory.APP, context.getString(R.string.event_crash_title),
|
||||||
|
context.getString(R.string.event_crash_text), EventAction.SEND_REPORT, EVENT_KEY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reasonName(reason: Int): String = when (reason)
|
||||||
|
{
|
||||||
|
ApplicationExitInfo.REASON_CRASH_NATIVE -> "native crash"
|
||||||
|
ApplicationExitInfo.REASON_ANR -> "ANR"
|
||||||
|
else -> "java crash"
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-4
@@ -25,6 +25,7 @@ import android.net.Network;
|
|||||||
import android.net.NetworkRequest;
|
import android.net.NetworkRequest;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
|
|
||||||
public class NetworkManager extends BroadcastReceiver implements Runnable
|
public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||||
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
private volatile boolean mRegistered;
|
private volatile boolean mRegistered;
|
||||||
private ConnectivityManager.NetworkCallback mCallback;
|
private ConnectivityManager.NetworkCallback mCallback;
|
||||||
private Thread mEventNotifier;
|
private Thread mEventNotifier;
|
||||||
private int mConnectedNetworks = 0;
|
/* a set, not a counter: a repeated onAvailable or a missed onLost cannot
|
||||||
|
* make it drift away from the networks that really exist */
|
||||||
|
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
|
||||||
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
||||||
|
|
||||||
public NetworkManager(Context context)
|
public NetworkManager(Context context)
|
||||||
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
{
|
{
|
||||||
/* we expect this to be called if connected to at least one network during
|
/* we expect this to be called if connected to at least one network during
|
||||||
* callback registration */
|
* callback registration */
|
||||||
mConnectedNetworks += 1;
|
mConnectedNetworks.add(network);
|
||||||
mEvents.addLast(true);
|
mEvents.addLast(true);
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
synchronized (NetworkManager.this)
|
synchronized (NetworkManager.this)
|
||||||
{
|
{
|
||||||
/* in particular mobile connections are disconnected overlapping with WiFi */
|
/* in particular mobile connections are disconnected overlapping with WiFi */
|
||||||
mConnectedNetworks -= 1;
|
mConnectedNetworks.remove(network);
|
||||||
mEvents.addLast(mConnectedNetworks > 0);
|
mEvents.addLast(!mConnectedNetworks.isEmpty());
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,8 +76,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Register()
|
public void Register()
|
||||||
|
{
|
||||||
|
synchronized (this)
|
||||||
{
|
{
|
||||||
mEvents.clear();
|
mEvents.clear();
|
||||||
|
mConnectedNetworks.clear();
|
||||||
|
}
|
||||||
mRegistered = true;
|
mRegistered = true;
|
||||||
mEventNotifier = new Thread(this);
|
mEventNotifier = new Thread(this);
|
||||||
mEventNotifier.start();
|
mEventNotifier.start();
|
||||||
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
connected = mEvents.removeFirst();
|
connected = mEvents.removeFirst();
|
||||||
|
/* a burst of identical states (several networks coming up at once)
|
||||||
|
* is one change for charon, not one reconnect attempt each */
|
||||||
|
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
|
||||||
|
{
|
||||||
|
mEvents.removeFirst();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/* call the native parts without holding the lock */
|
/* call the native parts without holding the lock */
|
||||||
networkChanged(!connected);
|
networkChanged(!connected);
|
||||||
|
|||||||
@@ -229,6 +229,32 @@ object PauseManager
|
|||||||
alarmManager(ctx).cancel(alarmIntent(ctx))
|
alarmManager(ctx).cancel(alarmIntent(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a reboot: alarms do not survive it, so a pause that was running would
|
||||||
|
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun rearmAfterRestart(context: Context)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
val until = prefs.getLong(PREF_UNTIL, 0L)
|
||||||
|
val profile = prefs.getString(PREF_PROFILE, null)
|
||||||
|
if (profile == null || until == 0L)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (until > System.currentTimeMillis())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
|
||||||
|
store(ctx, until, profile)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onAlarm(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun store(ctx: Context, until: Long, profileUuid: String)
|
private fun store(ctx: Context, until: Long, profileUuid: String)
|
||||||
{
|
{
|
||||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/** Why an established IKE SA went down; for the diagnostic log only (plan 8.1). */
|
||||||
|
enum class SaClose(val label: String, val drop: Boolean)
|
||||||
|
{
|
||||||
|
USER("user", false),
|
||||||
|
REAUTH("reauth", false),
|
||||||
|
RETRANSMIT_AFTER_NETWORK_CHANGE("retransmit-timeout after network change", true),
|
||||||
|
RETRANSMIT_TIMEOUT("retransmit-timeout", true),
|
||||||
|
NETWORK_CHANGE("network change", true),
|
||||||
|
OTHER("other (server close, rekey failure, MOBIKE probing)", true);
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** a network change this close before the SA went down is blamed first */
|
||||||
|
const val NETWORK_WINDOW_MS = 20_000L
|
||||||
|
|
||||||
|
/** sinceNetworkChangeMs is -1 when there was none */
|
||||||
|
@JvmStatic
|
||||||
|
fun classify(disconnecting: Boolean, reauth: Boolean, retransmitTimeout: Boolean,
|
||||||
|
sinceNetworkChangeMs: Long): SaClose
|
||||||
|
{
|
||||||
|
val network = sinceNetworkChangeMs in 0..NETWORK_WINDOW_MS
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
disconnecting -> USER
|
||||||
|
reauth -> REAUTH
|
||||||
|
retransmitTimeout && network -> RETRANSMIT_AFTER_NETWORK_CHANGE
|
||||||
|
retransmitTimeout -> RETRANSMIT_TIMEOUT
|
||||||
|
network -> NETWORK_CHANGE
|
||||||
|
else -> OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts events per local calendar day; resets when the process restarts. */
|
||||||
|
class DailyCounter
|
||||||
|
{
|
||||||
|
private var day = Long.MIN_VALUE
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun next(nowMs: Long, utcOffsetMs: Long): Int
|
||||||
|
{
|
||||||
|
val today = Math.floorDiv(nowMs + utcOffsetMs, 86_400_000L)
|
||||||
|
if (today != day)
|
||||||
|
{
|
||||||
|
day = today
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
return ++count
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import java.util.PriorityQueue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
|
||||||
|
* bookkeeping of the single alarm that wakes the device for the earliest one.
|
||||||
|
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
|
||||||
|
*
|
||||||
|
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
|
||||||
|
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
|
||||||
|
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
|
||||||
|
* allow-while-idle alarms that the keepalive needs.
|
||||||
|
*
|
||||||
|
* Not thread-safe; the caller synchronises.
|
||||||
|
*/
|
||||||
|
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
|
||||||
|
{
|
||||||
|
private class Job(val id: String, val time: Long) : Comparable<Job>
|
||||||
|
{
|
||||||
|
override fun compareTo(other: Job): Int = time.compareTo(other.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val jobs = PriorityQueue<Job>()
|
||||||
|
private var armed = NONE
|
||||||
|
|
||||||
|
/** Due time the alarm is currently set for, or [NONE]. */
|
||||||
|
val armedAt: Long get() = armed
|
||||||
|
|
||||||
|
val size: Int get() = jobs.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a job.
|
||||||
|
*
|
||||||
|
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
|
||||||
|
* armed alarm already covers the job
|
||||||
|
*/
|
||||||
|
fun add(id: String, time: Long): Boolean
|
||||||
|
{
|
||||||
|
jobs.add(Job(id, time))
|
||||||
|
if (armed == NONE || time < armed - coalesceMs)
|
||||||
|
{
|
||||||
|
armed = time
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the jobs that are due at [now] (or within the coalescing window).
|
||||||
|
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
|
||||||
|
*/
|
||||||
|
fun popDue(now: Long): List<String>
|
||||||
|
{
|
||||||
|
val limit = now + coalesceMs
|
||||||
|
val due = ArrayList<String>()
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val head = jobs.peek() ?: break
|
||||||
|
if (head.time > limit)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
due.add(jobs.remove().id)
|
||||||
|
}
|
||||||
|
armed = jobs.peek()?.time ?: NONE
|
||||||
|
return due
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
|
||||||
|
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
|
||||||
|
|
||||||
|
fun clear()
|
||||||
|
{
|
||||||
|
jobs.clear()
|
||||||
|
armed = NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
const val NONE = -1L
|
||||||
|
const val COALESCE_MS = 2000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-51
@@ -24,10 +24,10 @@ import android.content.Context;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.IntentFilter;
|
import android.content.IntentFilter;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.List;
|
||||||
import java.util.PriorityQueue;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
private final String TAG = Scheduler.class.getSimpleName();
|
private final String TAG = Scheduler.class.getSimpleName();
|
||||||
private final Context mContext;
|
private final Context mContext;
|
||||||
private final AlarmManager mManager;
|
private final AlarmManager mManager;
|
||||||
private final PriorityQueue<ScheduledJob> mJobs;
|
/* due times are elapsedRealtime: unlike RTC they do not jump when the clock
|
||||||
|
* is set (NTP, manual change, time zone), which would delay keepalives and
|
||||||
|
* rekeyings by hours */
|
||||||
|
private final ScheduledJobQueue mJobs;
|
||||||
|
private boolean mExactLogged;
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
public Scheduler(Context context)
|
public Scheduler(Context context)
|
||||||
{
|
{
|
||||||
mContext = context;
|
mContext = context;
|
||||||
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||||
mJobs = new PriorityQueue<>();
|
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
|
||||||
|
|
||||||
IntentFilter filter = new IntentFilter();
|
IntentFilter filter = new IntentFilter();
|
||||||
filter.addAction(EXECUTE_JOB);
|
filter.addAction(EXECUTE_JOB);
|
||||||
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* alarms instead of crashing with a SecurityException (connectivity then
|
* alarms instead of crashing with a SecurityException (connectivity then
|
||||||
* only degrades slightly while timers may be delayed in doze mode).
|
* only degrades slightly while timers may be delayed in doze mode).
|
||||||
*
|
*
|
||||||
* @param time wakeup time based on RTC
|
* @param time wakeup time based on elapsedRealtime
|
||||||
*/
|
*/
|
||||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||||
private void scheduleAlarm(long time)
|
private void scheduleAlarm(long time)
|
||||||
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
exact = mManager.canScheduleExactAlarms();
|
exact = mManager.canScheduleExactAlarms();
|
||||||
}
|
}
|
||||||
|
if (!mExactLogged)
|
||||||
|
{ /* once per run, for support reports */
|
||||||
|
mExactLogged = true;
|
||||||
|
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (exact)
|
if (exact)
|
||||||
{
|
{
|
||||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (SecurityException e)
|
catch (SecurityException e)
|
||||||
{ /* permission might have been revoked, or OEM restrictions apply */
|
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||||
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
|
long time = SystemClock.elapsedRealtime() + ms;
|
||||||
mJobs.add(job);
|
if (mJobs.add(id, time))
|
||||||
|
{ /* the job is due before the armed alarm: move the alarm */
|
||||||
if (job == mJobs.peek())
|
scheduleAlarm(time);
|
||||||
{ /* update the alarm if the job has to be executed before all others */
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
@Override
|
@Override
|
||||||
public void onReceive(Context context, Intent intent)
|
public void onReceive(Context context, Intent intent)
|
||||||
{
|
{
|
||||||
ArrayList<ScheduledJob> jobs = new ArrayList<>();
|
List<String> jobs;
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = mJobs.peek();
|
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
|
||||||
while (job != null)
|
long next = mJobs.nextAlarm();
|
||||||
|
if (next != ScheduledJobQueue.NONE)
|
||||||
{
|
{
|
||||||
if (job.Time > now)
|
scheduleAlarm(next);
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
jobs.add(mJobs.remove());
|
|
||||||
job = mJobs.peek();
|
|
||||||
}
|
|
||||||
if (job != null)
|
|
||||||
{
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ScheduledJob job : jobs)
|
for (String id : jobs)
|
||||||
{
|
{
|
||||||
executeJob(job.Id);
|
executeJob(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* @param id job ID
|
* @param id job ID
|
||||||
*/
|
*/
|
||||||
public native void executeJob(String id);
|
public native void executeJob(String id);
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep track of scheduled jobs.
|
|
||||||
*/
|
|
||||||
private static class ScheduledJob implements Comparable<ScheduledJob>
|
|
||||||
{
|
|
||||||
String Id;
|
|
||||||
long Time;
|
|
||||||
|
|
||||||
ScheduledJob(String id, long time)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Time = time;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int compareTo(ScheduledJob o)
|
|
||||||
{
|
|
||||||
return Long.compare(Time, o.Time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+4
@@ -99,6 +99,7 @@ public class StrongSwanApplication extends Application
|
|||||||
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
||||||
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
|
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
|
||||||
Log.getStackTraceString(throwable));
|
Log.getStackTraceString(throwable));
|
||||||
|
CrashWatch.markJavaCrash(StrongSwanApplication.this);
|
||||||
if (previousHandler != null)
|
if (previousHandler != null)
|
||||||
{
|
{
|
||||||
previousHandler.uncaughtException(thread, throwable);
|
previousHandler.uncaughtException(thread, throwable);
|
||||||
@@ -107,6 +108,9 @@ public class StrongSwanApplication extends Application
|
|||||||
|
|
||||||
mDatabaseHelper = new DatabaseHelper(mContext);
|
mDatabaseHelper = new DatabaseHelper(mContext);
|
||||||
|
|
||||||
|
/* «the app crashed» event with a report button for the previous process */
|
||||||
|
mExecutorService.execute(() -> CrashWatch.check(mContext));
|
||||||
|
|
||||||
mManagedConfigurationService = new ManagedConfigurationService(mContext);
|
mManagedConfigurationService = new ManagedConfigurationService(mContext);
|
||||||
|
|
||||||
mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler,
|
mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler,
|
||||||
|
|||||||
+35
@@ -90,6 +90,9 @@ public class VpnStateService extends Service
|
|||||||
private long mRetryIn;
|
private long mRetryIn;
|
||||||
/* true while the countdown is parked because the ISP whitelist blocks the server */
|
/* true while the countdown is parked because the ISP whitelist blocks the server */
|
||||||
private boolean mRetryFrozen;
|
private boolean mRetryFrozen;
|
||||||
|
/* when the pending retry is due in elapsedRealtime: the countdown itself
|
||||||
|
* runs in uptime and stands still while the device sleeps */
|
||||||
|
private long mRetryDue;
|
||||||
/* timestamp of the last network-change-triggered immediate retry */
|
/* timestamp of the last network-change-triggered immediate retry */
|
||||||
private long mLastNetworkRetry;
|
private long mLastNetworkRetry;
|
||||||
/* double-start guard: profile and issue time of the last connect() that
|
/* double-start guard: profile and issue time of the last connect() that
|
||||||
@@ -610,6 +613,37 @@ public class VpnStateService extends Service
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a retry countdown is pending (an error with an automatic retry). */
|
||||||
|
public boolean isRetryPending()
|
||||||
|
{
|
||||||
|
return mRetryTimeout > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the pending retry now if its time has passed on the wall clock.
|
||||||
|
* The countdown ticks in uptime, so it stands still while the device
|
||||||
|
* sleeps; the watchdog's wakeup alarm calls this so a tunnel that dropped
|
||||||
|
* overnight comes back without the user picking up the phone. Never
|
||||||
|
* shortens a backoff. Main thread only.
|
||||||
|
*
|
||||||
|
* @return true if a reconnect was started
|
||||||
|
*/
|
||||||
|
public boolean retryIfOverdue()
|
||||||
|
{
|
||||||
|
if (mRetryTimeout <= 0 || mProfile == null || SystemClock.elapsedRealtime() < mRetryDue ||
|
||||||
|
WhiteListManager.getInstance(this).shouldBlockReconnect())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "retry overdue (device slept through the countdown), reconnecting");
|
||||||
|
mRetryTimeout = 0;
|
||||||
|
mRetryIn = 0;
|
||||||
|
mRetryFrozen = false;
|
||||||
|
mHandler.removeMessages(RETRY_MSG);
|
||||||
|
connect(null, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update state and notify all listeners about the change. By using a Handler
|
* Update state and notify all listeners about the change. By using a Handler
|
||||||
* this is done from the main UI thread and not the initial reporter thread.
|
* this is done from the main UI thread and not the initial reporter thread.
|
||||||
@@ -826,6 +860,7 @@ public class VpnStateService extends Service
|
|||||||
{
|
{
|
||||||
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
||||||
mRetryFrozen = false;
|
mRetryFrozen = false;
|
||||||
|
mRetryDue = SystemClock.elapsedRealtime() + mRetryTimeout;
|
||||||
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
|
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
|
||||||
/* never run two countdown chains in parallel (double-speed countdown,
|
/* never run two countdown chains in parallel (double-speed countdown,
|
||||||
* double notification updates and two reconnects at zero) */
|
* double notification updates and two reconnects at zero) */
|
||||||
|
|||||||
+21
-9
@@ -154,15 +154,16 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a check counts towards the steady-state backoff of a live
|
* Whether a check counts towards the steady-state backoff of a live
|
||||||
* process: connected, retrying/connecting, or held back by the ISP
|
* process: connected, connecting, or held back by the ISP whitelist. In
|
||||||
* whitelist. In all of these the in-process machinery owns recovery and
|
* all of these the in-process machinery owns recovery and the alarm can
|
||||||
* the alarm can only guard against the process dying - firing every
|
* only guard against the process dying - firing every minute (and waking
|
||||||
* minute (and waking Doze every 10 minutes) for hours of failed retries
|
* Doze every 10 minutes) for hours of whitelist blocking only burns
|
||||||
* or whitelist blocking only burns battery.
|
* battery.
|
||||||
*/
|
*/
|
||||||
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked)
|
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked, boolean retryPending)
|
||||||
{
|
{ /* a pending retry needs the fast wakeup alarm: its countdown stands
|
||||||
return action == Action.NONE && processAlive && !parked;
|
* still while the device sleeps and only this alarm resumes it */
|
||||||
|
return action == Action.NONE && processAlive && !parked && !retryPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -190,7 +191,13 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
|
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
|
||||||
processAlive, wifiPresent, autoProfileConfigured);
|
processAlive, wifiPresent, autoProfileConfigured);
|
||||||
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
|
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
|
||||||
boolean alive = isAliveSteady(action, processAlive, parked);
|
/* the whitelist parks retries on purpose, that state may back off */
|
||||||
|
boolean retryPending = serviceTracking && !whitelistBlocks && service.isRetryPending();
|
||||||
|
if (retryPending && service.retryIfOverdue())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "resumed a retry the device slept through");
|
||||||
|
}
|
||||||
|
boolean alive = isAliveSteady(action, processAlive, parked, retryPending);
|
||||||
if (parked)
|
if (parked)
|
||||||
{
|
{
|
||||||
sParkedChecks++;
|
sParkedChecks++;
|
||||||
@@ -232,6 +239,7 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
return;
|
return;
|
||||||
case RESTART:
|
case RESTART:
|
||||||
{
|
{
|
||||||
|
boolean autoRestart = uuid == null;
|
||||||
if (uuid == null)
|
if (uuid == null)
|
||||||
{
|
{
|
||||||
uuid = resolveAutoProfile(context, prefs);
|
uuid = resolveAutoProfile(context, prefs);
|
||||||
@@ -260,6 +268,10 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
* once, which is harmless (retry flag set, same profile). */
|
* once, which is harmless (retry flag set, same profile). */
|
||||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||||
DiagLog.i(TAG, "restarting the VPN process");
|
DiagLog.i(TAG, "restarting the VPN process");
|
||||||
|
if (autoRestart)
|
||||||
|
{ /* this process does not remember the session was auto-started */
|
||||||
|
AutoConnectManager.getInstance(context).markAutoSessionRestored();
|
||||||
|
}
|
||||||
Intent start = new Intent(context, CharonVpnService.class);
|
Intent start = new Intent(context, CharonVpnService.class);
|
||||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||||
|
|||||||
+42
-6
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
|
|||||||
private var currentNetwork: NetworkKind? = null
|
private var currentNetwork: NetworkKind? = null
|
||||||
/* marks the session this manager started itself */
|
/* marks the session this manager started itself */
|
||||||
private val autoSession = AtomicBoolean(false)
|
private val autoSession = AtomicBoolean(false)
|
||||||
|
/* an auto session was restored by the watchdog and its tunnel is not up yet */
|
||||||
|
@Volatile private var awaitingRestoredTunnel = false
|
||||||
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||||
private var lastParkAt = 0L
|
private var lastParkAt = 0L
|
||||||
|
|
||||||
@@ -123,10 +125,28 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Network scenarios need the network events even when the network rules are off. */
|
||||||
|
private fun scenariosNeedNetwork(): Boolean =
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
|
||||||
|
|
||||||
|
/** Called when the scenarios changed: starts or stops the network listening to fit. */
|
||||||
|
fun scenariosChanged()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (config.enabled || scenariosNeedNetwork())
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun start()
|
fun start()
|
||||||
{
|
{
|
||||||
config = loadConfig()
|
config = loadConfig()
|
||||||
if (!config.enabled)
|
if (!config.enabled && !scenariosNeedNetwork())
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
|
|||||||
override fun stateChanged()
|
override fun stateChanged()
|
||||||
{
|
{
|
||||||
val state = service?.state ?: return
|
val state = service?.state ?: return
|
||||||
if (state == VpnStateService.State.DISABLED)
|
if (state != VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = false
|
||||||
|
}
|
||||||
|
else if (!awaitingRestoredTunnel)
|
||||||
{
|
{
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watchdog brought an auto session back in a fresh process, whose memory
|
||||||
|
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
|
||||||
|
* Wi-Fi instead of running all night; the DISABLED state of the not yet
|
||||||
|
* started service must not clear the mark.
|
||||||
|
*/
|
||||||
|
fun markAutoSessionRestored()
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = true
|
||||||
|
autoSession.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure decision function, unit-testable.
|
* Pure decision function, unit-testable.
|
||||||
*/
|
*/
|
||||||
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
|
|||||||
|
|
||||||
private fun evaluate()
|
private fun evaluate()
|
||||||
{
|
{
|
||||||
if (!running.get())
|
if (!running.get() || !config.enabled)
|
||||||
{
|
{ /* running only for the scenarios: they decide, not the network rules */
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val svc = service ?: return
|
val svc = service ?: return
|
||||||
|
|||||||
+4
@@ -70,6 +70,10 @@ enum class EventAction
|
|||||||
PROFILE_UPDATE,
|
PROFILE_UPDATE,
|
||||||
/** open the server page of the profile [ShxEvent.target] */
|
/** open the server page of the profile [ShxEvent.target] */
|
||||||
OPEN_SERVER,
|
OPEN_SERVER,
|
||||||
|
/** share the logs with support (F_Log «Send») */
|
||||||
|
SEND_REPORT,
|
||||||
|
/** the Android VPN settings (always-on, block without VPN) */
|
||||||
|
OPEN_VPN_SETTINGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ShxEvent(
|
data class ShxEvent(
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ object Events
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A report went to the panel: the code stays here so the user can find it later. */
|
||||||
|
@JvmStatic
|
||||||
|
fun reportSent(context: Context, code: String)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code),
|
||||||
|
context.getString(R.string.report_event_text), key = "report:$code",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The watchdog did not bring back [name]: a password would be needed. */
|
/** The watchdog did not bring back [name]: a password would be needed. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
||||||
@@ -173,4 +183,20 @@ object Events
|
|||||||
context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid,
|
context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Own DoH/DoT: warn when the probe failed (plain DNS is used), clear it when it works. */
|
||||||
|
@JvmStatic
|
||||||
|
fun secureDnsResult(context: Context, ok: Boolean)
|
||||||
|
{
|
||||||
|
val events = EventLog.getInstance(context)
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
events.resolve("secure-dns")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events.record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_secure_dns_title),
|
||||||
|
context.getString(R.string.event_secure_dns_text), EventAction.NONE, "secure-dns",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+8
-1
@@ -39,7 +39,10 @@ data class ProtectionState(
|
|||||||
val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD
|
val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD
|
||||||
val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter)
|
val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter)
|
||||||
|
|
||||||
/** null: keep the profile's (or the server's) DNS */
|
/** CUSTOM with a DoH/DoT address instead of IPs */
|
||||||
|
val secure: SecureDnsConfig? get() = if (choice == DnsChoice.CUSTOM) SecureDnsConfig.parse(custom) else null
|
||||||
|
|
||||||
|
/** null: keep the profile's (or the server's) DNS; a DoH/DoT choice is [secure] instead */
|
||||||
fun servers(): List<String>? = when (choice)
|
fun servers(): List<String>? = when (choice)
|
||||||
{
|
{
|
||||||
DnsChoice.PROFILE -> null
|
DnsChoice.PROFILE -> null
|
||||||
@@ -110,6 +113,10 @@ object Protection
|
|||||||
_state.value = state
|
_state.value = state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** For CharonVpnService: the user's DoH/DoT resolver, or null. */
|
||||||
|
@JvmStatic
|
||||||
|
fun secureDns(context: Context): SecureDnsConfig? = get(context).secure
|
||||||
|
|
||||||
/** For CharonVpnService: the servers replacing the profile's ones, or null. */
|
/** For CharonVpnService: the servers replacing the profile's ones, or null. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun dnsServers(context: Context): List<String>? = get(context).servers()
|
fun dnsServers(context: Context): List<String>? = get(context).servers()
|
||||||
|
|||||||
+528
@@ -0,0 +1,528 @@
|
|||||||
|
/*
|
||||||
|
* 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.protection
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.SSLSocket
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
|
||||||
|
/** The user's own encrypted resolver: DoH (`https://host/path`) or DoT (`tls://host[:port]`). */
|
||||||
|
data class SecureDnsConfig(val doh: Boolean, val host: String, val port: Int, val url: String)
|
||||||
|
{
|
||||||
|
/** For the diag log: never the queried names, only where queries go. */
|
||||||
|
val label: String get() = if (doh) "DoH $url" else "DoT $host:$port"
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** null when the text is not a DoH/DoT address (plain IPs are handled by [ProtectionState.parseCustom]). */
|
||||||
|
@JvmStatic
|
||||||
|
fun parse(text: String): SecureDnsConfig?
|
||||||
|
{
|
||||||
|
val t = text.trim()
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
when
|
||||||
|
{
|
||||||
|
t.startsWith("https://", true) ->
|
||||||
|
{
|
||||||
|
val uri = URI(t)
|
||||||
|
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
if (uri.userInfo != null || uri.query != null || uri.fragment != null) return null
|
||||||
|
val path = uri.rawPath?.takeIf { it.isNotEmpty() && it != "/" } ?: "/dns-query"
|
||||||
|
val port = if (uri.port > 0) uri.port else 443
|
||||||
|
val portPart = if (uri.port > 0) ":${uri.port}" else ""
|
||||||
|
SecureDnsConfig(true, host, port, "https://$host$portPart$path")
|
||||||
|
}
|
||||||
|
t.startsWith("tls://", true) ->
|
||||||
|
{
|
||||||
|
val uri = URI(t)
|
||||||
|
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
if (uri.userInfo != null || (uri.rawPath ?: "").let { it.isNotEmpty() && it != "/" }) return null
|
||||||
|
val port = if (uri.port > 0) uri.port else 853
|
||||||
|
SecureDnsConfig(false, host, port, "tls://$host:$port")
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DNS wire bits the resolver needs; pure functions, unit tested. */
|
||||||
|
object SecureDnsCodec
|
||||||
|
{
|
||||||
|
/** A minimal query (RD set) for [name] and [type], e.g. 1 = A. */
|
||||||
|
@JvmStatic
|
||||||
|
fun query(id: Int, name: String, type: Int): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
out.write(byteArrayOf((id shr 8).toByte(), id.toByte(), 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0))
|
||||||
|
name.trimEnd('.').split('.').filter { it.isNotEmpty() }.forEach {
|
||||||
|
val label = it.toByteArray(Charsets.US_ASCII)
|
||||||
|
out.write(label.size)
|
||||||
|
out.write(label)
|
||||||
|
}
|
||||||
|
out.write(byteArrayOf(0, (type shr 8).toByte(), type.toByte(), 0, 1))
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SERVFAIL for [query]: header with QR and RA set, RCODE 2, the question
|
||||||
|
* kept and no records. null if the query is not even a header.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun servfail(query: ByteArray): ByteArray?
|
||||||
|
{
|
||||||
|
if (query.size < 12) return null
|
||||||
|
val end = questionEnd(query) ?: 12
|
||||||
|
val reply = query.copyOf(end)
|
||||||
|
reply[2] = ((query[2].toInt() and 0x79) or 0x80).toByte() /* QR, keep opcode and RD */
|
||||||
|
reply[3] = 0x82.toByte() /* RA, RCODE 2 */
|
||||||
|
val qd = if (end > 12) 1 else 0
|
||||||
|
reply[4] = 0; reply[5] = qd.toByte()
|
||||||
|
for (i in 6 until 12) reply[i] = 0
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End of the first question, or null if it is malformed / absent. */
|
||||||
|
private fun questionEnd(msg: ByteArray): Int?
|
||||||
|
{
|
||||||
|
if ((msg[4].toInt() or msg[5].toInt()) == 0) return null
|
||||||
|
var i = 12
|
||||||
|
while (i < msg.size)
|
||||||
|
{
|
||||||
|
val len = msg[i].toInt() and 0xff
|
||||||
|
if (len == 0) return (i + 5).takeIf { it <= msg.size }
|
||||||
|
if (len and 0xc0 != 0) return null
|
||||||
|
i += len + 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 7858: two-byte length prefix. */
|
||||||
|
@JvmStatic
|
||||||
|
fun frame(msg: ByteArray): ByteArray =
|
||||||
|
byteArrayOf((msg.size shr 8).toByte(), msg.size.toByte()) + msg
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun readFrame(input: InputStream): ByteArray
|
||||||
|
{
|
||||||
|
val data = DataInputStream(input)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val len = data.readUnsignedShort()
|
||||||
|
val msg = ByteArray(len)
|
||||||
|
data.readFully(msg)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
catch (e: java.io.EOFException)
|
||||||
|
{ /* some servers drop names they refuse to answer by closing the connection */
|
||||||
|
throw IOException("connection closed without a reply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same transaction id: a reply that belongs to the query. */
|
||||||
|
@JvmStatic
|
||||||
|
fun sameId(a: ByteArray, b: ByteArray): Boolean = a.size >= 2 && b.size >= 2 && a[0] == b[0] && a[1] == b[1]
|
||||||
|
|
||||||
|
/** Throws unless [reply] is a DNS response (header, QR bit) to [query]. */
|
||||||
|
@JvmStatic
|
||||||
|
fun checkReply(query: ByteArray, reply: ByteArray)
|
||||||
|
{
|
||||||
|
if (reply.isEmpty()) throw IOException("empty reply")
|
||||||
|
if (reply.size < 12 || (reply[2].toInt() and 0x80) == 0) throw IOException("not a DNS reply")
|
||||||
|
if (!sameId(query, reply)) throw IOException("reply id mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local resolver behind the tunnel's DNS address [VIP]. The native DNS proxy
|
||||||
|
* hands UDP queries for [VIP] to 127.0.0.1:<port>; each one goes to the user's
|
||||||
|
* DoH/DoT server. Our own app is excluded from the VPN, so the upstream
|
||||||
|
* connection uses the physical network (encrypted) and its hostname resolves
|
||||||
|
* there, without looping through this resolver. A failed query is answered
|
||||||
|
* with SERVFAIL, never by plain DNS.
|
||||||
|
*/
|
||||||
|
object SecureDns
|
||||||
|
{
|
||||||
|
private const val TAG = "SecureDns"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tunnel's DNS address while encrypted DNS is on. 198.18.0.0/15 is
|
||||||
|
* the benchmarking range (RFC 2544): never routed on the internet and not
|
||||||
|
* used for VPN pools, so it cannot shadow a real server-pushed subnet.
|
||||||
|
*/
|
||||||
|
const val VIP = "198.18.0.53"
|
||||||
|
@JvmField
|
||||||
|
val VIP_BYTES = byteArrayOf(198.toByte(), 18, 0, 53)
|
||||||
|
|
||||||
|
private const val TIMEOUT_MS = 3000
|
||||||
|
private const val PROBE_TTL_MS = 10 * 60 * 1000L
|
||||||
|
private const val PROBE_FAIL_TTL_MS = 60 * 1000L
|
||||||
|
private const val SUMMARY_EVERY_MS = 15 * 60 * 1000L
|
||||||
|
private const val WORKERS = 4
|
||||||
|
|
||||||
|
private var server: DatagramSocket? = null
|
||||||
|
private var serverConfig: SecureDnsConfig? = null
|
||||||
|
private var pool: ThreadPoolExecutor? = null
|
||||||
|
private val dot = ThreadLocal<DotConnection?>()
|
||||||
|
|
||||||
|
private var probedConfig: SecureDnsConfig? = null
|
||||||
|
private var probedAt = 0L
|
||||||
|
private var probeOk = false
|
||||||
|
|
||||||
|
private val queries = AtomicLong()
|
||||||
|
private val failures = AtomicLong()
|
||||||
|
private val totalMs = AtomicLong()
|
||||||
|
@Volatile private var lastSummary = 0L
|
||||||
|
@Volatile private var failing = false
|
||||||
|
private val loggedErrors = java.util.Collections.synchronizedSet(HashSet<String>())
|
||||||
|
|
||||||
|
/** Result of a probe: round trip in ms, or the failure. */
|
||||||
|
class Probe(val ms: Long, val error: String?)
|
||||||
|
{
|
||||||
|
val ok: Boolean get() = error == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names for the probe; any well-formed reply (any rcode) means the server
|
||||||
|
* speaks DoH/DoT. Not example.com: some servers silently drop it.
|
||||||
|
*/
|
||||||
|
private val PROBE_NAMES = arrayOf("one.one.one.one", "ya.ru")
|
||||||
|
|
||||||
|
/** One query (a second name if the first gets no reply); for the connect decision and the «Save» check. */
|
||||||
|
@JvmStatic
|
||||||
|
fun probe(config: SecureDnsConfig): Probe
|
||||||
|
{
|
||||||
|
val start = SystemClock.elapsedRealtime()
|
||||||
|
var error: String? = null
|
||||||
|
for (name in PROBE_NAMES)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val q = SecureDnsCodec.query((SystemClock.elapsedRealtime() and 0xffff).toInt(), name, 1)
|
||||||
|
SecureDnsCodec.checkReply(q, resolve(config, q))
|
||||||
|
error = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
error = describe(e)
|
||||||
|
if (e is java.net.SocketTimeoutException) break /* a dead server: no point waiting twice */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = Probe(SystemClock.elapsedRealtime() - start, error)
|
||||||
|
remember(config, result.ok)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun remember(config: SecureDnsConfig, ok: Boolean)
|
||||||
|
{
|
||||||
|
probedConfig = config
|
||||||
|
probedAt = SystemClock.elapsedRealtime()
|
||||||
|
probeOk = ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [config] works right now. The result of the last probe (also
|
||||||
|
* the one from «Save») is reused: a success for 10 min, a failure for
|
||||||
|
* 1 min, so one connect does not probe (and wait) several times. Logs
|
||||||
|
* the decision when it probes.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun ensureReady(config: SecureDnsConfig): Boolean
|
||||||
|
{
|
||||||
|
val age = SystemClock.elapsedRealtime() - probedAt
|
||||||
|
if (config == probedConfig && age < if (probeOk) PROBE_TTL_MS else PROBE_FAIL_TTL_MS)
|
||||||
|
{
|
||||||
|
return probeOk
|
||||||
|
}
|
||||||
|
val result = probe(config)
|
||||||
|
DiagLog.i(TAG, if (result.ok) "probe ${config.label} ok in ${result.ms} ms"
|
||||||
|
else "probe ${config.label} failed after ${result.ms} ms: ${result.error}; falling back to plain DNS")
|
||||||
|
return result.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts (or keeps) the local resolver for [config]; returns its port, 0 on failure. */
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun start(config: SecureDnsConfig): Int
|
||||||
|
{
|
||||||
|
server?.let { if (config == serverConfig && !it.isClosed) return it.localPort }
|
||||||
|
stopLocked("restart")
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val socket = DatagramSocket(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0))
|
||||||
|
val executor = ThreadPoolExecutor(WORKERS, WORKERS, 60, TimeUnit.SECONDS, ArrayBlockingQueue(256),
|
||||||
|
{ r ->
|
||||||
|
/* a worker that times out or is shut down closes its DoT connection,
|
||||||
|
* otherwise the TLS socket lingers until the garbage collector finds it */
|
||||||
|
Thread({ try { r.run() } finally { dot.get()?.close(); dot.remove() } }, "SecureDns-worker").apply { isDaemon = true }
|
||||||
|
}, ThreadPoolExecutor.DiscardPolicy())
|
||||||
|
executor.allowCoreThreadTimeOut(true)
|
||||||
|
server = socket
|
||||||
|
serverConfig = config
|
||||||
|
pool = executor
|
||||||
|
lastSummary = SystemClock.elapsedRealtime()
|
||||||
|
Thread({ serve(socket, executor, config) }, "SecureDns").apply { isDaemon = true }.start()
|
||||||
|
DiagLog.i(TAG, "on: ${config.label}, tunnel DNS $VIP -> 127.0.0.1:${socket.localPort}")
|
||||||
|
socket.localPort
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "local resolver failed to start: ${describe(e)}")
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun stop(reason: String) = stopLocked(reason)
|
||||||
|
|
||||||
|
private fun stopLocked(reason: String)
|
||||||
|
{
|
||||||
|
val socket = server ?: return
|
||||||
|
summary(force = true)
|
||||||
|
DiagLog.i(TAG, "off ($reason)")
|
||||||
|
socket.close()
|
||||||
|
pool?.shutdownNow()
|
||||||
|
server = null
|
||||||
|
serverConfig = null
|
||||||
|
pool = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serve(socket: DatagramSocket, executor: ThreadPoolExecutor, config: SecureDnsConfig)
|
||||||
|
{
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
while (!socket.isClosed)
|
||||||
|
{
|
||||||
|
val packet = DatagramPacket(buffer, buffer.size)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socket.receive(packet)
|
||||||
|
}
|
||||||
|
catch (_: IOException)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val query = packet.data.copyOfRange(packet.offset, packet.offset + packet.length)
|
||||||
|
val from = packet.socketAddress
|
||||||
|
executor.execute { answer(socket, config, query, from) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun answer(socket: DatagramSocket, config: SecureDnsConfig, query: ByteArray, to: java.net.SocketAddress)
|
||||||
|
{
|
||||||
|
val start = SystemClock.elapsedRealtime()
|
||||||
|
val reply = try
|
||||||
|
{
|
||||||
|
resolve(config, query).also {
|
||||||
|
SecureDnsCodec.checkReply(query, it)
|
||||||
|
totalMs.addAndGet(SystemClock.elapsedRealtime() - start)
|
||||||
|
if (failing)
|
||||||
|
{
|
||||||
|
failing = false
|
||||||
|
DiagLog.i(TAG, "upstream answers again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
failures.incrementAndGet()
|
||||||
|
failing = true
|
||||||
|
val error = describe(e)
|
||||||
|
/* the first error of each kind only: a dead upstream would flood the log */
|
||||||
|
if (loggedErrors.add(e.javaClass.simpleName))
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "query failed: $error (answered SERVFAIL)")
|
||||||
|
}
|
||||||
|
SecureDnsCodec.servfail(query)
|
||||||
|
}
|
||||||
|
queries.incrementAndGet()
|
||||||
|
if (reply != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socket.send(DatagramPacket(reply, reply.size, to))
|
||||||
|
}
|
||||||
|
catch (_: IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary(force = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summary(force: Boolean)
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (!force && now - lastSummary < SUMMARY_EVERY_MS) return
|
||||||
|
lastSummary = now
|
||||||
|
val n = queries.getAndSet(0)
|
||||||
|
val failed = failures.getAndSet(0)
|
||||||
|
val ms = totalMs.getAndSet(0)
|
||||||
|
if (n == 0L) return
|
||||||
|
val ok = n - failed
|
||||||
|
DiagLog.i(TAG, "stats: $n queries, $failed failed, avg ${if (ok > 0) ms / ok else 0} ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One upstream round trip; a stale pooled connection gets one retry on a fresh one. */
|
||||||
|
private fun resolve(config: SecureDnsConfig, query: ByteArray): ByteArray = try
|
||||||
|
{
|
||||||
|
if (config.doh) doh(config, query) else dot(config, query)
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
if (e is java.net.SocketTimeoutException) throw e /* a slow upstream: retrying would only double the wait */
|
||||||
|
dot.get()?.close()
|
||||||
|
dot.set(null)
|
||||||
|
if (config.doh) doh(config, query) else dot(config, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 8484 POST; HttpsURLConnection verifies the certificate and the hostname. */
|
||||||
|
private fun doh(config: SecureDnsConfig, query: ByteArray): ByteArray
|
||||||
|
{
|
||||||
|
val connection = URL(config.url).openConnection() as HttpsURLConnection
|
||||||
|
try
|
||||||
|
{
|
||||||
|
connection.connectTimeout = TIMEOUT_MS
|
||||||
|
connection.readTimeout = TIMEOUT_MS
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.useCaches = false
|
||||||
|
connection.setRequestProperty("Content-Type", "application/dns-message")
|
||||||
|
connection.setRequestProperty("Accept", "application/dns-message")
|
||||||
|
connection.setFixedLengthStreamingMode(query.size)
|
||||||
|
connection.outputStream.use { it.write(query) }
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK)
|
||||||
|
{
|
||||||
|
throw IOException("HTTP ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
return connection.inputStream.use { readBounded(it) }
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
connection.disconnect()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBounded(input: InputStream): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buf = ByteArray(4096)
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
out.write(buf, 0, n)
|
||||||
|
if (out.size() > 65535) throw IOException("reply too large")
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 7858 over a per-worker TLS connection. */
|
||||||
|
private fun dot(config: SecureDnsConfig, query: ByteArray): ByteArray
|
||||||
|
{
|
||||||
|
val conn = dot.get()?.takeIf { it.config == config } ?: DotConnection.open(config).also {
|
||||||
|
dot.get()?.close()
|
||||||
|
dot.set(it)
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
conn.out.write(SecureDnsCodec.frame(query))
|
||||||
|
conn.out.flush()
|
||||||
|
val reply = SecureDnsCodec.readFrame(conn.input)
|
||||||
|
SecureDnsCodec.checkReply(query, reply)
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{ /* a late reply would stay in the stream and answer the next query: never reuse it */
|
||||||
|
conn.close()
|
||||||
|
dot.set(null)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DotConnection(val config: SecureDnsConfig, val socket: SSLSocket)
|
||||||
|
{
|
||||||
|
val input: InputStream = socket.inputStream.buffered()
|
||||||
|
val out: OutputStream = socket.outputStream
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
catch (_: IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
fun open(config: SecureDnsConfig): DotConnection
|
||||||
|
{
|
||||||
|
val raw = Socket()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
raw.connect(InetSocketAddress(config.host, config.port), TIMEOUT_MS)
|
||||||
|
raw.soTimeout = TIMEOUT_MS
|
||||||
|
/* the host name sets SNI; the factory does not check it against the certificate */
|
||||||
|
val tls = (SSLSocketFactory.getDefault() as SSLSocketFactory).createSocket(raw, config.host, config.port, true) as SSLSocket
|
||||||
|
tls.startHandshake()
|
||||||
|
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(config.host, tls.session))
|
||||||
|
{
|
||||||
|
tls.close()
|
||||||
|
throw javax.net.ssl.SSLPeerUnverifiedException("certificate does not match ${config.host}")
|
||||||
|
}
|
||||||
|
return DotConnection(config, tls)
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
raw.close()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describe(e: Exception): String = "${e.javaClass.simpleName}: ${e.message ?: ""}".trimEnd(' ', ':')
|
||||||
|
}
|
||||||
+65
-3
@@ -25,10 +25,12 @@ import android.content.IntentFilter
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
import android.telephony.TelephonyManager
|
import android.telephony.TelephonyManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
@@ -162,6 +164,9 @@ object ScenarioEngine
|
|||||||
private const val TAG = "Scenarios"
|
private const val TAG = "Scenarios"
|
||||||
private const val FILE_NAME = "scenarios.json"
|
private const val FILE_NAME = "scenarios.json"
|
||||||
private const val POLL_MS = 4000L
|
private const val POLL_MS = 4000L
|
||||||
|
/* how long a broadcast waits for the VPN state service of a fresh process */
|
||||||
|
private const val SERVICE_WAIT_MS = 8000L
|
||||||
|
private const val SERVICE_POLL_MS = 200L
|
||||||
/* the foreground app did not change for a minute: poll half as often */
|
/* the foreground app did not change for a minute: poll half as often */
|
||||||
private const val SLOW_AFTER_POLLS = 15
|
private const val SLOW_AFTER_POLLS = 15
|
||||||
private const val SLOW_POLL_MS = 8000L
|
private const val SLOW_POLL_MS = 8000L
|
||||||
@@ -193,9 +198,20 @@ object ScenarioEngine
|
|||||||
_list.value = list
|
_list.value = list
|
||||||
persist(context)
|
persist(context)
|
||||||
start(context)
|
start(context)
|
||||||
|
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
|
||||||
evaluate(context)
|
evaluate(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
|
||||||
|
@JvmStatic
|
||||||
|
fun needsNetworkEvents(context: Context): Boolean
|
||||||
|
{
|
||||||
|
load(context.applicationContext)
|
||||||
|
return _list.value.any {
|
||||||
|
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Starts listening (from the application); idempotent. */
|
/** Starts listening (from the application); idempotent. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun start(context: Context)
|
fun start(context: Context)
|
||||||
@@ -289,6 +305,39 @@ object ScenarioEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides again once the VPN state service exists. A broadcast that starts a
|
||||||
|
* fresh process arrives before the managers bound the service; evaluating
|
||||||
|
* then would silently drop the event. Gives up after [SERVICE_WAIT_MS]
|
||||||
|
* (the next network or alarm event evaluates again) and always calls [done].
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun evaluateWhenReady(context: Context, done: Runnable?)
|
||||||
|
{
|
||||||
|
val app = context.applicationContext
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS
|
||||||
|
val check = object : Runnable
|
||||||
|
{
|
||||||
|
override fun run()
|
||||||
|
{
|
||||||
|
if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline)
|
||||||
|
{
|
||||||
|
handler.postDelayed(this, SERVICE_POLL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluate(app)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
done?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.post(check)
|
||||||
|
}
|
||||||
|
|
||||||
/** Network, whitelist or alarm: decide again. */
|
/** Network, whitelist or alarm: decide again. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun evaluate(context: Context)
|
fun evaluate(context: Context)
|
||||||
@@ -431,10 +480,12 @@ object ScenarioEngine
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
/* without a live service nothing can be put back: keep the record so the
|
||||||
|
* next evaluation (the receiver waits for the service) still restores */
|
||||||
|
val service = VpnStateService.getLiveInstance() ?: return
|
||||||
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
||||||
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
||||||
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||||
val service = VpnStateService.getLiveInstance() ?: return
|
|
||||||
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
||||||
if (PauseManager.isPaused(context))
|
if (PauseManager.isPaused(context))
|
||||||
{
|
{
|
||||||
@@ -562,12 +613,22 @@ object ScenarioEngine
|
|||||||
{
|
{
|
||||||
c.add(Calendar.DAY_OF_YEAR, 1)
|
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
}
|
}
|
||||||
|
/* same choice as PauseManager: exact when the grant allows it */
|
||||||
|
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
if (exact)
|
||||||
|
{
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (_: SecurityException)
|
catch (_: SecurityException)
|
||||||
{
|
{ /* revoked in between: an inexact alarm is still better than none */
|
||||||
|
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
|
|||||||
override fun onReceive(context: Context, intent: Intent?)
|
override fun onReceive(context: Context, intent: Intent?)
|
||||||
{
|
{
|
||||||
ScenarioEngine.start(context)
|
ScenarioEngine.start(context)
|
||||||
ScenarioEngine.evaluate(context)
|
val pending = goAsync()
|
||||||
|
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+52
-3
@@ -15,6 +15,8 @@
|
|||||||
package org.strongswan.android.logic.stats
|
package org.strongswan.android.logic.stats
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
||||||
* counters are sampled every [SAMPLE_S] seconds and the growth is added to
|
* counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
|
||||||
|
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
|
||||||
* the current day; the counters restart with every connection, so a drop
|
* the current day; the counters restart with every connection, so a drop
|
||||||
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
||||||
*/
|
*/
|
||||||
@@ -46,12 +49,21 @@ object TrafficHistory
|
|||||||
private const val TAG = "TrafficHistory"
|
private const val TAG = "TrafficHistory"
|
||||||
private const val FILE_NAME = "traffic.json"
|
private const val FILE_NAME = "traffic.json"
|
||||||
private const val SAMPLE_S = 15L
|
private const val SAMPLE_S = 15L
|
||||||
|
private const val SAMPLE_OFF_S = 60L
|
||||||
|
/* the history lives in memory and is written out this often (not on every
|
||||||
|
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
|
||||||
|
* killed process loses at most this much of the statistics */
|
||||||
|
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
|
||||||
private const val KEEP_DAYS = 120
|
private const val KEEP_DAYS = 120
|
||||||
|
|
||||||
private val executor = Executors.newSingleThreadScheduledExecutor()
|
private val executor = Executors.newSingleThreadScheduledExecutor()
|
||||||
private var task: ScheduledFuture<*>? = null
|
private var task: ScheduledFuture<*>? = null
|
||||||
private var lastVpn = -1L
|
private var lastVpn = -1L
|
||||||
private var lastDirect = -1L
|
private var lastDirect = -1L
|
||||||
|
private var lastSampleAt = 0L
|
||||||
|
private var dirty = false
|
||||||
|
private var lastSavedAt = 0L
|
||||||
|
private var lastSavedDay: String? = null
|
||||||
@Volatile private var loaded = false
|
@Volatile private var loaded = false
|
||||||
private val days = LinkedHashMap<String, LongArray>()
|
private val days = LinkedHashMap<String, LongArray>()
|
||||||
private var firstDay: String? = null
|
private var firstDay: String? = null
|
||||||
@@ -86,11 +98,38 @@ object TrafficHistory
|
|||||||
val running = task ?: return
|
val running = task ?: return
|
||||||
running.cancel(false)
|
running.cancel(false)
|
||||||
task = null
|
task = null
|
||||||
executor.execute { sample(app) }
|
executor.execute {
|
||||||
|
sample(app, force = true)
|
||||||
|
flush(app)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sample(context: Context)
|
/** Writes the history out now if it changed since the last write. */
|
||||||
|
@Synchronized
|
||||||
|
fun flush(context: Context)
|
||||||
{
|
{
|
||||||
|
if (dirty)
|
||||||
|
{
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a changed history has to be written now: periodically and when the day turns. */
|
||||||
|
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
|
||||||
|
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
|
||||||
|
|
||||||
|
private fun screenOn(context: Context): Boolean =
|
||||||
|
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
|
||||||
|
|
||||||
|
private fun sample(context: Context, force: Boolean = false)
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val screen = screenOn(context)
|
||||||
|
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
|
||||||
|
{ /* screen off: a sample a minute is enough, the counters accumulate */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSampleAt = now
|
||||||
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
||||||
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
||||||
/* direct bytes only exist with the userspace router (s[3]) */
|
/* direct bytes only exist with the userspace router (s[3]) */
|
||||||
@@ -121,10 +160,17 @@ object TrafficHistory
|
|||||||
firstDay = key
|
firstDay = key
|
||||||
}
|
}
|
||||||
prune()
|
prune()
|
||||||
|
dirty = true
|
||||||
|
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
|
||||||
|
{
|
||||||
save(context)
|
save(context)
|
||||||
|
}
|
||||||
_changes.value = System.currentTimeMillis()
|
_changes.value = System.currentTimeMillis()
|
||||||
|
if (screenOn(context))
|
||||||
|
{ /* nobody sees the widget with the screen off */
|
||||||
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -205,6 +251,9 @@ object TrafficHistory
|
|||||||
file.writeText(json.toString())
|
file.writeText(json.toString())
|
||||||
tmp.delete()
|
tmp.delete()
|
||||||
}
|
}
|
||||||
|
dirty = false
|
||||||
|
lastSavedAt = SystemClock.elapsedRealtime()
|
||||||
|
lastSavedDay = dayKey(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
catch (e: Exception)
|
catch (e: Exception)
|
||||||
{
|
{
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What goes into a report sent from the log screen: today's part of charon.log and,
|
||||||
|
* on request, the app's diag.log. No Android types here, so it runs in unit tests.
|
||||||
|
*/
|
||||||
|
object ReportContent
|
||||||
|
{
|
||||||
|
/* the panel unpacks a report with a 5 MB cap; 3 + 0.75 MB of text leaves room and stays far below 2 MB gzip */
|
||||||
|
const val CHARON_MAX_BYTES = 3 * 1024 * 1024
|
||||||
|
const val DIAG_MAX_BYTES = 768 * 1024
|
||||||
|
const val CUT_MARK = "... (earlier lines cut)\n"
|
||||||
|
|
||||||
|
/** Why a report was not sent; each maps to a reason line of the "not sent" sheet */
|
||||||
|
enum class Failure { NO_NETWORK, SERVER_DOWN, SIGNATURE, TOO_OFTEN, TOO_LARGE, REJECTED }
|
||||||
|
|
||||||
|
fun failure(httpCode: Int): Failure = when
|
||||||
|
{
|
||||||
|
httpCode == 0 -> Failure.NO_NETWORK
|
||||||
|
httpCode == 401 -> Failure.SIGNATURE
|
||||||
|
httpCode == 413 -> Failure.TOO_LARGE
|
||||||
|
httpCode == 429 -> Failure.TOO_OFTEN
|
||||||
|
httpCode >= 500 || httpCode == 404 -> Failure.SERVER_DOWN
|
||||||
|
else -> Failure.REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
/* charon: «Nov 8 14:02:11 06[IKE] ...» (strftime %b %e, C locale) */
|
||||||
|
private val CHARON_DATE = Regex("""^(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines of [month] (1-12) / [day] only; lines without a date stay with the dated line before them.
|
||||||
|
* When nothing is from today the whole text is kept: an old log still helps support.
|
||||||
|
*/
|
||||||
|
fun todayOnly(text: String, month: Int, day: Int): String
|
||||||
|
{
|
||||||
|
var keep = true
|
||||||
|
var today = false
|
||||||
|
val out = StringBuilder()
|
||||||
|
for (line in text.lineSequence())
|
||||||
|
{
|
||||||
|
CHARON_DATE.find(line)?.let { m ->
|
||||||
|
keep = MONTHS.indexOf(m.groupValues[1]) + 1 == month && m.groupValues[2].toInt() == day
|
||||||
|
today = today || keep
|
||||||
|
}
|
||||||
|
if (keep && line.isNotEmpty())
|
||||||
|
{
|
||||||
|
out.append(line).append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* undated lines alone (e.g. the cut mark of readTail) do not count as today's log */
|
||||||
|
return if (today) out.toString() else text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last [maxBytes] of UTF-8 [text], cut at a line start; the cut is marked */
|
||||||
|
fun tailBytes(text: String, maxBytes: Int): String
|
||||||
|
{
|
||||||
|
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||||
|
if (bytes.size <= maxBytes)
|
||||||
|
{
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
var start = bytes.size - maxBytes
|
||||||
|
/* '\n' is never part of a multi-byte UTF-8 sequence, so cutting after it keeps characters whole */
|
||||||
|
while (start < bytes.size && bytes[start - 1] != '\n'.code.toByte())
|
||||||
|
{
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
if (start == bytes.size)
|
||||||
|
{ /* one huge line: cut anywhere, but not inside a character (skip 10xxxxxx continuation bytes) */
|
||||||
|
start = bytes.size - maxBytes
|
||||||
|
while (start < bytes.size && (bytes[start].toInt() and 0xC0) == 0x80)
|
||||||
|
{
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CUT_MARK + String(bytes, start, bytes.size - start, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to [maxBytes] from the end of [file] ("" when missing); a partial first line is dropped */
|
||||||
|
fun readTail(file: File, maxBytes: Int): String
|
||||||
|
{
|
||||||
|
if (!file.exists())
|
||||||
|
{
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
RandomAccessFile(file, "r").use { f ->
|
||||||
|
val from = maxOf(0L, f.length() - maxBytes)
|
||||||
|
val buf = ByteArray((f.length() - from).toInt())
|
||||||
|
f.seek(from)
|
||||||
|
f.readFully(buf)
|
||||||
|
val text = String(buf, Charsets.UTF_8)
|
||||||
|
if (from > 0) CUT_MARK + text.substringAfter('\n') else text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One network check of the diagnostics screen: the panel shows them as a check list */
|
||||||
|
data class Check(val title: String, val value: String, val ok: Boolean?)
|
||||||
|
|
||||||
|
/** JSON array of the checks (no org.json: this object stays testable without Android) */
|
||||||
|
fun checksJson(checks: List<Check>): String = checks.joinToString(",", "[", "]") { c ->
|
||||||
|
"{\"title\":${jsonString(c.title)},\"value\":${jsonString(c.value)},\"ok\":${c.ok?.toString() ?: "null"}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonString(s: String): String = buildString {
|
||||||
|
append('"')
|
||||||
|
for (ch in s)
|
||||||
|
{
|
||||||
|
when
|
||||||
|
{
|
||||||
|
ch == '"' -> append("\\\"")
|
||||||
|
ch == '\\' -> append("\\\\")
|
||||||
|
ch == '\n' -> append("\\n")
|
||||||
|
ch == '\r' -> append("\\r")
|
||||||
|
ch == '\t' -> append("\\t")
|
||||||
|
ch < ' ' -> append("\\u%04x".format(ch.code))
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append('"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report text: the connection log, the network checks and diag.log when asked for */
|
||||||
|
fun build(charon: String, diag: String?, checks: List<Check>? = null): String = buildString {
|
||||||
|
append("=== charon.log ===\n")
|
||||||
|
append(tailBytes(charon, CHARON_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||||
|
if (!checks.isNullOrEmpty())
|
||||||
|
{
|
||||||
|
append("\n=== checks.json ===\n")
|
||||||
|
append(checksJson(checks)).append('\n')
|
||||||
|
}
|
||||||
|
if (diag != null)
|
||||||
|
{
|
||||||
|
append("\n=== diag.log ===\n")
|
||||||
|
append(tailBytes(diag, DIAG_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meta of a report from the log screen; [network] is "Wi-Fi", "mobile" or "none" */
|
||||||
|
fun meta(
|
||||||
|
versionName: String, versionCode: Long, manufacturer: String, model: String, release: String, sdk: Int,
|
||||||
|
network: String, comment: String, profile: String,
|
||||||
|
) = ReportUploader.Meta(
|
||||||
|
versionName = versionName,
|
||||||
|
versionCode = versionCode,
|
||||||
|
device = if (model.startsWith(manufacturer, ignoreCase = true)) model else "$manufacturer $model",
|
||||||
|
os = "Android $release (API $sdk)",
|
||||||
|
network = network,
|
||||||
|
source = "log",
|
||||||
|
comment = comment.trim(),
|
||||||
|
profile = profile,
|
||||||
|
)
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a diagnostic log to the web panel (POST /api/v1/reports) and returns the report
|
||||||
|
* code the user can quote to support. The request is signed like the update check.
|
||||||
|
* The "Send report" sheet of the log screen (LogActivity) calls it, see [ReportContent].
|
||||||
|
*/
|
||||||
|
class ReportUploader(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val hmacKey: String,
|
||||||
|
private val installId: String,
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
data class Meta(
|
||||||
|
val versionName: String,
|
||||||
|
val versionCode: Long,
|
||||||
|
val device: String,
|
||||||
|
val os: String,
|
||||||
|
val network: String,
|
||||||
|
val source: String,
|
||||||
|
val comment: String,
|
||||||
|
val profile: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class Result
|
||||||
|
{
|
||||||
|
data class Sent(val code: String) : Result()
|
||||||
|
data class Failed(val httpCode: Int, val detail: String) : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** Server limit: the whole request body must stay below 2 MB */
|
||||||
|
const val MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
const val BOUNDARY = "----shxreport"
|
||||||
|
private val CODE = Regex("^SHX-[0-9A-Z]{5}$")
|
||||||
|
|
||||||
|
/** The panel serves the app API under /api/v1 (the proxy routes only that prefix); the build may give just the host */
|
||||||
|
fun apiBase(url: String): String
|
||||||
|
{
|
||||||
|
val base = url.trim().trimEnd('/')
|
||||||
|
return if (base.endsWith("/api/v1")) base else "$base/api/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 201 with {"code":"SHX-XXXXX"} is the only success; a broken 201 is not "no network" */
|
||||||
|
fun parseResponse(httpCode: Int, body: String): Result
|
||||||
|
{
|
||||||
|
if (httpCode != 201)
|
||||||
|
{
|
||||||
|
return Result.Failed(httpCode, body.take(200))
|
||||||
|
}
|
||||||
|
val code = try { JSONObject(body).optString("code", "") } catch (e: Exception) { "" }
|
||||||
|
return if (CODE.matches(code)) Result.Sent(code) else Result.Failed(httpCode, "bad response: ${body.take(100)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gzip(text: String): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun metaJson(m: Meta): String = JSONObject()
|
||||||
|
.put("versionName", m.versionName).put("versionCode", m.versionCode)
|
||||||
|
.put("device", m.device.take(120)).put("os", m.os.take(60)).put("network", m.network.take(40))
|
||||||
|
.put("source", m.source).put("comment", m.comment.take(1000)).put("profile", m.profile.take(200))
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
/** multipart/form-data with the gzip log and the meta JSON */
|
||||||
|
fun multipartBody(logGz: ByteArray, meta: String): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
fun w(s: String) = out.write(s.toByteArray(Charsets.UTF_8))
|
||||||
|
w("--$BOUNDARY\r\nContent-Disposition: form-data; name=\"log\"; filename=\"log.gz\"\r\nContent-Type: application/gzip\r\n\r\n")
|
||||||
|
out.write(logGz)
|
||||||
|
/* a plain text field: @fastify/multipart turns an application/json field into an object and the server then reads "[object Object]" */
|
||||||
|
w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"meta\"\r\n\r\n")
|
||||||
|
w(meta)
|
||||||
|
w("\r\n--$BOUNDARY--\r\n")
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(logText: String, meta: Meta): Result
|
||||||
|
{
|
||||||
|
val body = multipartBody(gzip(logText), metaJson(meta))
|
||||||
|
if (body.size > MAX_BYTES)
|
||||||
|
{
|
||||||
|
return Result.Failed(413, "report is larger than 2 MB")
|
||||||
|
}
|
||||||
|
val url = apiBase(baseUrl) + "/reports"
|
||||||
|
val ts = clock() / 1000
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
conn.requestMethod = "POST"
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY")
|
||||||
|
conn.setRequestProperty("X-Install-Id", installId)
|
||||||
|
conn.setRequestProperty("X-Timestamp", ts.toString())
|
||||||
|
conn.setRequestProperty("X-Signature", UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("POST", URL(url).path, ts)))
|
||||||
|
conn.outputStream.use { it.write(body) }
|
||||||
|
val code = conn.responseCode
|
||||||
|
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes().decodeToString() } ?: ""
|
||||||
|
parseResponse(code, text)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Result.Failed(0, e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAEngine
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
||||||
|
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
|
||||||
|
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client of the SHX update API (/api/v1/updates/check).
|
||||||
|
*
|
||||||
|
* The wire contract is frozen in the web panel repository: docs/openapi.v1.json
|
||||||
|
* and packages/contracts/vectors/signing.json. [UpdateSigning] builds exactly
|
||||||
|
* the strings the server signs; UpdateApiTest checks it against those vectors.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
object UpdateSigning
|
||||||
|
{
|
||||||
|
const val SIGNING_VERSION = "shx-manifest-v1"
|
||||||
|
|
||||||
|
/** The fields covered by the manifest signature (notes are display-only and not signed) */
|
||||||
|
data class Fields(
|
||||||
|
val update: Boolean,
|
||||||
|
val mandatory: Boolean,
|
||||||
|
val versionCode: Long,
|
||||||
|
val versionName: String,
|
||||||
|
val sha256: String,
|
||||||
|
val size: Long,
|
||||||
|
val apkUrl: String,
|
||||||
|
val issuedAt: Long,
|
||||||
|
val checkHours: Int,
|
||||||
|
val wifiOnly: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fields joined by '\n' in a strict order; absent values are 0 / empty (same as the server) */
|
||||||
|
fun manifestInput(f: Fields): String
|
||||||
|
{
|
||||||
|
return listOf(
|
||||||
|
SIGNING_VERSION,
|
||||||
|
if (f.update) "1" else "0",
|
||||||
|
if (f.mandatory) "1" else "0",
|
||||||
|
f.versionCode.toString(),
|
||||||
|
f.versionName,
|
||||||
|
f.sha256,
|
||||||
|
f.size.toString(),
|
||||||
|
f.apkUrl,
|
||||||
|
f.issuedAt.toString(),
|
||||||
|
f.checkHours.toString(),
|
||||||
|
if (f.wifiOnly) "1" else "0",
|
||||||
|
).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestInput(method: String, path: String, timestamp: Long): String
|
||||||
|
{
|
||||||
|
return "${method.uppercase()}|$path|$timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hmacBase64(key: String, input: String): String
|
||||||
|
{
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
||||||
|
return Base64.Default.encode(mac.doFinal(input.toByteArray(Charsets.UTF_8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ed25519 verification on API 24+ (java.security Ed25519 exists only from API 33),
|
||||||
|
* therefore a small pure-Java implementation is used
|
||||||
|
*/
|
||||||
|
fun verifyEd25519(publicKeyRaw: ByteArray, input: String, signatureBase64: String): Boolean
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val spec = EdDSAPublicKeySpec(publicKeyRaw, EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519))
|
||||||
|
val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512"))
|
||||||
|
engine.initVerify(EdDSAPublicKey(spec))
|
||||||
|
engine.update(input.toByteArray(Charsets.UTF_8))
|
||||||
|
engine.verify(Base64.Default.decode(signatureBase64))
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the HTTP is delegated to, so the client can be tested without a network */
|
||||||
|
interface UpdateHttp
|
||||||
|
{
|
||||||
|
data class Response(val code: Int, val body: String)
|
||||||
|
|
||||||
|
fun get(url: String, headers: Map<String, String>): Response
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp
|
||||||
|
{
|
||||||
|
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
|
||||||
|
{
|
||||||
|
val conn = URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection
|
||||||
|
conn.connectTimeout = timeoutMs
|
||||||
|
conn.readTimeout = timeoutMs
|
||||||
|
conn.setRequestProperty("Connection", "close")
|
||||||
|
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||||
|
for ((k, v) in headers)
|
||||||
|
{
|
||||||
|
conn.setRequestProperty(k, v)
|
||||||
|
}
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val code = conn.responseCode
|
||||||
|
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
UpdateHttp.Response(code, stream?.use { it.readBytes().decodeToString() } ?: "")
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Policy delivered inside the signed manifest; the user's own settings stay the default */
|
||||||
|
data class UpdatePolicy(val checkHours: Int, val wifiOnly: Boolean)
|
||||||
|
|
||||||
|
sealed class UpdateApiResult
|
||||||
|
{
|
||||||
|
data class Update(val info: UpdateInfo, val mandatory: Boolean, val policy: UpdatePolicy, val issuedAt: Long) : UpdateApiResult()
|
||||||
|
|
||||||
|
data class NoUpdate(val policy: UpdatePolicy) : UpdateApiResult()
|
||||||
|
|
||||||
|
/** [kind]: NETWORK (try the legacy path), SIGNATURE (never fall back), OTHER (rate limit, bad request) */
|
||||||
|
data class Failure(val kind: UpdateError, val detail: String) : UpdateApiResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class UpdateApiClient(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val hmacKey: String,
|
||||||
|
private val publicKeyBase64: String,
|
||||||
|
private val installId: String,
|
||||||
|
private val http: UpdateHttp = HttpUrlConnectionHttp(),
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** The manifest is rejected when its issue time is far from the device clock (replay of a stale "no update") */
|
||||||
|
const val MAX_SKEW_SECONDS = 6 * 3600L
|
||||||
|
|
||||||
|
fun isConfigured(baseUrl: String, hmacKey: String, publicKey: String): Boolean
|
||||||
|
{
|
||||||
|
return baseUrl.isNotBlank() && hmacKey.isNotBlank() && publicKey.isNotBlank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun check(packageId: String, versionCode: Long, channel: String, abi: String?, sdk: Int, betaToken: String?): UpdateApiResult
|
||||||
|
{
|
||||||
|
val path = URL(ReportUploader.apiBase(baseUrl) + "/updates/check").path
|
||||||
|
val query = buildString {
|
||||||
|
append("package=").append(enc(packageId))
|
||||||
|
append("&versionCode=").append(versionCode)
|
||||||
|
append("&channel=").append(enc(channel))
|
||||||
|
if (!abi.isNullOrBlank()) append("&abi=").append(enc(abi))
|
||||||
|
append("&sdk=").append(sdk)
|
||||||
|
if (!betaToken.isNullOrBlank()) append("&betaToken=").append(enc(betaToken))
|
||||||
|
}
|
||||||
|
val ts = clock() / 1000
|
||||||
|
val headers = mapOf(
|
||||||
|
"X-Install-Id" to installId,
|
||||||
|
"X-Timestamp" to ts.toString(),
|
||||||
|
"X-Signature" to UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", path, ts)),
|
||||||
|
)
|
||||||
|
val response = try
|
||||||
|
{
|
||||||
|
http.get(ReportUploader.apiBase(baseUrl) + "/updates/check?" + query, headers)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.NETWORK, e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
response.code in 500..599 || response.code == 404 -> UpdateApiResult.Failure(UpdateError.NETWORK, "HTTP ${response.code}")
|
||||||
|
response.code != 200 -> UpdateApiResult.Failure(UpdateError.OTHER, "HTTP ${response.code}")
|
||||||
|
else -> parse(response.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(body: String): UpdateApiResult
|
||||||
|
{
|
||||||
|
val json = try { JSONObject(body) } catch (e: Exception) { return UpdateApiResult.Failure(UpdateError.OTHER, "bad json") }
|
||||||
|
val update = json.optBoolean("update", false)
|
||||||
|
val fields = UpdateSigning.Fields(
|
||||||
|
update = update,
|
||||||
|
mandatory = json.optBoolean("mandatory", false),
|
||||||
|
versionCode = if (update) json.optLong("versionCode", 0L) else 0L,
|
||||||
|
versionName = if (update) json.optString("versionName", "") else "",
|
||||||
|
sha256 = if (update) json.optString("sha256", "").lowercase() else "",
|
||||||
|
size = if (update) json.optLong("size", 0L) else 0L,
|
||||||
|
apkUrl = if (update) json.optString("apkUrl", "") else "",
|
||||||
|
issuedAt = json.optLong("issuedAt", 0L),
|
||||||
|
checkHours = json.optInt("checkHours", 12),
|
||||||
|
wifiOnly = json.optBoolean("wifiOnly", true),
|
||||||
|
)
|
||||||
|
val key = try { Base64.Default.decode(publicKeyBase64) } catch (e: Exception) { ByteArray(0) }
|
||||||
|
if (key.size != 32 || !UpdateSigning.verifyEd25519(key, UpdateSigning.manifestInput(fields), json.optString("signature", "")))
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest signature mismatch")
|
||||||
|
}
|
||||||
|
val skew = Math.abs(clock() / 1000 - fields.issuedAt)
|
||||||
|
if (skew > MAX_SKEW_SECONDS)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest is too old or the device clock is off ($skew s)")
|
||||||
|
}
|
||||||
|
val policy = UpdatePolicy(fields.checkHours, fields.wifiOnly)
|
||||||
|
if (!update)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.NoUpdate(policy)
|
||||||
|
}
|
||||||
|
if (fields.apkUrl.isBlank() || fields.sha256.length != 64)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.OTHER, "incomplete manifest")
|
||||||
|
}
|
||||||
|
val notes = HashMap<String, String>()
|
||||||
|
json.optJSONObject("notes")?.let { n -> for (k in n.keys()) notes[k.lowercase()] = n.optString(k, "") }
|
||||||
|
val info = UpdateInfo(
|
||||||
|
versionCode = fields.versionCode, versionName = fields.versionName, apkUrl = fields.apkUrl,
|
||||||
|
sha256 = fields.sha256, size = fields.size, notes = notes, fromApi = true, issuedAt = fields.issuedAt,
|
||||||
|
)
|
||||||
|
return UpdateApiResult.Update(info, fields.mandatory, policy, fields.issuedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
|
||||||
|
}
|
||||||
+202
-24
@@ -62,6 +62,7 @@ import java.net.Proxy
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
/** Categories of update failures, mapped to localized messages by the UI */
|
/** Categories of update failures, mapped to localized messages by the UI */
|
||||||
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
||||||
@@ -76,6 +77,10 @@ data class UpdateInfo(
|
|||||||
val sha256: String,
|
val sha256: String,
|
||||||
val size: Long,
|
val size: Long,
|
||||||
val notes: Map<String, String>,
|
val notes: Map<String, String>,
|
||||||
|
/* set for manifests from the SHX update API: the file URL is presigned and short-lived */
|
||||||
|
val fromApi: Boolean = false,
|
||||||
|
val issuedAt: Long = 0L,
|
||||||
|
val mandatory: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class UpdateState
|
sealed class UpdateState
|
||||||
@@ -117,6 +122,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
||||||
private const val UPDATES_NOTIFICATION_ID = 1001
|
private const val UPDATES_NOTIFICATION_ID = 1001
|
||||||
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
||||||
|
private const val PREF_INSTALL_ID = "pref_update_install_id"
|
||||||
|
private const val PREF_UPDATE_CHANNEL = "pref_update_channel"
|
||||||
|
private const val PREF_BETA_TOKEN = "pref_update_beta_token"
|
||||||
|
private const val PREF_API_CHECK_HOURS = "pref_update_api_check_hours"
|
||||||
|
private const val PREF_API_WIFI_ONLY = "pref_update_api_wifi_only"
|
||||||
|
/* a mandatory update the last check found: keeps the blocking sheet up across restarts and failed checks */
|
||||||
|
private const val PREF_MANDATORY_CODE = "pref_update_mandatory_code"
|
||||||
|
private const val PREF_MANDATORY_NAME = "pref_update_mandatory_name"
|
||||||
|
private const val PREF_MANDATORY_SIZE = "pref_update_mandatory_size"
|
||||||
|
/* refresh the manifest when it is older than this before downloading (the URL lives 10 minutes) */
|
||||||
|
private const val URL_MAX_AGE_SECONDS = 8 * 60L
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mInstance: UpdateManager? = null
|
private var mInstance: UpdateManager? = null
|
||||||
@@ -166,6 +182,11 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
* was known in this process: install as soon as the check finds it */
|
* was known in this process: install as soon as the check finds it */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mInstallPending = false
|
private var mInstallPending = false
|
||||||
|
/* set by cancelDownload(); the copy loop and the install step check it */
|
||||||
|
@Volatile
|
||||||
|
private var mCancelRequested = false
|
||||||
|
@Volatile
|
||||||
|
private var mDownloadConn: HttpURLConnection? = null
|
||||||
private var mVpnService: VpnStateService? = null
|
private var mVpnService: VpnStateService? = null
|
||||||
|
|
||||||
private val mConnection = object : ServiceConnection
|
private val mConnection = object : ServiceConnection
|
||||||
@@ -191,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
cleanup()
|
cleanup()
|
||||||
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
||||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS)
|
/* the interval comes from the server policy (signed manifest), 12 h until it is known */
|
||||||
|
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(checkIntervalHours(), TimeUnit.HOURS)
|
||||||
.setConstraints(Constraints.Builder()
|
.setConstraints(Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
/* background checks only run while charging - zero battery impact */
|
/* background checks only run while charging - zero battery impact */
|
||||||
@@ -224,7 +246,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
||||||
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS)
|
if (System.currentTimeMillis() - last >= TimeUnit.HOURS.toMillis(checkIntervalHours()))
|
||||||
{
|
{
|
||||||
return check()
|
return check()
|
||||||
}
|
}
|
||||||
@@ -244,6 +266,57 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
check()
|
check()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while the update channel is "beta" */
|
||||||
|
fun isBetaChannel(): Boolean = mPrefs.getString(PREF_UPDATE_CHANNEL, "release") == "beta"
|
||||||
|
|
||||||
|
/** Switches the channel (leaving beta forgets the invitation code) and checks right away */
|
||||||
|
fun setChannel(beta: Boolean, token: String?)
|
||||||
|
{
|
||||||
|
val edit = mPrefs.edit().putString(PREF_UPDATE_CHANNEL, if (beta) "beta" else "release")
|
||||||
|
if (beta) edit.putString(PREF_BETA_TOKEN, token?.trim()) else edit.remove(PREF_BETA_TOKEN)
|
||||||
|
edit.apply()
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Version name and size of a mandatory update newer than the installed app, or null */
|
||||||
|
fun pendingMandatory(): Pair<String, Long>?
|
||||||
|
{
|
||||||
|
if (mPrefs.getLong(PREF_MANDATORY_CODE, 0L) <= currentVersionCode())
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (mPrefs.getString(PREF_MANDATORY_NAME, null) ?: "") to mPrefs.getLong(PREF_MANDATORY_SIZE, -1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interrupts a running download, deletes the partial file and goes back to Idle */
|
||||||
|
@Synchronized
|
||||||
|
fun cancelDownload()
|
||||||
|
{
|
||||||
|
if (mDownloadJob?.isActive != true)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mInstallPending = false
|
||||||
|
mCancelRequested = true
|
||||||
|
mDownloadJob?.cancel()
|
||||||
|
/* unblocks a read waiting for the network */
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mDownloadConn?.disconnect()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadCancelled()
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "update: download cancelled")
|
||||||
|
cleanup()
|
||||||
|
cancelDownloadProgress()
|
||||||
|
mState.value = UpdateState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun check(): Job?
|
private fun check(): Job?
|
||||||
{
|
{
|
||||||
@@ -257,7 +330,12 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
mInstallPending = false
|
mInstallPending = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
|
val mandatory = info.mandatory && info.versionCode > currentVersionCode()
|
||||||
|
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis())
|
||||||
|
.putLong(PREF_MANDATORY_CODE, if (mandatory) info.versionCode else 0L)
|
||||||
|
.putString(PREF_MANDATORY_NAME, info.versionName)
|
||||||
|
.putLong(PREF_MANDATORY_SIZE, info.size)
|
||||||
|
.apply()
|
||||||
if (info.versionCode > currentVersionCode())
|
if (info.versionCode > currentVersionCode())
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
||||||
@@ -300,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isNetworkMetered())
|
/* server policy "Wi-Fi only" (default true); a policy of false allows the silent download on metered networks too */
|
||||||
|
if (mPrefs.getBoolean(PREF_API_WIFI_ONLY, true) && isNetworkMetered())
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
||||||
return
|
return
|
||||||
@@ -309,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mCancelRequested = false
|
||||||
mDownloadJob = mScope.launch {
|
mDownloadJob = mScope.launch {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
||||||
val apk = downloadApk(info)
|
val apk = downloadApk(info)
|
||||||
verifySignature(apk)
|
verifySignature(apk)
|
||||||
|
if (mCancelRequested)
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
if (isVpnDown() && canInstallSilently())
|
if (isVpnDown() && canInstallSilently())
|
||||||
{
|
{
|
||||||
Log.i(TAG, "Applying the downloaded update silently")
|
Log.i(TAG, "Applying the downloaded update silently")
|
||||||
@@ -340,6 +424,10 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (e: CancellationException)
|
||||||
|
{
|
||||||
|
onDownloadCancelled()
|
||||||
|
}
|
||||||
catch (e: Exception)
|
catch (e: Exception)
|
||||||
{
|
{
|
||||||
Log.e(TAG, "Silent update download failed", e)
|
Log.e(TAG, "Silent update download failed", e)
|
||||||
@@ -350,6 +438,9 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check interval from the server policy, kept between 1 hour and a week */
|
||||||
|
fun checkIntervalHours(): Long = mPrefs.getInt(PREF_API_CHECK_HOURS, 12).coerceIn(1, 168).toLong()
|
||||||
|
|
||||||
private fun isSilentUpdatesEnabled(): Boolean
|
private fun isSilentUpdatesEnabled(): Boolean
|
||||||
{
|
{
|
||||||
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
||||||
@@ -408,7 +499,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
||||||
{
|
{
|
||||||
Notification.Builder(context, UPDATES_CHANNEL)
|
val builder = Notification.Builder(context, UPDATES_CHANNEL)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText("$progress%")
|
.setContentText("$progress%")
|
||||||
@@ -416,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
.setStyle(Notification.ProgressStyle()
|
.setStyle(Notification.ProgressStyle()
|
||||||
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
||||||
.setProgress(progress))
|
.setProgress(progress))
|
||||||
.setRequestPromotedOngoing(true)
|
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
.build()
|
/* setRequestPromotedOngoing appeared in Android 16 QPR2 (API 36.1): on 36.0 it is a
|
||||||
|
* NoSuchMethodError that killed the app as soon as an update download started */
|
||||||
|
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1)
|
||||||
|
{
|
||||||
|
builder.setRequestPromotedOngoing(true)
|
||||||
|
}
|
||||||
|
builder.build()
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -519,16 +615,25 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
check()
|
check()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mCancelRequested = false
|
||||||
mDownloadJob = mScope.launch {
|
mDownloadJob = mScope.launch {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
||||||
val apk = downloadApk(info)
|
val apk = downloadApk(info)
|
||||||
verifySignature(apk)
|
verifySignature(apk)
|
||||||
|
if (mCancelRequested)
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
||||||
UpdateInstaller.install(context, apk)
|
UpdateInstaller.install(context, apk)
|
||||||
mState.value = UpdateState.Idle
|
mState.value = UpdateState.Idle
|
||||||
}
|
}
|
||||||
|
catch (e: CancellationException)
|
||||||
|
{
|
||||||
|
onDownloadCancelled()
|
||||||
|
}
|
||||||
catch (e: UpdateException)
|
catch (e: UpdateException)
|
||||||
{
|
{
|
||||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||||
@@ -560,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
|
|
||||||
private fun fetchManifest(): UpdateInfo?
|
private fun fetchManifest(): UpdateInfo?
|
||||||
{
|
{
|
||||||
|
if (apiConfigured())
|
||||||
|
{
|
||||||
|
when (val r = callApi())
|
||||||
|
{
|
||||||
|
is UpdateApiResult.Update -> return r.info.copy(mandatory = r.mandatory)
|
||||||
|
is UpdateApiResult.NoUpdate -> return upToDateMarker()
|
||||||
|
is UpdateApiResult.Failure ->
|
||||||
|
{
|
||||||
|
/* only a network or server failure falls back to the legacy bucket path; a bad
|
||||||
|
* signature or a rate limit must not be bypassed by asking the bucket */
|
||||||
|
if (r.kind != UpdateError.NETWORK || !legacyConfigured())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "update: API check failed: ${r.kind} ${r.detail}")
|
||||||
|
mState.value = UpdateState.Failed(r.kind, r.detail)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "update: API unreachable (${r.detail}), using the legacy manifest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
||||||
@@ -592,6 +717,58 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** also signs report uploads (ReportUploader) */
|
||||||
|
internal fun apiHmacKey(): String = decodeUpdateSecret(BuildConfig.UPDATE_API_HMAC_KEY)
|
||||||
|
|
||||||
|
private fun apiConfigured(): Boolean =
|
||||||
|
UpdateApiClient.isConfigured(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY)
|
||||||
|
|
||||||
|
/* the legacy path needs the bucket URL; without it there is nothing to fall back to */
|
||||||
|
private fun legacyConfigured(): Boolean = BuildConfig.UPDATE_BASE_URL.isNotBlank()
|
||||||
|
|
||||||
|
/** Stable random installation id (no personal data); the server stores only its hash */
|
||||||
|
internal fun installId(): String
|
||||||
|
{
|
||||||
|
mPrefs.getString(PREF_INSTALL_ID, null)?.let { return it }
|
||||||
|
val bytes = ByteArray(18).also { java.security.SecureRandom().nextBytes(it) }
|
||||||
|
val id = android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
|
||||||
|
mPrefs.edit().putString(PREF_INSTALL_ID, id).apply()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callApi(): UpdateApiResult
|
||||||
|
{
|
||||||
|
val client = UpdateApiClient(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY, installId())
|
||||||
|
val abi = Build.SUPPORTED_ABIS.firstOrNull()
|
||||||
|
val result = client.check(context.packageName, currentVersionCode(), mPrefs.getString(PREF_UPDATE_CHANNEL, "release") ?: "release",
|
||||||
|
abi, Build.VERSION.SDK_INT, mPrefs.getString(PREF_BETA_TOKEN, null))
|
||||||
|
val policy = when (result)
|
||||||
|
{
|
||||||
|
is UpdateApiResult.Update -> result.policy
|
||||||
|
is UpdateApiResult.NoUpdate -> result.policy
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
/* server hints are kept for the scheduler; the user's own settings stay in charge */
|
||||||
|
policy?.let { mPrefs.edit().putInt(PREF_API_CHECK_HOURS, it.checkHours).putBoolean(PREF_API_WIFI_ONLY, it.wifiOnly).apply() }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An UpdateInfo that sends check() into the "up to date" branch */
|
||||||
|
private fun upToDateMarker() = UpdateInfo(currentVersionCode(), currentVersionName(), "", "", -1L, emptyMap(), fromApi = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presigned file URL lives ~10 minutes while a download may wait for Wi-Fi or charging:
|
||||||
|
* ask the API again when the manifest is old
|
||||||
|
*/
|
||||||
|
private fun freshen(info: UpdateInfo, force: Boolean = false): UpdateInfo
|
||||||
|
{
|
||||||
|
if (!info.fromApi || info.apkUrl.isBlank()) return info
|
||||||
|
val age = System.currentTimeMillis() / 1000 - info.issuedAt
|
||||||
|
if (!force && age in 0..URL_MAX_AGE_SECONDS) return info
|
||||||
|
val r = callApi()
|
||||||
|
return if (r is UpdateApiResult.Update && r.info.versionCode == info.versionCode && r.info.sha256 == info.sha256) r.info.copy(mandatory = r.mandatory) else info
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
||||||
{
|
{
|
||||||
if (notes == null)
|
if (notes == null)
|
||||||
@@ -606,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadApk(info: UpdateInfo): File
|
private fun downloadApk(original: UpdateInfo, retried: Boolean = false): File
|
||||||
{
|
{
|
||||||
|
val info = freshen(original, force = retried)
|
||||||
val dir = File(context.filesDir, UPDATE_DIR)
|
val dir = File(context.filesDir, UPDATE_DIR)
|
||||||
if (!dir.exists() && !dir.mkdirs())
|
if (!dir.exists() && !dir.mkdirs())
|
||||||
{
|
{
|
||||||
@@ -620,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
apk.delete()
|
apk.delete()
|
||||||
|
|
||||||
val conn = openConnection(info.apkUrl)
|
/* a presigned URL already carries its own signature: adding S3 auth headers makes S3 reject it */
|
||||||
|
val conn = openConnection(info.apkUrl, signS3 = !info.fromApi)
|
||||||
conn.readTimeout = 30_000
|
conn.readTimeout = 30_000
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
|
if (code == HttpURLConnection.HTTP_FORBIDDEN && info.fromApi && !retried)
|
||||||
|
{ /* the link expired while waiting: fetch a fresh one once */
|
||||||
|
conn.disconnect()
|
||||||
|
return downloadApk(original, retried = true)
|
||||||
|
}
|
||||||
if (code != HttpURLConnection.HTTP_OK)
|
if (code != HttpURLConnection.HTTP_OK)
|
||||||
{
|
{
|
||||||
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
||||||
}
|
}
|
||||||
val expected = if (info.size > 0) info.size else -1L
|
val expected = if (info.size > 0) info.size else -1L
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
val buffer = ByteArray(64 * 1024)
|
|
||||||
var downloaded = 0L
|
|
||||||
var lastProgress = -1
|
var lastProgress = -1
|
||||||
|
|
||||||
conn.inputStream.use { input ->
|
mDownloadConn = conn
|
||||||
|
val downloaded = conn.inputStream.use { input ->
|
||||||
FileOutputStream(apk).use { output ->
|
FileOutputStream(apk).use { output ->
|
||||||
while (true)
|
copyDownload(input, output, digest, { mCancelRequested }) { done ->
|
||||||
{
|
|
||||||
val len = input.read(buffer)
|
|
||||||
if (len < 0)
|
|
||||||
{
|
|
||||||
break
|
|
||||||
}
|
|
||||||
output.write(buffer, 0, len)
|
|
||||||
digest.update(buffer, 0, len)
|
|
||||||
downloaded += len
|
|
||||||
if (expected > 0)
|
if (expected > 0)
|
||||||
{
|
{
|
||||||
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
|
val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
|
||||||
if (progress != lastProgress)
|
if (progress != lastProgress)
|
||||||
{
|
{
|
||||||
lastProgress = progress
|
lastProgress = progress
|
||||||
@@ -674,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
mDownloadConn = null
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
cancelDownloadProgress()
|
cancelDownloadProgress()
|
||||||
}
|
}
|
||||||
@@ -771,14 +946,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String): HttpURLConnection
|
private fun openConnection(url: String, signS3: Boolean = true): HttpURLConnection
|
||||||
{
|
{
|
||||||
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
||||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
conn.readTimeout = READ_TIMEOUT_MS
|
conn.readTimeout = READ_TIMEOUT_MS
|
||||||
conn.setRequestProperty("Connection", "close")
|
conn.setRequestProperty("Connection", "close")
|
||||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||||
|
if (signS3)
|
||||||
|
{
|
||||||
applyS3Auth(conn, url)
|
applyS3Auth(conn, url)
|
||||||
|
}
|
||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release notes of the app language: the full tag ("zh-tw"), then the language ("zh"),
|
||||||
|
* then Russian, then English. Keys of [notes] are lowercase (see UpdateManager.parseNotes).
|
||||||
|
*/
|
||||||
|
fun pickNotes(notes: Map<String, String>, languageTag: String, language: String): String?
|
||||||
|
{
|
||||||
|
return listOf(languageTag.lowercase(), language.lowercase(), "ru", "en")
|
||||||
|
.firstNotNullOfOrNull { key -> notes[key]?.takeIf { it.isNotBlank() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every non-empty line of the notes is one list item; a leading "-", "*" or "•" is dropped. */
|
||||||
|
fun noteLines(text: String?): List<String>
|
||||||
|
{
|
||||||
|
return text.orEmpty().lines().map { it.trim().trimStart('-', '*', '•').trim() }.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the download into [output] and the digest; [isCancelled] is checked between reads and
|
||||||
|
* when a read fails (cancelling disconnects the connection, which breaks a blocked read).
|
||||||
|
*/
|
||||||
|
internal fun copyDownload(input: InputStream, output: OutputStream, digest: MessageDigest,
|
||||||
|
isCancelled: () -> Boolean, onProgress: (Long) -> Unit): Long
|
||||||
|
{
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
var total = 0L
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (isCancelled())
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
|
val len = try
|
||||||
|
{
|
||||||
|
input.read(buffer)
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
if (isCancelled()) throw CancellationException("update download cancelled") else throw e
|
||||||
|
}
|
||||||
|
if (len < 0)
|
||||||
|
{
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, len)
|
||||||
|
digest.update(buffer, 0, len)
|
||||||
|
total += len
|
||||||
|
onProgress(total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,14 @@ class InboxActivity : AppCompatActivity()
|
|||||||
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
|
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
|
||||||
EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) }
|
EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) }
|
||||||
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
|
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
|
||||||
|
EventAction.SEND_REPORT -> startActivity(Intent(this, LogActivity::class.java).putExtra(LogActivity.EXTRA_SEND, true))
|
||||||
|
EventAction.OPEN_VPN_SETTINGS -> try
|
||||||
|
{
|
||||||
|
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||||
|
}
|
||||||
|
catch (_: android.content.ActivityNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
|
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -77,6 +81,7 @@ import org.strongswan.android.R
|
|||||||
import org.strongswan.android.data.LogContentProvider
|
import org.strongswan.android.data.LogContentProvider
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.DiagLog
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
@@ -87,18 +92,61 @@ class LogActivity : AppCompatActivity()
|
|||||||
{
|
{
|
||||||
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
|
/* the active profile goes into the report meta */
|
||||||
|
private var vpn: VpnStateService? = null
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
vpn = (binder as VpnStateService.LocalBinder).service
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
vpn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?)
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
{
|
{
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||||
|
/* «Send a report» from the crash event opens the report right away */
|
||||||
|
val autoSend = savedInstanceState == null && intent.getBooleanExtra(EXTRA_SEND, false)
|
||||||
setContent {
|
setContent {
|
||||||
ShxTheme {
|
ShxTheme {
|
||||||
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages)
|
val report: ReportViewModel = viewModel()
|
||||||
|
/* without the panel API in this build the report goes by email, as before */
|
||||||
|
val send = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }
|
||||||
|
LogScreen(onBack = { finish() }, onSend = send, messages = messages)
|
||||||
|
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
|
||||||
|
if (autoSend)
|
||||||
|
{
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) { send() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendLog()
|
companion object
|
||||||
|
{
|
||||||
|
const val EXTRA_SEND = "org.strongswan.android.extra.SEND_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy()
|
||||||
|
{
|
||||||
|
unbindService(connection)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeProfile(): String
|
||||||
|
{
|
||||||
|
val profile = vpn?.profile ?: return ""
|
||||||
|
return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendLog(comment: String)
|
||||||
{
|
{
|
||||||
/* the daemon log plus the lifecycle log; the latter survives process
|
/* the daemon log plus the lifecycle log; the latter survives process
|
||||||
* deaths and documents watchdog/reconnect decisions */
|
* deaths and documents watchdog/reconnect decisions */
|
||||||
@@ -124,6 +172,10 @@ class LogActivity : AppCompatActivity()
|
|||||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
||||||
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||||
intent.type = "text/plain"
|
intent.type = "text/plain"
|
||||||
|
if (comment.isNotBlank())
|
||||||
|
{
|
||||||
|
intent.putExtra(Intent.EXTRA_TEXT, comment)
|
||||||
|
}
|
||||||
if (uris.size > 1)
|
if (uris.size > 1)
|
||||||
{
|
{
|
||||||
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import org.strongswan.android.ui.compose.runDiagnostics
|
||||||
|
import org.strongswan.android.BuildConfig
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
import org.strongswan.android.logic.updates.ReportContent
|
||||||
|
import org.strongswan.android.logic.updates.ReportUploader
|
||||||
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIntro
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/** State of the "Send report" sheet (mockups App8-App10); the upload survives a rotation */
|
||||||
|
class ReportViewModel(app: Application) : AndroidViewModel(app)
|
||||||
|
{
|
||||||
|
sealed class Ui
|
||||||
|
{
|
||||||
|
data object Hidden : Ui()
|
||||||
|
data class Form(val sending: Boolean = false) : Ui()
|
||||||
|
data class Sent(val code: String) : Ui()
|
||||||
|
data class Failed(val failure: ReportContent.Failure) : Ui()
|
||||||
|
}
|
||||||
|
|
||||||
|
var ui by mutableStateOf<Ui>(Ui.Hidden)
|
||||||
|
private set
|
||||||
|
var comment by mutableStateOf("")
|
||||||
|
var attachDiag by mutableStateOf(true)
|
||||||
|
|
||||||
|
/* a swipe may hide the sheet mid-upload: the result then opens it again */
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun open()
|
||||||
|
{
|
||||||
|
ui = Ui.Form(sending = job?.isActive == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
{
|
||||||
|
ui = Ui.Hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(profile: String)
|
||||||
|
{
|
||||||
|
if (job?.isActive == true)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui = Ui.Form(sending = true)
|
||||||
|
val app = getApplication<Application>()
|
||||||
|
val note = comment
|
||||||
|
val diag = attachDiag
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
/* the same checks as the Diagnostics screen (~3 s); a hang must not block the report */
|
||||||
|
val checks = if (!diag) null else withTimeoutOrNull(10_000) {
|
||||||
|
runCatching { runDiagnostics(app) }.getOrNull()?.steps?.map { ReportContent.Check(it.title, it.value, it.ok) }
|
||||||
|
}
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
val charon = ReportContent.todayOnly(
|
||||||
|
ReportContent.readTail(File(app.filesDir, CharonVpnService.LOG_FILE), ReportContent.CHARON_MAX_BYTES * 2),
|
||||||
|
now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH),
|
||||||
|
)
|
||||||
|
val text = ReportContent.build(charon, if (diag) ReportContent.readTail(DiagLog.file(app), ReportContent.DIAG_MAX_BYTES) else null, checks)
|
||||||
|
val meta = ReportContent.meta(
|
||||||
|
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong(), Build.MANUFACTURER, Build.MODEL,
|
||||||
|
Build.VERSION.RELEASE, Build.VERSION.SDK_INT, networkLabel(app), note, profile,
|
||||||
|
)
|
||||||
|
val updates = UpdateManager.getInstance(app)
|
||||||
|
ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta)
|
||||||
|
}
|
||||||
|
when (result)
|
||||||
|
{
|
||||||
|
is ReportUploader.Result.Sent ->
|
||||||
|
{
|
||||||
|
Events.reportSent(app, result.code)
|
||||||
|
comment = ""
|
||||||
|
ui = Ui.Sent(result.code)
|
||||||
|
}
|
||||||
|
is ReportUploader.Result.Failed ->
|
||||||
|
{
|
||||||
|
DiagLog.i("Report", "upload failed: HTTP ${result.httpCode} ${result.detail}")
|
||||||
|
ui = Ui.Failed(ReportContent.failure(result.httpCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** Only the address and the HMAC key are needed (the manifest key is for updates) */
|
||||||
|
fun apiReady(context: Context): Boolean =
|
||||||
|
BuildConfig.UPDATE_API_URL.isNotBlank() && UpdateManager.getInstance(context).apiHmacKey().isNotBlank()
|
||||||
|
|
||||||
|
/** The network under the tunnel: with the VPN up the active network is the VPN itself */
|
||||||
|
private fun networkLabel(context: Context): String
|
||||||
|
{
|
||||||
|
val network = UnderlyingNetwork.resolve(context) ?: return "none"
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none"
|
||||||
|
val caps = try { cm.getNetworkCapabilities(network) } catch (_: Exception) { null } ?: return "none"
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReportSheet(model: ReportViewModel, profile: () -> String, onMail: (String) -> Unit)
|
||||||
|
{
|
||||||
|
val ui = model.ui
|
||||||
|
if (ui == ReportViewModel.Ui.Hidden)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ShxSheet(onDismiss = model::close)
|
||||||
|
{
|
||||||
|
when (ui)
|
||||||
|
{
|
||||||
|
is ReportViewModel.Ui.Form -> ReportForm(model, ui.sending, onSend = { model.send(profile()) }, onMail = { onMail(model.comment) })
|
||||||
|
is ReportViewModel.Ui.Sent -> ReportSent(ui.code, onDone = model::close)
|
||||||
|
is ReportViewModel.Ui.Failed -> ReportFailed(ui.failure, onRetry = model::open, onMail = { onMail(model.comment) })
|
||||||
|
ReportViewModel.Ui.Hidden -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App8 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> Unit, onMail: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
ShxSheetTitle(stringResource(R.string.log_send_report))
|
||||||
|
ShxIntro(stringResource(R.string.report_intro))
|
||||||
|
}
|
||||||
|
ReasonCard(
|
||||||
|
stringResource(R.string.report_contents), c.accent,
|
||||||
|
listOf(R.string.report_item_log, R.string.report_item_device, R.string.report_item_network, R.string.report_item_ips).map { stringResource(it) to null },
|
||||||
|
)
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.report_diag_title), model.attachDiag, { model.attachDiag = it },
|
||||||
|
subtitle = stringResource(R.string.report_diag_sub), enabled = !sending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.report_comment_label),
|
||||||
|
value = model.comment,
|
||||||
|
onValueChange = { model.comment = it.take(1000) },
|
||||||
|
singleLine = false,
|
||||||
|
enabled = !sending,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.report_cancel), model::close, style = ShxButtonStyle.OUTLINE, enabled = !sending, modifier = Modifier.weight(1f))
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(2f))
|
||||||
|
{
|
||||||
|
ShxButton(if (sending) "" else stringResource(R.string.report_send), { if (!sending) onSend() }, modifier = Modifier.fillMaxWidth())
|
||||||
|
if (sending)
|
||||||
|
{
|
||||||
|
CircularProgressIndicator(color = c.onAccent, strokeWidth = 2.5.dp, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sending)
|
||||||
|
{
|
||||||
|
TextLink(stringResource(R.string.report_by_mail), onMail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App9 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportSent(code: String, onDone: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val copied = stringResource(R.string.report_code_copied)
|
||||||
|
ResultHeader(ShxIcons.Check, c.successContainer, c.success, stringResource(R.string.report_sent_title), stringResource(R.string.report_sent_sub))
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp))
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.report_code_label).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Text(code, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, letterSpacing = 0.9.sp), color = c.text)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile).clickable(role = Role.Button) {
|
||||||
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText("report", code))
|
||||||
|
Toast.makeText(context, copied, Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.report_copy_code), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.report_saved_note))
|
||||||
|
ShxButton(stringResource(R.string.report_done), onDone, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App10 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportFailed(failure: ReportContent.Failure, onRetry: () -> Unit, onMail: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val noAnswer = failure == ReportContent.Failure.NO_NETWORK || failure == ReportContent.Failure.SERVER_DOWN
|
||||||
|
ResultHeader(
|
||||||
|
ShxIcons.Alert, c.errorContainer, c.error, stringResource(R.string.report_failed_title),
|
||||||
|
stringResource(if (noAnswer) R.string.report_failed_no_answer else R.string.report_failed_refused),
|
||||||
|
)
|
||||||
|
val reasons = when (failure)
|
||||||
|
{
|
||||||
|
ReportContent.Failure.NO_NETWORK -> listOf(R.string.report_reason_offline to R.string.report_reason_offline_d, R.string.report_reason_server to R.string.report_reason_server_d)
|
||||||
|
ReportContent.Failure.SERVER_DOWN -> listOf(R.string.report_reason_server to R.string.report_reason_server_d)
|
||||||
|
ReportContent.Failure.SIGNATURE -> listOf(R.string.report_reason_clock to R.string.report_reason_clock_d)
|
||||||
|
ReportContent.Failure.TOO_OFTEN -> listOf(R.string.report_reason_often to R.string.report_reason_often_d)
|
||||||
|
ReportContent.Failure.TOO_LARGE -> listOf(R.string.report_reason_large to R.string.report_reason_large_d)
|
||||||
|
ReportContent.Failure.REJECTED -> listOf(R.string.report_reason_rejected to R.string.report_reason_rejected_d)
|
||||||
|
}
|
||||||
|
ReasonCard(stringResource(R.string.report_reasons), c.error, reasons.map { (t, d) -> stringResource(t) to stringResource(d) })
|
||||||
|
ShxNote(stringResource(R.string.report_mail_note))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.report_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.report_by_letter), onMail, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 52dp round icon, 22/800 title, 13 sub (as F_ErrImport) */
|
||||||
|
@Composable
|
||||||
|
private fun ResultHeader(icon: ImageVector, background: Color, tint: Color, title: String, sub: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(background))
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text)
|
||||||
|
Text(sub, style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** White card with a section label and dotted lines: «bold — muted» when [items] carry a second part */
|
||||||
|
@Composable
|
||||||
|
private fun ReasonCard(title: String, dot: Color, items: List<Pair<String, String?>>)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(22.dp)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(title.uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
items.forEach { (head, tail) ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (tail == null)
|
||||||
|
{
|
||||||
|
append(head)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(head) }
|
||||||
|
append(" — ")
|
||||||
|
withStyle(SpanStyle(color = c.textSub)) { append(tail) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextLink(text: String, onClick: () -> Unit)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text, style = ShxType.chip, color = LocalShxColors.current.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
|
|||||||
Box(Modifier.padding(padding)) {
|
Box(Modifier.padding(padding)) {
|
||||||
SettingsScreen(profiles = profiles, showTitle = false)
|
SettingsScreen(profiles = profiles, showTitle = false)
|
||||||
}
|
}
|
||||||
|
org.strongswan.android.ui.compose.UpdateSheetHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolea
|
|||||||
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
|
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||||
val vpn = VpnStateService.getLiveInstance()
|
val vpn = VpnStateService.getLiveInstance()
|
||||||
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
||||||
val gateway = (vpn?.profile)?.gateway
|
val gateway = (vpn?.profile)?.gateway
|
||||||
|
|||||||
+2
-1
@@ -857,7 +857,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
companion object
|
companion object
|
||||||
{
|
{
|
||||||
private const val TRAFFIC_POLL_MS = 1000L
|
private const val TRAFFIC_POLL_MS = 1000L
|
||||||
private const val PROBE_INTERVAL_MS = 10_000L
|
/* 10 s kept the cellular radio out of its idle state for as long as Home was open */
|
||||||
|
private const val PROBE_INTERVAL_MS = 30_000L
|
||||||
private const val EXIT_REFRESH_MS = 60_000L
|
private const val EXIT_REFRESH_MS = 60_000L
|
||||||
private const val EXIT_TIMEOUT_MS = 5000
|
private const val EXIT_TIMEOUT_MS = 5000
|
||||||
private const val FASTEST_TIMEOUT_MS = 1500
|
private const val FASTEST_TIMEOUT_MS = 1500
|
||||||
|
|||||||
+2
@@ -309,5 +309,7 @@ private fun EventAction.labelRes(): Int = when (this)
|
|||||||
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
|
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
|
||||||
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
|
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
|
||||||
EventAction.OPEN_SERVER -> R.string.events_action_open_server
|
EventAction.OPEN_SERVER -> R.string.events_action_open_server
|
||||||
|
EventAction.SEND_REPORT -> R.string.diag_send_report
|
||||||
|
EventAction.OPEN_VPN_SETTINGS -> R.string.home_lockdown_enable
|
||||||
EventAction.NONE -> R.string.events_action_open
|
EventAction.NONE -> R.string.events_action_open
|
||||||
}
|
}
|
||||||
+17
-8
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
|||||||
var result by remember { mutableStateOf<CheckResult?>(null) }
|
var result by remember { mutableStateOf<CheckResult?>(null) }
|
||||||
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||||
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||||
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
|
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
|
||||||
if (mode == RouteCheckMode.APP && value.isEmpty())
|
|
||||||
|
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
|
||||||
|
suspend fun loadApps(): List<InstalledApp>
|
||||||
{
|
{
|
||||||
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
if (apps.isEmpty())
|
||||||
|
{
|
||||||
|
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||||
|
}
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(mode)
|
||||||
|
{
|
||||||
|
if (mode == RouteCheckMode.APP)
|
||||||
|
{
|
||||||
|
loadApps()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
|||||||
{
|
{
|
||||||
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
||||||
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
||||||
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||||
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
||||||
}
|
}
|
||||||
if (result !is CheckResult.BadInput)
|
if (result !is CheckResult.BadInput)
|
||||||
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
|||||||
{
|
{
|
||||||
if (initialQuery.isNotBlank())
|
if (initialQuery.isNotBlank())
|
||||||
{
|
{
|
||||||
if (mode == RouteCheckMode.APP)
|
|
||||||
{
|
|
||||||
delay(300)
|
|
||||||
}
|
|
||||||
run()
|
run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-98
@@ -52,11 +52,12 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.logic.updates.UpdateState
|
|
||||||
import org.strongswan.android.ui.compose.design.ShxButton
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
import org.strongswan.android.ui.compose.design.ShxNote
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
import org.strongswan.android.ui.compose.design.ShxSwitch
|
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||||
@@ -221,91 +222,6 @@ fun KillSwitchPage(lockdown: Boolean)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update card (F_Update): app icon, version, what is new, progress, Later / Install. */
|
|
||||||
@Composable
|
|
||||||
fun UpdateCard(state: UpdateState, installed: String, onCheck: () -> Unit, onInstall: () -> Unit)
|
|
||||||
{
|
|
||||||
val c = LocalShxColors.current
|
|
||||||
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
|
|
||||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
|
||||||
{
|
|
||||||
val shape = RoundedCornerShape(24.dp)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(16.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
|
|
||||||
{
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
|
|
||||||
{
|
|
||||||
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
if (info != null) "SHX VPN ${info.versionName}" else stringResource(R.string.update_card_uptodate),
|
|
||||||
style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
when
|
|
||||||
{
|
|
||||||
info != null -> stringResource(R.string.update_card_have, installed, formatBytes(info.size))
|
|
||||||
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
|
||||||
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
|
|
||||||
else -> stringResource(R.string.update_card_version, installed)
|
|
||||||
},
|
|
||||||
style = ShxType.sub, color = c.textSub,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (info != null)
|
|
||||||
{
|
|
||||||
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull()
|
|
||||||
if (!notes.isNullOrBlank())
|
|
||||||
{
|
|
||||||
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
|
||||||
notes.split(Regex("(?<=[.;])\\s+")).map { it.trim().trimEnd('.', ';') }.filter { it.isNotEmpty() }.forEach { line ->
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
|
||||||
{
|
|
||||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.accent))
|
|
||||||
Text(line, style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state is UpdateState.Downloading)
|
|
||||||
{
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
|
||||||
{
|
|
||||||
Row(Modifier.fillMaxWidth())
|
|
||||||
{
|
|
||||||
Text(stringResource(R.string.update_card_downloading), style = ShxType.meta, color = c.textSub, modifier = Modifier.weight(1f))
|
|
||||||
Text("${state.progress}%", style = ShxType.meta, color = c.textSub)
|
|
||||||
}
|
|
||||||
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
|
|
||||||
{
|
|
||||||
Box(Modifier.fillMaxWidth(state.progress.coerceIn(0, 100) / 100f).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
|
||||||
{
|
|
||||||
if (info != null)
|
|
||||||
{
|
|
||||||
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
|
||||||
ShxButton(stringResource(R.string.update_card_install), onInstall, modifier = Modifier.weight(2f), enabled = state !is UpdateState.Downloading)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), enabled = state !is UpdateState.Checking)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.update_card_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «Help» (F_Help): two big buttons (check the connection, contact support),
|
* «Help» (F_Help): two big buttons (check the connection, contact support),
|
||||||
* the frequent questions as an accordion, and «About the app».
|
* the frequent questions as an accordion, and «About the app».
|
||||||
@@ -566,14 +482,7 @@ internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit)
|
|||||||
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
|
||||||
Text(stringResource(R.string.install_perm_safe), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
{
|
{
|
||||||
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
@@ -613,6 +522,15 @@ internal fun ProtectionPage()
|
|||||||
val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle()
|
val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle()
|
||||||
val state = stateNullable ?: return
|
val state = stateNullable ?: return
|
||||||
var custom by remember { mutableStateOf(state.custom) }
|
var custom by remember { mutableStateOf(state.custom) }
|
||||||
|
/* IPs, or an own DoH/DoT server */
|
||||||
|
fun customValid(text: String) = org.strongswan.android.logic.protection.ProtectionState.parseCustom(text).isNotEmpty() ||
|
||||||
|
org.strongswan.android.logic.protection.SecureDnsConfig.parse(text) != null
|
||||||
|
/* result of the check on «Save» for DoH/DoT: (ok, text) */
|
||||||
|
var probe by remember { mutableStateOf<Pair<Boolean, String>?>(null) }
|
||||||
|
var probing by remember { mutableStateOf(false) }
|
||||||
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
|
val probeOk = stringResource(R.string.prot_secure_ok)
|
||||||
|
val probeFail = stringResource(R.string.prot_secure_fail)
|
||||||
val applies = stringResource(R.string.prot_applies)
|
val applies = stringResource(R.string.prot_applies)
|
||||||
fun update(next: org.strongswan.android.logic.protection.ProtectionState)
|
fun update(next: org.strongswan.android.logic.protection.ProtectionState)
|
||||||
{
|
{
|
||||||
@@ -666,7 +584,7 @@ internal fun ProtectionPage()
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) {
|
modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) {
|
||||||
if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isEmpty())
|
if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && !customValid(custom))
|
||||||
{ /* the field below takes the address first */
|
{ /* the field below takes the address first */
|
||||||
org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice))
|
org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice))
|
||||||
}
|
}
|
||||||
@@ -690,12 +608,33 @@ internal fun ProtectionPage()
|
|||||||
{
|
{
|
||||||
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
{
|
{
|
||||||
org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it }, placeholder = "9.9.9.9")
|
org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it; probe = null }, placeholder = "9.9.9.9")
|
||||||
|
val secure = org.strongswan.android.logic.protection.SecureDnsConfig.parse(custom)
|
||||||
ShxButton(
|
ShxButton(
|
||||||
stringResource(R.string.server_save), { update(state.copy(custom = custom)) },
|
stringResource(R.string.server_save),
|
||||||
|
{
|
||||||
|
if (secure == null) update(state.copy(custom = custom))
|
||||||
|
else
|
||||||
|
{ /* check the server first, so a typo shows here and not as a dead tunnel */
|
||||||
|
probing = true
|
||||||
|
scope.launch {
|
||||||
|
val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { org.strongswan.android.logic.protection.SecureDns.probe(secure) }
|
||||||
|
org.strongswan.android.logic.DiagLog.i("SecureDns", "check on save: ${secure.label} " +
|
||||||
|
if (result.ok) "ok in ${result.ms} ms" else "failed: ${result.error}")
|
||||||
|
probing = false
|
||||||
|
probe = if (result.ok) true to probeOk.format(result.ms) else false to probeFail.format(result.error ?: "")
|
||||||
|
update(state.copy(custom = custom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(),
|
style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isNotEmpty(),
|
enabled = customValid(custom) && !probing,
|
||||||
)
|
)
|
||||||
|
probe?.let { (ok, text) -> ShxNote(text, tone = if (ok) ShxTone.SUCCESS else ShxTone.WARNING) }
|
||||||
|
if (secure != null)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.prot_secure_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-189
@@ -105,8 +105,6 @@ import org.strongswan.android.logic.StrongSwanApplication
|
|||||||
import org.strongswan.android.logic.WatchdogReceiver
|
import org.strongswan.android.logic.WatchdogReceiver
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||||
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
||||||
import org.strongswan.android.logic.updates.UpdateError
|
|
||||||
import org.strongswan.android.logic.updates.UpdateInstaller
|
|
||||||
import org.strongswan.android.logic.updates.UpdateManager
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
import org.strongswan.android.logic.updates.UpdateState
|
import org.strongswan.android.logic.updates.UpdateState
|
||||||
import org.strongswan.android.ui.LogActivity
|
import org.strongswan.android.ui.LogActivity
|
||||||
@@ -151,43 +149,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
|
|
||||||
val updateManager = remember { UpdateManager.getInstance(context) }
|
val updateManager = remember { UpdateManager.getInstance(context) }
|
||||||
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
|
||||||
var showInstallPermission by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (UpdateInstaller.canInstall(context))
|
|
||||||
{
|
|
||||||
updateManager.downloadAndInstall()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val startUpdate: () -> Unit = {
|
|
||||||
if (UpdateInstaller.canInstall(context))
|
|
||||||
{
|
|
||||||
updateManager.downloadAndInstall()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{ /* F_UpdateInstall explains the system page first */
|
|
||||||
showInstallPermission = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showInstallPermission)
|
|
||||||
{
|
|
||||||
InstallPermissionSheet(
|
|
||||||
onOpen = {
|
|
||||||
showInstallPermission = false
|
|
||||||
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
|
||||||
},
|
|
||||||
onDismiss = { showInstallPermission = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
||||||
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
|
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
|
||||||
@@ -419,18 +383,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
|
|
||||||
if (page == SettingsPage.UPDATES)
|
if (page == SettingsPage.UPDATES)
|
||||||
{
|
{
|
||||||
UpdateCard(
|
UpdatesPage()
|
||||||
state = updateState,
|
|
||||||
installed = versionName,
|
|
||||||
onCheck = {
|
|
||||||
when (updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
|
||||||
else -> updateManager.checkNow()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onInstall = { showUpdateConfirm = true },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.KILLSWITCH)
|
if (page == SettingsPage.KILLSWITCH)
|
||||||
@@ -445,75 +398,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
|
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.UPDATES)
|
if (page == SettingsPage.ROOT) Text(
|
||||||
{
|
|
||||||
ShxSection(title = stringResource(R.string.update_settings))
|
|
||||||
{
|
|
||||||
var autoUpdate by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
|
|
||||||
}
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = ShxIcons.Refresh,
|
|
||||||
title = stringResource(R.string.update_auto_check_title),
|
|
||||||
subtitle = stringResource(R.string.update_auto_check_summary),
|
|
||||||
checked = autoUpdate,
|
|
||||||
onCheckedChange = {
|
|
||||||
autoUpdate = it
|
|
||||||
prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
var silentUpdates by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
|
|
||||||
}
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = ShxIcons.Refresh,
|
|
||||||
title = stringResource(R.string.update_silent_title),
|
|
||||||
subtitle = stringResource(R.string.update_silent_summary),
|
|
||||||
checked = silentUpdates,
|
|
||||||
onCheckedChange = {
|
|
||||||
silentUpdates = it
|
|
||||||
prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = ShxIcons.Download,
|
|
||||||
title = stringResource(R.string.update_check_title),
|
|
||||||
subtitle = updateStatusText(updateState),
|
|
||||||
onClick = {
|
|
||||||
when (updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
|
||||||
else -> updateManager.checkNow()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val updateInfo = (updateState as? UpdateState.Available)?.info
|
|
||||||
?: (updateState as? UpdateState.Downloading)?.info
|
|
||||||
if (updateInfo != null)
|
|
||||||
{
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = ShxIcons.Download,
|
|
||||||
title = stringResource(R.string.update_download_install),
|
|
||||||
subtitle = when (val state = updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
|
||||||
else -> stringResource(R.string.update_available_fmt, updateInfo.versionName)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (updateState !is UpdateState.Downloading)
|
|
||||||
{
|
|
||||||
showUpdateConfirm = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
|
|
||||||
text = stringResource(R.string.app_version_fmt, versionName),
|
text = stringResource(R.string.app_version_fmt, versionName),
|
||||||
style = ShxType.meta,
|
style = ShxType.meta,
|
||||||
color = LocalShxColors.current.textSub,
|
color = LocalShxColors.current.textSub,
|
||||||
@@ -600,82 +485,13 @@ 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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether the system-level lockdown ("Block connections without VPN") is
|
* Whether the system-level lockdown ("Block connections without VPN") is on for
|
||||||
* active for our package. Returns false if not determinable.
|
* us, as the VPN service last saw it (false before the first connection).
|
||||||
*/
|
*/
|
||||||
internal fun Context.isVpnLockdownEnabled(): Boolean
|
internal fun Context.isVpnLockdownEnabled(): Boolean = org.strongswan.android.logic.AlwaysOnVpn.isLockdown(this)
|
||||||
{
|
|
||||||
return try
|
|
||||||
{
|
|
||||||
Settings.Global.getInt(
|
|
||||||
contentResolver,
|
|
||||||
"vpn_lockdown_$packageName",
|
|
||||||
0
|
|
||||||
) != 0
|
|
||||||
}
|
|
||||||
catch (_: Exception)
|
|
||||||
{
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the Samsung battery settings where the user can exclude the app from
|
* Opens the Samsung battery settings where the user can exclude the app from
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
{
|
{
|
||||||
AddServerSheet(onDismiss = { addSheetOpen = false })
|
AddServerSheet(onDismiss = { addSheetOpen = false })
|
||||||
}
|
}
|
||||||
|
/* update sheets, the mandatory update included, over every tab */
|
||||||
|
UpdateSheetHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+658
@@ -0,0 +1,658 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.strongswan.android.BuildConfig
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.updates.UpdateError
|
||||||
|
import org.strongswan.android.logic.updates.UpdateInfo
|
||||||
|
import org.strongswan.android.logic.updates.UpdateInstaller
|
||||||
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
|
import org.strongswan.android.logic.updates.UpdateState
|
||||||
|
import org.strongswan.android.logic.updates.noteLines
|
||||||
|
import org.strongswan.android.logic.updates.pickNotes
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxChoiceCard
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIntro
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
|
||||||
|
/** Which update sheet the user opened: «What is new» (App2) or the install attempt (App3 / App5). */
|
||||||
|
enum class UpdateSheet { REVIEW, ATTEMPT }
|
||||||
|
|
||||||
|
/** Shared between the Updates page and the one sheet host of the activity. */
|
||||||
|
object UpdateSheets
|
||||||
|
{
|
||||||
|
val shown = MutableStateFlow<UpdateSheet?>(null)
|
||||||
|
|
||||||
|
fun show(sheet: UpdateSheet?)
|
||||||
|
{
|
||||||
|
shown.value = sheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All update sheets of an activity (ShxApp and SettingsActivity call it once):
|
||||||
|
* «What is new» (App2), the download (App3), the mandatory update (App4),
|
||||||
|
* the failure (App5) and the install permission (App6).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateSheetHost()
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val manager = remember { UpdateManager.getInstance(context) }
|
||||||
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
|
val sheet by UpdateSheets.shown.collectAsStateWithLifecycle()
|
||||||
|
val mandatory = remember(state) { manager.pendingMandatory() }
|
||||||
|
var askPermission by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val install = {
|
||||||
|
UpdateSheets.show(UpdateSheet.ATTEMPT)
|
||||||
|
manager.downloadAndInstall()
|
||||||
|
}
|
||||||
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult())
|
||||||
|
{
|
||||||
|
if (UpdateInstaller.canInstall(context)) install()
|
||||||
|
else Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
val start: () -> Unit = {
|
||||||
|
if (UpdateInstaller.canInstall(context)) install() else askPermission = true
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit)
|
||||||
|
{ /* a mandatory update found earlier (e.g. before a restart): make sure it is still current */
|
||||||
|
if (manager.pendingMandatory() != null && state !is UpdateState.Available && state !is UpdateState.Downloading)
|
||||||
|
{
|
||||||
|
manager.checkNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state)
|
||||||
|
{ /* the installer took over, or the download was cancelled */
|
||||||
|
if (sheet == UpdateSheet.ATTEMPT && (state is UpdateState.Idle || state is UpdateState.UpToDate))
|
||||||
|
{
|
||||||
|
UpdateSheets.show(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when
|
||||||
|
{
|
||||||
|
askPermission -> InstallPermissionSheet(
|
||||||
|
onOpen = {
|
||||||
|
askPermission = false
|
||||||
|
launcher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
||||||
|
},
|
||||||
|
onDismiss = { askPermission = false },
|
||||||
|
)
|
||||||
|
sheet == UpdateSheet.ATTEMPT -> ShxSheet(onDismiss = { UpdateSheets.show(null) })
|
||||||
|
{
|
||||||
|
when (val s = state)
|
||||||
|
{
|
||||||
|
is UpdateState.Failed -> UpdateFailedContent(s, onRetry = start)
|
||||||
|
else -> UpdateProgressContent(s, onCancel = { manager.cancelDownload() }, onInstall = start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mandatory != null -> MandatoryUpdateSheet(mandatory.first, mandatory.second, onUpdate = start)
|
||||||
|
sheet == UpdateSheet.REVIEW ->
|
||||||
|
{
|
||||||
|
val info = (state as? UpdateState.Available)?.info
|
||||||
|
if (info != null) UpdateReviewSheet(info, onInstall = start, onDismiss = { UpdateSheets.show(null) })
|
||||||
|
else androidx.compose.runtime.SideEffect { UpdateSheets.show(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Updates» page (App1): the available version, automatic mode, channel, safety note, manual check. */
|
||||||
|
@Composable
|
||||||
|
fun UpdatesPage()
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val manager = remember { UpdateManager.getInstance(context) }
|
||||||
|
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
|
||||||
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
|
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
|
||||||
|
val busy = state is UpdateState.Checking || state is UpdateState.Downloading
|
||||||
|
var beta by remember { mutableStateOf(manager.isBetaChannel()) }
|
||||||
|
var askCode by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(Modifier.padding(horizontal = ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
||||||
|
{
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accentContainer).padding(ShxSpacing.lg),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Refresh, contentDescription = null, tint = c.onAccent, modifier = Modifier.size(26.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
when
|
||||||
|
{
|
||||||
|
info != null -> stringResource(R.string.update_available_fmt, info.versionName)
|
||||||
|
state is UpdateState.UpToDate -> stringResource(R.string.update_card_uptodate)
|
||||||
|
else -> "SHX VPN ${BuildConfig.VERSION_NAME}"
|
||||||
|
},
|
||||||
|
style = ShxType.cardHeading, color = c.onAccentContainerStrong,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
when
|
||||||
|
{
|
||||||
|
info != null -> stringResource(R.string.updates_have_size, BuildConfig.VERSION_NAME, sizeText(info.size))
|
||||||
|
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||||
|
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
|
||||||
|
state is UpdateState.UpToDate -> stringResource(R.string.update_card_version, BuildConfig.VERSION_NAME)
|
||||||
|
else -> stringResource(R.string.update_status_idle)
|
||||||
|
},
|
||||||
|
style = ShxType.sub, color = c.onAccentContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info != null)
|
||||||
|
{
|
||||||
|
val downloading = state as? UpdateState.Downloading
|
||||||
|
ShxButton(
|
||||||
|
if (downloading != null) stringResource(R.string.update_downloading_fmt, downloading.progress) else stringResource(R.string.updates_review),
|
||||||
|
{ UpdateSheets.show(if (downloading != null) UpdateSheet.ATTEMPT else UpdateSheet.REVIEW) },
|
||||||
|
height = 52.dp, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.updates_section_auto))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
var autoCheck by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) }
|
||||||
|
var silent by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) }
|
||||||
|
val hours = remember { manager.checkIntervalHours().toInt() }
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.updates_auto_check), autoCheck,
|
||||||
|
{ autoCheck = it; prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() },
|
||||||
|
subtitle = pluralStringResource(R.plurals.updates_auto_check_sub, hours, hours), divider = true,
|
||||||
|
)
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.updates_silent), silent,
|
||||||
|
{ silent = it; prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() },
|
||||||
|
subtitle = stringResource(R.string.updates_silent_sub),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.updates_section_channel))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxChoiceCard(
|
||||||
|
stringResource(R.string.updates_channel_stable), stringResource(R.string.updates_channel_stable_sub), !beta,
|
||||||
|
{
|
||||||
|
if (beta)
|
||||||
|
{
|
||||||
|
beta = false
|
||||||
|
manager.setChannel(beta = false, token = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ShxChoiceCard(
|
||||||
|
stringResource(R.string.updates_channel_beta), stringResource(R.string.updates_channel_beta_sub), beta,
|
||||||
|
{ if (!beta) askCode = true },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.updates_channel_hint), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
|
||||||
|
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||||
|
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.updates_check_now), { manager.checkNow() },
|
||||||
|
style = ShxButtonStyle.SOFT, height = 52.dp, enabled = !busy, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
val last = remember(state) { prefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) }
|
||||||
|
Text(
|
||||||
|
when (val s = state)
|
||||||
|
{
|
||||||
|
is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||||
|
is UpdateState.Failed -> stringResource(updateErrorText(s.error))
|
||||||
|
else -> if (last > 0) stringResource(R.string.updates_last_check, lastCheckText(context, last))
|
||||||
|
else stringResource(R.string.updates_never_checked)
|
||||||
|
},
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub,
|
||||||
|
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (askCode)
|
||||||
|
{
|
||||||
|
BetaCodeSheet(
|
||||||
|
onEnable = { code ->
|
||||||
|
askCode = false
|
||||||
|
beta = true
|
||||||
|
manager.setChannel(beta = true, token = code)
|
||||||
|
},
|
||||||
|
onDismiss = { askCode = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invitation code of the beta channel. */
|
||||||
|
@Composable
|
||||||
|
private fun BetaCodeSheet(onEnable: (String) -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
ShxSheetTitle(stringResource(R.string.updates_beta_title))
|
||||||
|
ShxIntro(stringResource(R.string.updates_beta_text))
|
||||||
|
ShxFormField(label = stringResource(R.string.updates_beta_code), value = code, onValueChange = { code = it }, mono = true)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.updates_beta_enable), { onEnable(code.trim()) }, enabled = code.isNotBlank(), modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «A new version is available» (App2): version, what is new from the release notes, Later / Download and install. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateReviewSheet(info: UpdateInfo, onInstall: () -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val locale = LocalConfiguration.current.locales[0]
|
||||||
|
val lines = noteLines(pickNotes(info.notes, "${locale.language}-${locale.country}", locale.language))
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
UpdateSheetHeader(
|
||||||
|
"SHX VPN ${info.versionName}",
|
||||||
|
stringResource(R.string.updates_review_sub, BuildConfig.VERSION_NAME, sizeText(info.size)),
|
||||||
|
)
|
||||||
|
if (lines.isNotEmpty())
|
||||||
|
{
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(ShxSpacing.lg))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section, color = c.accentText)
|
||||||
|
lines.forEach { Bullet(it, c.accent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdateSafeNote(stringResource(R.string.updates_review_safe))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.cert_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.update_download_install), onInstall, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download (App3): progress and the four steps of the update. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateProgressContent(state: UpdateState, onCancel: () -> Unit, onInstall: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val info = (state as? UpdateState.Downloading)?.info ?: (state as? UpdateState.Available)?.info
|
||||||
|
val progress = (state as? UpdateState.Downloading)?.progress ?: 0
|
||||||
|
val downloading = state is UpdateState.Downloading
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_downloading_title, info?.versionName ?: ""),
|
||||||
|
stringResource(R.string.updates_downloading_sub),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Row(Modifier.fillMaxWidth())
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.update_card_downloading), style = ShxType.chip, color = c.textSoft, modifier = Modifier.weight(1f))
|
||||||
|
if (info != null && info.size > 0)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.updates_progress, progress, sizeText(info.size * progress / 100), sizeText(info.size)),
|
||||||
|
style = ShxType.chip, color = c.textSoft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.cardBorder))
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth(progress / 100f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* steps: the list is known once a version is shown; at 100% the file is being checked */
|
||||||
|
val verifying = downloading && progress >= 100
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 6.dp))
|
||||||
|
{
|
||||||
|
UpdateStep(StepState.DONE, stringResource(R.string.updates_step_list), stringResource(R.string.updates_step_list_sub), divider = true)
|
||||||
|
UpdateStep(
|
||||||
|
if (verifying) StepState.DONE else StepState.RUNNING, stringResource(R.string.updates_step_download),
|
||||||
|
if (info != null && info.size > 0) sizeText(info.size) else stringResource(R.string.update_card_downloading), divider = true,
|
||||||
|
)
|
||||||
|
UpdateStep(if (verifying) StepState.RUNNING else StepState.WAITING, stringResource(R.string.updates_step_verify), stringResource(R.string.updates_step_verify_sub), divider = true)
|
||||||
|
UpdateStep(StepState.WAITING, stringResource(R.string.updates_step_install), stringResource(R.string.updates_step_install_sub), divider = false)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.updates_cancel), onCancel, style = ShxButtonStyle.OUTLINE, enabled = downloading, modifier = Modifier.weight(1f))
|
||||||
|
/* while downloading the app installs on its own; Available here means a background download
|
||||||
|
* finished or failed without an install request, so the button starts it */
|
||||||
|
ShxButton(stringResource(R.string.update_card_install), onInstall, enabled = state is UpdateState.Available, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class StepState { DONE, RUNNING, WAITING }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UpdateStep(step: StepState, title: String, sub: String, divider: Boolean)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column()
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = ShxSpacing.md),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val shape = CircleShape
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(24.dp).clip(shape).then(
|
||||||
|
when (step)
|
||||||
|
{
|
||||||
|
StepState.DONE -> Modifier.background(c.success)
|
||||||
|
StepState.RUNNING -> Modifier.background(c.accentContainer).border(2.dp, c.accent, shape)
|
||||||
|
StepState.WAITING -> Modifier.background(c.card).border(1.dp, c.outline, shape)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
when (step)
|
||||||
|
{
|
||||||
|
StepState.DONE -> Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||||
|
StepState.RUNNING -> Text("…", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||||
|
StepState.WAITING -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (step == StepState.WAITING) c.iconMuted else c.text)
|
||||||
|
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mandatory update (App4): cannot be closed; update now or leave the app. */
|
||||||
|
@Composable
|
||||||
|
private fun MandatoryUpdateSheet(versionName: String, size: Long, onUpdate: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
ShxSheet(onDismiss = {}, dismissible = false)
|
||||||
|
{
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_mandatory_title),
|
||||||
|
stringResource(R.string.updates_mandatory_sub, BuildConfig.VERSION_NAME),
|
||||||
|
icon = ShxIcons.Alert, iconBackground = c.warningContainer, iconTint = c.onWarningContainer, round = true,
|
||||||
|
)
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.updates_mandatory_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Bullet(stringResource(R.string.updates_mandatory_reason), c.warning)
|
||||||
|
Bullet(stringResource(R.string.updates_mandatory_install, versionName, sizeText(size)), c.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.updates_mandatory_locked), tone = ShxTone.WARNING)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.cert_update_now), onUpdate, modifier = Modifier.fillMaxWidth())
|
||||||
|
ShxButton(stringResource(R.string.updates_exit), { activity?.finishAndRemoveTask() }, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update not installed (App5): reasons by error kind, retry, the download page, details for support. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateFailedContent(state: UpdateState.Failed, onRetry: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val checkFailed = state.error == UpdateError.HASH || state.error == UpdateError.SIGNATURE
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_failed_title),
|
||||||
|
stringResource(
|
||||||
|
when (state.error)
|
||||||
|
{
|
||||||
|
UpdateError.HASH, UpdateError.SIGNATURE -> R.string.updates_failed_sub_check
|
||||||
|
UpdateError.NETWORK -> R.string.updates_failed_sub_network
|
||||||
|
UpdateError.INSTALL -> R.string.updates_failed_sub_install
|
||||||
|
UpdateError.OTHER -> R.string.updates_failed_sub_other
|
||||||
|
}
|
||||||
|
),
|
||||||
|
icon = ShxIcons.Shield, iconBackground = c.errorContainer, iconTint = c.error, round = true,
|
||||||
|
)
|
||||||
|
val (title, detail) = when (state.error)
|
||||||
|
{
|
||||||
|
UpdateError.HASH -> R.string.updates_reason_hash to R.string.updates_reason_hash_d
|
||||||
|
UpdateError.SIGNATURE -> R.string.updates_reason_signature to R.string.updates_reason_signature_d
|
||||||
|
UpdateError.NETWORK -> R.string.updates_reason_network to R.string.updates_reason_network_d
|
||||||
|
UpdateError.INSTALL -> R.string.updates_reason_install to R.string.updates_reason_install_d
|
||||||
|
UpdateError.OTHER -> R.string.updates_reason_other to R.string.updates_reason_other_d
|
||||||
|
}
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.updates_failed_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Reason(stringResource(title), stringResource(detail), if (checkFailed) c.error else c.accent)
|
||||||
|
Reason(stringResource(R.string.updates_reason_same), stringResource(R.string.updates_reason_same_d, BuildConfig.VERSION_NAME), c.accent)
|
||||||
|
Reason(stringResource(R.string.updates_reason_later), stringResource(R.string.updates_reason_later_d), c.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.updates_manual_note))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.updates_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.updates_from_site),
|
||||||
|
{ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.UPDATE_DOWNLOAD_PAGE_URL))) } },
|
||||||
|
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.updates_support),
|
||||||
|
style = ShxType.chip, color = c.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(role = Role.Button) { copySupportDetails(context, state) }.padding(horizontal = ShxSpacing.sm, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error kind, message and versions for support; the beta code stays out. */
|
||||||
|
private fun copySupportDetails(context: Context, state: UpdateState.Failed)
|
||||||
|
{
|
||||||
|
val manager = UpdateManager.getInstance(context)
|
||||||
|
val text = buildString {
|
||||||
|
appendLine("SHX VPN update error: ${state.error}")
|
||||||
|
appendLine("Detail: ${state.detail ?: "-"}")
|
||||||
|
appendLine("Installed: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||||
|
appendLine("Channel: ${if (manager.isBetaChannel()) "beta" else "release"}")
|
||||||
|
appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}, ${Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}")
|
||||||
|
}
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("SHX VPN", text))
|
||||||
|
/* Android 13+ confirms the copy itself */
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||||
|
{
|
||||||
|
Toast.makeText(context, R.string.updates_copied, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icon well, sheet title and subtitle of the update sheets. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateSheetHeader(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
iconBackground: Color = LocalShxColors.current.accent,
|
||||||
|
iconTint: Color = Color.White,
|
||||||
|
round: Boolean = false,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val size = if (round) 52.dp else 56.dp
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(if (round) CircleShape else RoundedCornerShape(16.dp)).background(iconBackground))
|
||||||
|
{
|
||||||
|
if (icon != null) Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp))
|
||||||
|
else Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.pageTitle, color = c.text)
|
||||||
|
Text(subtitle, style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green «safe» box with a shield (App1, App2, App6). */
|
||||||
|
@Composable
|
||||||
|
internal fun UpdateSafeNote(text: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = ShxSpacing.md),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||||
|
Text(text, style = ShxType.note, color = c.onSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bullet(text: String, dot: Color)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Title — detail» line of App5. */
|
||||||
|
@Composable
|
||||||
|
private fun Reason(title: String, detail: String, dot: Color)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = c.text)) { append(title) }
|
||||||
|
append(" — ")
|
||||||
|
withStyle(SpanStyle(color = c.textSoft)) { append(detail) }
|
||||||
|
},
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sizeText(bytes: Long): String = if (bytes > 0) formatBytes(bytes) else "—"
|
||||||
|
|
||||||
|
private fun lastCheckText(context: Context, time: Long): String =
|
||||||
|
DateUtils.getRelativeDateTimeString(context, time, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
|
||||||
|
|
||||||
|
internal fun updateErrorText(error: UpdateError): Int = when (error)
|
||||||
|
{
|
||||||
|
UpdateError.NETWORK -> R.string.update_error_network
|
||||||
|
UpdateError.HASH -> R.string.update_error_hash
|
||||||
|
UpdateError.SIGNATURE -> R.string.update_error_signature
|
||||||
|
UpdateError.INSTALL -> R.string.update_error_install
|
||||||
|
UpdateError.OTHER -> R.string.update_error_generic
|
||||||
|
}
|
||||||
+22
-6
@@ -331,7 +331,7 @@ fun ShxButton(
|
|||||||
.background(if (enabled) bg else bg.copy(alpha = 0.38f))
|
.background(if (enabled) bg else bg.copy(alpha = 0.38f))
|
||||||
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
||||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
.padding(horizontal = if (height >= 44.dp) 16.dp else 14.dp),
|
.padding(horizontal = if (height >= 44.dp) 10.dp else 14.dp),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (icon != null)
|
if (icon != null)
|
||||||
@@ -339,7 +339,17 @@ fun ShxButton(
|
|||||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
|
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
Text(text, style = textStyle, color = if (enabled) fg else fg.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
/* two lines on large buttons: on 320dp screens «Скачать и установить» beside «Позже» was cut to «Скачать и установ…» */
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = textStyle,
|
||||||
|
color = if (enabled) fg else fg.copy(alpha = 0.6f),
|
||||||
|
/* a single word must not wrap mid-word («Отмени / ть»): it shrinks instead */
|
||||||
|
maxLines = if (height >= 52.dp && text.trim().contains(' ')) 2 else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = textStyle.fontSize),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,14 +565,18 @@ fun ShxTag(text: String, background: Color, color: Color)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bottom sheet of the mockup: radius 32, 36x4 handle, page background. */
|
/**
|
||||||
|
* Bottom sheet of the mockup: radius 32, 36x4 handle, page background.
|
||||||
|
* [dismissible] = false: swipe, scrim tap and «Back» do not close it (a mandatory update).
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit)
|
||||||
{
|
{
|
||||||
val c = LocalShxColors.current
|
val c = LocalShxColors.current
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (dismissible) onDismiss() },
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }),
|
||||||
|
properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible),
|
||||||
containerColor = c.background,
|
containerColor = c.background,
|
||||||
scrimColor = c.scrim,
|
scrimColor = c.scrim,
|
||||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||||
@@ -571,6 +585,8 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
/* belt and braces for a mandatory sheet: «Back» is swallowed even if the dialog forwards it */
|
||||||
|
androidx.activity.compose.BackHandler(enabled = !dismissible) {}
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||||
|
|||||||
+95
-20
@@ -35,6 +35,21 @@
|
|||||||
|
|
||||||
typedef struct private_android_dns_proxy_t private_android_dns_proxy_t;
|
typedef struct private_android_dns_proxy_t private_android_dns_proxy_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted DNS redirect: the virtual address (network order) and the local
|
||||||
|
* resolver port (0 = off). Written by JNI, read on the TUN reader and
|
||||||
|
* watcher threads; the port is stored last and read first.
|
||||||
|
*/
|
||||||
|
static uint32_t redirect_vip;
|
||||||
|
static uint16_t redirect_port;
|
||||||
|
|
||||||
|
void android_dns_proxy_set_redirect(uint32_t vip, uint16_t port)
|
||||||
|
{
|
||||||
|
__atomic_store_n(&redirect_port, 0, __ATOMIC_RELEASE);
|
||||||
|
__atomic_store_n(&redirect_vip, vip, __ATOMIC_RELEASE);
|
||||||
|
__atomic_store_n(&redirect_port, port, __ATOMIC_RELEASE);
|
||||||
|
}
|
||||||
|
|
||||||
struct private_android_dns_proxy_t {
|
struct private_android_dns_proxy_t {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,6 +188,19 @@ CALLBACK(handle_response, bool,
|
|||||||
DBG1(DBG_NET, "failed to parse source address");
|
DBG1(DBG_NET, "failed to parse source address");
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
if (addr.ss_family == AF_INET &&
|
||||||
|
((struct sockaddr_in*)&addr)->sin_addr.s_addr == htonl(INADDR_LOOPBACK))
|
||||||
|
{ /* the app's encrypted DNS resolver: the reply must come from the
|
||||||
|
* address the query went to, or the system resolver drops it */
|
||||||
|
uint32_t vip = __atomic_load_n(&redirect_vip, __ATOMIC_ACQUIRE);
|
||||||
|
|
||||||
|
src->destroy(src);
|
||||||
|
src = host_create_from_chunk(AF_INET, chunk_from_thing(vip), 53);
|
||||||
|
if (!src)
|
||||||
|
{
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
packet = ip_packet_create_udp_from_data(src, this->src,
|
packet = ip_packet_create_udp_from_data(src, this->src,
|
||||||
chunk_create(buf, len));
|
chunk_create(buf, len));
|
||||||
src->destroy(src);
|
src->destroy(src);
|
||||||
@@ -302,29 +330,16 @@ static bool check_hostname(private_android_dns_proxy_t *this, chunk_t data)
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
METHOD(android_dns_proxy_t, handle, bool,
|
/**
|
||||||
private_android_dns_proxy_t *this, ip_packet_t *packet)
|
* Send the DNS payload of packet to dst through the per-source socket; takes
|
||||||
|
* the packet over on success.
|
||||||
|
*/
|
||||||
|
static bool forward(private_android_dns_proxy_t *this, ip_packet_t *packet,
|
||||||
|
chunk_t data, host_t *dst)
|
||||||
{
|
{
|
||||||
proxy_socket_t *skt;
|
proxy_socket_t *skt;
|
||||||
host_t *dst, *src;
|
host_t *src;
|
||||||
chunk_t data;
|
|
||||||
|
|
||||||
if (packet->get_next_header(packet) != IPPROTO_UDP)
|
|
||||||
{
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
dst = packet->get_destination(packet);
|
|
||||||
if (dst->get_port(dst) != 53)
|
|
||||||
{ /* no DNS packet */
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
data = packet->get_payload(packet);
|
|
||||||
/* remove UDP header */
|
|
||||||
data = chunk_skip(data, 8);
|
|
||||||
if (!check_hostname(this, data))
|
|
||||||
{
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
src = packet->get_source(packet);
|
src = packet->get_source(packet);
|
||||||
this->lock->write_lock(this->lock);
|
this->lock->write_lock(this->lock);
|
||||||
skt = this->sockets->get(this->sockets, src);
|
skt = this->sockets->get(this->sockets, src);
|
||||||
@@ -354,6 +369,65 @@ METHOD(android_dns_proxy_t, handle, bool,
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
METHOD(android_dns_proxy_t, handle, bool,
|
||||||
|
private_android_dns_proxy_t *this, ip_packet_t *packet)
|
||||||
|
{
|
||||||
|
host_t *dst;
|
||||||
|
chunk_t data;
|
||||||
|
|
||||||
|
if (packet->get_next_header(packet) != IPPROTO_UDP)
|
||||||
|
{
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
dst = packet->get_destination(packet);
|
||||||
|
if (dst->get_port(dst) != 53)
|
||||||
|
{ /* no DNS packet */
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
data = packet->get_payload(packet);
|
||||||
|
/* remove UDP header */
|
||||||
|
data = chunk_skip(data, 8);
|
||||||
|
if (!check_hostname(this, data))
|
||||||
|
{
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
return forward(this, packet, data, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
METHOD(android_dns_proxy_t, redirect, bool,
|
||||||
|
private_android_dns_proxy_t *this, ip_packet_t *packet)
|
||||||
|
{
|
||||||
|
uint16_t port = __atomic_load_n(&redirect_port, __ATOMIC_ACQUIRE);
|
||||||
|
uint32_t vip;
|
||||||
|
host_t *dst, *local;
|
||||||
|
chunk_t addr;
|
||||||
|
|
||||||
|
if (!port)
|
||||||
|
{ /* the common case: encrypted DNS is off */
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
if (packet->get_version(packet) != 4 ||
|
||||||
|
packet->get_next_header(packet) != IPPROTO_UDP)
|
||||||
|
{
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
vip = __atomic_load_n(&redirect_vip, __ATOMIC_ACQUIRE);
|
||||||
|
dst = packet->get_destination(packet);
|
||||||
|
addr = dst->get_address(dst);
|
||||||
|
if (dst->get_port(dst) != 53 || addr.len != 4 || !memeq(addr.ptr, &vip, 4))
|
||||||
|
{
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
local = host_create_from_string("127.0.0.1", port);
|
||||||
|
if (!local || !forward(this, packet,
|
||||||
|
chunk_skip(packet->get_payload(packet), 8), local))
|
||||||
|
{ /* the virtual address leads nowhere else: drop, do not tunnel it */
|
||||||
|
packet->destroy(packet);
|
||||||
|
}
|
||||||
|
DESTROY_IF(local);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
METHOD(android_dns_proxy_t, register_cb, void,
|
METHOD(android_dns_proxy_t, register_cb, void,
|
||||||
private_android_dns_proxy_t *this, dns_proxy_response_cb_t cb, void *data)
|
private_android_dns_proxy_t *this, dns_proxy_response_cb_t cb, void *data)
|
||||||
{
|
{
|
||||||
@@ -408,6 +482,7 @@ android_dns_proxy_t *android_dns_proxy_create()
|
|||||||
.register_cb = _register_cb,
|
.register_cb = _register_cb,
|
||||||
.unregister_cb = _unregister_cb,
|
.unregister_cb = _unregister_cb,
|
||||||
.add_hostname = _add_hostname,
|
.add_hostname = _add_hostname,
|
||||||
|
.redirect = _redirect,
|
||||||
.destroy = _destroy,
|
.destroy = _destroy,
|
||||||
},
|
},
|
||||||
.sockets = hashtable_create((hashtable_hash_t)socket_hash,
|
.sockets = hashtable_create((hashtable_hash_t)socket_hash,
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ struct android_dns_proxy_t {
|
|||||||
*/
|
*/
|
||||||
void (*add_hostname)(android_dns_proxy_t *this, char *hostname);
|
void (*add_hostname)(android_dns_proxy_t *this, char *hostname);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward a UDP/53 packet for the encrypted DNS address (see
|
||||||
|
* android_dns_proxy_set_redirect()) to the app's local resolver.
|
||||||
|
*
|
||||||
|
* @param packet packet to handle
|
||||||
|
* @return TRUE if handled (taken over), FALSE otherwise
|
||||||
|
*/
|
||||||
|
bool (*redirect)(android_dns_proxy_t *this, ip_packet_t *packet);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroy an instance.
|
* Destroy an instance.
|
||||||
*/
|
*/
|
||||||
@@ -83,5 +92,15 @@ struct android_dns_proxy_t {
|
|||||||
*/
|
*/
|
||||||
android_dns_proxy_t *android_dns_proxy_create();
|
android_dns_proxy_t *android_dns_proxy_create();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted DNS (DoH/DoT): UDP/53 queries to the IPv4 address vip go to
|
||||||
|
* 127.0.0.1:port, where the app resolves them; port 0 turns it off. Global,
|
||||||
|
* as the proxy instance is re-created with every connection.
|
||||||
|
*
|
||||||
|
* @param vip IPv4 address in network order
|
||||||
|
* @param port local resolver port, 0 to disable
|
||||||
|
*/
|
||||||
|
void android_dns_proxy_set_redirect(uint32_t vip, uint16_t port);
|
||||||
|
|
||||||
#endif /** ANDROID_DNS_PROXY_H_ @}*/
|
#endif /** ANDROID_DNS_PROXY_H_ @}*/
|
||||||
|
|
||||||
@@ -257,6 +257,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
|||||||
{
|
{
|
||||||
/* handled by the DNS proxy */
|
/* handled by the DNS proxy */
|
||||||
}
|
}
|
||||||
|
else if (this->dns_proxy->redirect(this->dns_proxy, packet))
|
||||||
|
{
|
||||||
|
/* encrypted DNS: answered by the app's local resolver */
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
direct_engine_t *engine = direct_engine_get();
|
direct_engine_t *engine = direct_engine_get();
|
||||||
@@ -591,6 +595,10 @@ METHOD(listener_t, ike_reestablish_pre, bool,
|
|||||||
{
|
{
|
||||||
if (this->ike_sa == old)
|
if (this->ike_sa == old)
|
||||||
{
|
{
|
||||||
|
if (old->has_condition(old, COND_REAUTHENTICATING))
|
||||||
|
{ /* planned reauthentication, not a drop */
|
||||||
|
charonservice->update_status(charonservice, CHARONSERVICE_REAUTH);
|
||||||
|
}
|
||||||
/* enable DNS proxy so hosts are properly resolved while the TUN device
|
/* enable DNS proxy so hosts are properly resolved while the TUN device
|
||||||
* is still active */
|
* is still active */
|
||||||
this->lock->write_lock(this->lock);
|
this->lock->write_lock(this->lock);
|
||||||
@@ -674,6 +682,12 @@ METHOD(listener_t, alert, bool,
|
|||||||
{
|
{
|
||||||
switch (alert)
|
switch (alert)
|
||||||
{
|
{
|
||||||
|
case ALERT_RETRANSMIT_SEND_TIMEOUT:
|
||||||
|
/* fires before the reestablish that reports CHILD_STATE_DOWN;
|
||||||
|
* break, not FALSE: returning FALSE unregisters the listener */
|
||||||
|
charonservice->update_status(charonservice,
|
||||||
|
CHARONSERVICE_RETRANSMIT_TIMEOUT);
|
||||||
|
break;
|
||||||
case ALERT_PEER_ADDR_FAILED:
|
case ALERT_PEER_ADDR_FAILED:
|
||||||
charonservice->update_status(charonservice,
|
charonservice->update_status(charonservice,
|
||||||
CHARONSERVICE_LOOKUP_ERROR);
|
CHARONSERVICE_LOOKUP_ERROR);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
#include "android_jni.h"
|
#include "android_jni.h"
|
||||||
#include "backend/android_attr.h"
|
#include "backend/android_attr.h"
|
||||||
#include "backend/android_creds.h"
|
#include "backend/android_creds.h"
|
||||||
|
#include "backend/android_dns_proxy.h"
|
||||||
#include "backend/android_fetcher.h"
|
#include "backend/android_fetcher.h"
|
||||||
#include "backend/android_private_key.h"
|
#include "backend/android_private_key.h"
|
||||||
#include "backend/android_scheduler.h"
|
#include "backend/android_scheduler.h"
|
||||||
@@ -1320,6 +1321,28 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
|
|||||||
pthread_mutex_unlock(&charon_mutex);
|
pthread_mutex_unlock(&charon_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted DNS (DoH/DoT): UDP/53 queries to vip (4 bytes, IPv4) go to the
|
||||||
|
* app's resolver on 127.0.0.1:port. A null vip or port 0 turns it off. Only
|
||||||
|
* stores two globals, so it is safe at any time; pushed on every connect.
|
||||||
|
*/
|
||||||
|
JNI_METHOD(CharonVpnService, setDnsRedirect, void,
|
||||||
|
jbyteArray jvip, jint port)
|
||||||
|
{
|
||||||
|
uint32_t vip = 0;
|
||||||
|
|
||||||
|
if (jvip && port > 0 && port < 65536 &&
|
||||||
|
(*env)->GetArrayLength(env, jvip) == sizeof(vip))
|
||||||
|
{
|
||||||
|
(*env)->GetByteArrayRegion(env, jvip, 0, sizeof(vip), (jbyte*)&vip);
|
||||||
|
android_dns_proxy_set_redirect(vip, port);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
android_dns_proxy_set_redirect(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot of the userspace routing engine counters (mode B diagnostics).
|
* Snapshot of the userspace routing engine counters (mode B diagnostics).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ enum android_vpn_state_t {
|
|||||||
CHARONSERVICE_UNREACHABLE_ERROR,
|
CHARONSERVICE_UNREACHABLE_ERROR,
|
||||||
CHARONSERVICE_CERTIFICATE_UNAVAILABLE,
|
CHARONSERVICE_CERTIFICATE_UNAVAILABLE,
|
||||||
CHARONSERVICE_GENERIC_ERROR,
|
CHARONSERVICE_GENERIC_ERROR,
|
||||||
|
/* diagnostics only, no state change: the next CHILD_STATE_DOWN is caused by it */
|
||||||
|
CHARONSERVICE_RETRANSMIT_TIMEOUT,
|
||||||
|
CHARONSERVICE_REAUTH,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
{
|
{
|
||||||
active = TRUE;
|
active = TRUE;
|
||||||
this->udp->reprotect(this->udp);
|
this->udp->reprotect(this->udp);
|
||||||
if (this->tcp_active)
|
/* also while lwIP is dormant: this clears the negative cache of
|
||||||
{
|
* failed direct connects, which must not survive a roam */
|
||||||
this->tcp->reprotect(this->tcp);
|
this->tcp->reprotect(this->tcp);
|
||||||
}
|
|
||||||
/* the network changed: whatever was dead for direct connects
|
/* the network changed: whatever was dead for direct connects
|
||||||
* may work again - drop the tunnel fallbacks with the rest of
|
* may work again - drop the tunnel fallbacks with the rest of
|
||||||
* the failure state (tcp->reprotect clears the neg table) */
|
* the failure state (tcp->reprotect clears the neg table) */
|
||||||
@@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
this->bind_lock->unlock(this->bind_lock);
|
this->bind_lock->unlock(this->bind_lock);
|
||||||
|
|
||||||
if (!bound && this->unbound_flushed)
|
if (!bound && this->unbound_flushed)
|
||||||
{ /* TUN still gone and already flushed */
|
{ /* TUN still gone and already flushed: nothing to do until the
|
||||||
|
* re-bind wakes us, so back off like an idle round instead of
|
||||||
|
* polling at the active cadence all through a re-authentication */
|
||||||
drain_queue_nobind(this);
|
drain_queue_nobind(this);
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
poll_ms = ENGINE_POLL_MS;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
poll_ms *= 2;
|
||||||
|
if (poll_ms > ENGINE_IDLE_POLL_MS)
|
||||||
|
{
|
||||||
|
poll_ms = ENGINE_IDLE_POLL_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this)
|
|||||||
{
|
{
|
||||||
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
||||||
this->tcp->check_timers(this->tcp);
|
this->tcp->check_timers(this->tcp);
|
||||||
|
if (!active && this->tcp->flow_count(this->tcp) == 0)
|
||||||
|
{ /* a full idle round with no TCP flow left: let lwIP go dormant
|
||||||
|
* again (the next TCP packet wakes it, its timers catch up),
|
||||||
|
* so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */
|
||||||
|
this->tcp_active = FALSE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{ /* adapt the cadence after the work of this round is known */
|
{ /* adapt the cadence after the work of this round is known */
|
||||||
|
|||||||
@@ -59,6 +59,13 @@
|
|||||||
*/
|
*/
|
||||||
#define MAX_BUFFERED (256 * 1024)
|
#define MAX_BUFFERED (256 * 1024)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop reading from the remote socket while this much is waiting for the
|
||||||
|
* app: the data stays in the kernel socket and TCP throttles the sender,
|
||||||
|
* instead of the flow hitting MAX_BUFFERED and being aborted
|
||||||
|
*/
|
||||||
|
#define RELAY_HIGH_WATER (64 * 1024)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Give up on a direct connect after this many milliseconds (a destination
|
* Give up on a direct connect after this many milliseconds (a destination
|
||||||
* that silently drops SYNs would otherwise hang the app's connection
|
* that silently drops SYNs would otherwise hang the app's connection
|
||||||
@@ -126,6 +133,7 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err);
|
|||||||
static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err);
|
static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err);
|
||||||
static void on_err(void *arg, lwip_err_t err);
|
static void on_err(void *arg, lwip_err_t err);
|
||||||
static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb);
|
static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb);
|
||||||
|
static lwip_err_t on_sent(void *arg, struct tcp_pcb *pcb, u16_t len);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One relayed connection
|
* One relayed connection
|
||||||
@@ -330,13 +338,20 @@ static void relay_flush_to_sock(tcp_relay_t *relay)
|
|||||||
{
|
{
|
||||||
while (relay->to_sock_len)
|
while (relay->to_sock_len)
|
||||||
{
|
{
|
||||||
ssize_t n = write(relay->fd, relay->to_sock, relay->to_sock_len);
|
ssize_t n = write(relay->fd, relay->to_sock,
|
||||||
|
min(relay->to_sock_len, 0xffff));
|
||||||
|
|
||||||
if (n < 0)
|
if (n < 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
buf_consume(relay->to_sock, &relay->to_sock_len, n);
|
buf_consume(relay->to_sock, &relay->to_sock_len, n);
|
||||||
|
if (relay->pcb)
|
||||||
|
{ /* reopen the app's send window only for what the socket took:
|
||||||
|
* acknowledging on receipt let a fast app outrun a slow uplink
|
||||||
|
* until the flow hit MAX_BUFFERED and was aborted */
|
||||||
|
tcp_recved(relay->pcb, n);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,6 +744,7 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
|
|||||||
tcp_arg(newpcb, relay);
|
tcp_arg(newpcb, relay);
|
||||||
tcp_recv(newpcb, on_recv);
|
tcp_recv(newpcb, on_recv);
|
||||||
tcp_err(newpcb, on_err);
|
tcp_err(newpcb, on_err);
|
||||||
|
tcp_sent(newpcb, on_sent);
|
||||||
tcp_poll(newpcb, on_poll, 4);
|
tcp_poll(newpcb, on_poll, 4);
|
||||||
this->relays->insert_last(this->relays, relay);
|
this->relays->insert_last(this->relays, relay);
|
||||||
DIRECT_STATS_ADD(this->stats->tcp_flows, 1);
|
DIRECT_STATS_ADD(this->stats->tcp_flows, 1);
|
||||||
@@ -767,7 +783,6 @@ static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_e
|
|||||||
relay->dead = TRUE;
|
relay->dead = TRUE;
|
||||||
return ERR_ABRT;
|
return ERR_ABRT;
|
||||||
}
|
}
|
||||||
tcp_recved(pcb, p->tot_len);
|
|
||||||
pbuf_free(p);
|
pbuf_free(p);
|
||||||
relay_flush_to_sock(relay);
|
relay_flush_to_sock(relay);
|
||||||
return ERR_OK;
|
return ERR_OK;
|
||||||
@@ -802,6 +817,22 @@ static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb)
|
|||||||
return ERR_OK;
|
return ERR_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lwIP sent callback: the app acknowledged data, so the send buffer has
|
||||||
|
* room again. Without it the rest of a response only moved on the next
|
||||||
|
* socket read or the 2 s poll callback.
|
||||||
|
*/
|
||||||
|
static lwip_err_t on_sent(void *arg, struct tcp_pcb *pcb, u16_t len)
|
||||||
|
{
|
||||||
|
tcp_relay_t *relay = arg;
|
||||||
|
|
||||||
|
if (relay && relay->pcb && !relay->dead)
|
||||||
|
{
|
||||||
|
relay_flush_to_pcb(relay);
|
||||||
|
}
|
||||||
|
return ERR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* netif output: a finished IP packet for the kernel. lwIP keeps ownership
|
* netif output: a finished IP packet for the kernel. lwIP keeps ownership
|
||||||
* of the pbuf (unacked segments reference it for retransmission and free
|
* of the pbuf (unacked segments reference it for retransmission and free
|
||||||
@@ -966,6 +997,12 @@ METHOD(direct_tcp_t, check_timers, void,
|
|||||||
enumerator->destroy(enumerator);
|
enumerator->destroy(enumerator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
METHOD(direct_tcp_t, flow_count, size_t,
|
||||||
|
private_direct_tcp_t *this)
|
||||||
|
{
|
||||||
|
return this->relays->get_count(this->relays);
|
||||||
|
}
|
||||||
|
|
||||||
METHOD(direct_tcp_t, set_mtu, void,
|
METHOD(direct_tcp_t, set_mtu, void,
|
||||||
private_direct_tcp_t *this, uint16_t mtu)
|
private_direct_tcp_t *this, uint16_t mtu)
|
||||||
{
|
{
|
||||||
@@ -1014,7 +1051,16 @@ METHOD(direct_tcp_t, fill_poll, size_t,
|
|||||||
while (n < cap && enumerator->enumerate(enumerator, &relay))
|
while (n < cap && enumerator->enumerate(enumerator, &relay))
|
||||||
{
|
{
|
||||||
fds[n].fd = relay->fd;
|
fds[n].fd = relay->fd;
|
||||||
fds[n].events = POLLIN | (relay->connected ? 0 : POLLOUT);
|
fds[n].events = 0;
|
||||||
|
if (!relay->connected || relay->to_sock_len)
|
||||||
|
{ /* connect result, or room for data the socket refused before */
|
||||||
|
fds[n].events |= POLLOUT;
|
||||||
|
}
|
||||||
|
if (!relay->remote_eof && relay->to_pcb_len < RELAY_HIGH_WATER)
|
||||||
|
{ /* a socket at EOF stays readable forever: polling it for input
|
||||||
|
* made the engine thread spin until the app took the last byte */
|
||||||
|
fds[n].events |= POLLIN;
|
||||||
|
}
|
||||||
fds[n].revents = 0;
|
fds[n].revents = 0;
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1097,13 @@ METHOD(direct_tcp_t, process_poll, void,
|
|||||||
relay_destroy(this, relay, TRUE);
|
relay_destroy(this, relay, TRUE);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (relay->remote_eof && (re & (POLLERR | POLLHUP)))
|
||||||
|
{ /* fully closed: HUP is reported whatever we ask for, so drop the
|
||||||
|
* socket (poll ignores fd -1); what is buffered still goes to the app */
|
||||||
|
close(relay->fd);
|
||||||
|
relay->fd = -1;
|
||||||
|
re = 0;
|
||||||
|
}
|
||||||
if (!relay->connected)
|
if (!relay->connected)
|
||||||
{
|
{
|
||||||
uint64_t now = now_ms();
|
uint64_t now = now_ms();
|
||||||
@@ -1136,13 +1189,16 @@ METHOD(direct_tcp_t, process_poll, void,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (re & (POLLIN | POLLHUP))
|
if (re & (POLLIN | POLLHUP | POLLERR))
|
||||||
{
|
{
|
||||||
uint8_t buf[16384];
|
uint8_t buf[16384];
|
||||||
ssize_t n;
|
ssize_t n = 1;
|
||||||
bool failed = FALSE;
|
bool failed = FALSE;
|
||||||
|
|
||||||
while ((n = read(relay->fd, buf, sizeof(buf))) > 0)
|
/* a broken socket is read out whatever is buffered: its error
|
||||||
|
* must surface, or poll would report it round after round */
|
||||||
|
while ((relay->to_pcb_len < RELAY_HIGH_WATER || (re & (POLLHUP | POLLERR))) &&
|
||||||
|
(n = read(relay->fd, buf, sizeof(buf))) > 0)
|
||||||
{
|
{
|
||||||
if (!buf_append(relay, FALSE, buf, n))
|
if (!buf_append(relay, FALSE, buf, n))
|
||||||
{
|
{
|
||||||
@@ -1290,6 +1346,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
|
|||||||
.public = {
|
.public = {
|
||||||
.handle_packet = _handle_packet,
|
.handle_packet = _handle_packet,
|
||||||
.check_timers = _check_timers,
|
.check_timers = _check_timers,
|
||||||
|
.flow_count = _flow_count,
|
||||||
.set_mtu = _set_mtu,
|
.set_mtu = _set_mtu,
|
||||||
.reprotect = _reprotect,
|
.reprotect = _reprotect,
|
||||||
.fill_poll = _fill_poll,
|
.fill_poll = _fill_poll,
|
||||||
|
|||||||
@@ -1251,7 +1251,7 @@
|
|||||||
<string name="prot_cloudflare_sub">1.1.1.1 · durch den Tunnel</string>
|
<string name="prot_cloudflare_sub">1.1.1.1 · durch den Tunnel</string>
|
||||||
<string name="prot_adguard_sub">Mit eingebautem Filter</string>
|
<string name="prot_adguard_sub">Mit eingebautem Filter</string>
|
||||||
<string name="prot_custom">Eigene Adresse</string>
|
<string name="prot_custom">Eigene Adresse</string>
|
||||||
<string name="prot_custom_sub">IPv4 oder IPv6, durch Komma getrennt</string>
|
<string name="prot_custom_sub">IPs, DoH (https://) oder DoT (tls://)</string>
|
||||||
<string name="prot_note">Anfragen an diesen DNS gehen durch den Tunnel — der Anbieter kann die Antwort nicht verfälschen. Apps, die am VPN vorbeigehen, nutzen den DNS des Netzes.</string>
|
<string name="prot_note">Anfragen an diesen DNS gehen durch den Tunnel — der Anbieter kann die Antwort nicht verfälschen. Apps, die am VPN vorbeigehen, nutzen den DNS des Netzes.</string>
|
||||||
<string name="prot_applies">Gilt nach einer Neuverbindung</string>
|
<string name="prot_applies">Gilt nach einer Neuverbindung</string>
|
||||||
<string name="prot_list_ads">Werbung</string>
|
<string name="prot_list_ads">Werbung</string>
|
||||||
@@ -1318,4 +1318,13 @@
|
|||||||
<item quantity="one">%d vertrauenswürdiges Zertifikat</item>
|
<item quantity="one">%d vertrauenswürdiges Zertifikat</item>
|
||||||
<item quantity="other">%d vertrauenswürdige Zertifikate</item>
|
<item quantity="other">%d vertrauenswürdige Zertifikate</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="event_crash_title">Die App wurde unerwartet beendet</string>
|
||||||
|
<string name="event_crash_text">SHX VPN ist zuletzt abgestürzt. Senden Sie einen Bericht, damit wir die Ursache finden.</string>
|
||||||
|
<string name="event_lockdown_off_title">Durchgehend aktives VPN wurde ausgeschaltet</string>
|
||||||
|
<string name="event_lockdown_off_text">Es war vorher an. Jetzt gehen Apps bei jedem VPN-Abbruch für einige Sekunden direkt ins Netz. Schalten Sie es in den VPN-Einstellungen von Android wieder ein.</string>
|
||||||
|
<string name="prot_secure_ok">Der Server hat in %1$d ms geantwortet. DNS-Anfragen werden verschlüsselt.</string>
|
||||||
|
<string name="prot_secure_fail">Der Server hat nicht geantwortet (%1$s). Bis dahin nutzt das VPN das DNS des Profils.</string>
|
||||||
|
<string name="prot_secure_note">DoH/DoT-Anfragen gehen verschlüsselt über das normale Netz an Ihren Server, nicht durch den Tunnel. Antwortet er nicht, nutzt das VPN das DNS des Profils. Bei strengem privatem DNS nutzt Android seinen eigenen Server.</string>
|
||||||
|
<string name="event_secure_dns_title">Eigener DoH/DoT-Server antwortet nicht</string>
|
||||||
|
<string name="event_secure_dns_text">Das VPN hat sich mit dem DNS des Profils verbunden. Prüfen Sie die Adresse unter „Einstellungen → DNS und Werbung“.</string>
|
||||||
</resources>
|
</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">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>
|
||||||
@@ -1141,7 +1141,7 @@
|
|||||||
<string name="prot_cloudflare_sub">1.1.1.1 · przez tunel</string>
|
<string name="prot_cloudflare_sub">1.1.1.1 · przez tunel</string>
|
||||||
<string name="prot_adguard_sub">Z wbudowanym filtrowaniem</string>
|
<string name="prot_adguard_sub">Z wbudowanym filtrowaniem</string>
|
||||||
<string name="prot_custom">Własny adres</string>
|
<string name="prot_custom">Własny adres</string>
|
||||||
<string name="prot_custom_sub">IPv4 lub IPv6 oddzielone przecinkami</string>
|
<string name="prot_custom_sub">IP, DoH (https://) lub DoT (tls://)</string>
|
||||||
<string name="prot_note">Zapytania do tego DNS idą przez tunel — dostawca nie podmieni odpowiedzi. Aplikacje z pominięciem VPN używają DNS sieci.</string>
|
<string name="prot_note">Zapytania do tego DNS idą przez tunel — dostawca nie podmieni odpowiedzi. Aplikacje z pominięciem VPN używają DNS sieci.</string>
|
||||||
<string name="prot_applies">Zostanie zastosowane po ponownym połączeniu</string>
|
<string name="prot_applies">Zostanie zastosowane po ponownym połączeniu</string>
|
||||||
<string name="prot_list_ads">reklamy</string>
|
<string name="prot_list_ads">reklamy</string>
|
||||||
@@ -1236,4 +1236,13 @@
|
|||||||
<item quantity="many">%d zaufanych certyfikatów</item>
|
<item quantity="many">%d zaufanych certyfikatów</item>
|
||||||
<item quantity="other">%d zaufanego certyfikatu</item>
|
<item quantity="other">%d zaufanego certyfikatu</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="event_crash_title">Aplikacja nieoczekiwanie się zamknęła</string>
|
||||||
|
<string name="event_crash_text">Ostatnio SHX VPN uległ awarii. Wyślij raport, abyśmy znaleźli przyczynę.</string>
|
||||||
|
<string name="event_lockdown_off_title">Stały VPN został wyłączony</string>
|
||||||
|
<string name="event_lockdown_off_text">Wcześniej był włączony. Teraz przy każdym zerwaniu VPN aplikacje na kilka sekund łączą się bezpośrednio. Włącz go ponownie w ustawieniach VPN Androida.</string>
|
||||||
|
<string name="prot_secure_ok">Serwer odpowiedział w %1$d ms. Zapytania DNS będą szyfrowane.</string>
|
||||||
|
<string name="prot_secure_fail">Serwer nie odpowiedział (%1$s). Dopóki jest niedostępny, VPN używa DNS z profilu.</string>
|
||||||
|
<string name="prot_secure_note">Zapytania DoH/DoT trafiają do Twojego serwera zaszyfrowane przez zwykłą sieć, nie przez tunel. Jeśli serwer nie odpowiada, VPN używa DNS z profilu. Przy ścisłym prywatnym DNS Android używa własnego serwera.</string>
|
||||||
|
<string name="event_secure_dns_title">Własny serwer DoH/DoT nie odpowiada</string>
|
||||||
|
<string name="event_secure_dns_text">VPN połączył się z DNS z profilu. Sprawdź adres w „Ustawienia → DNS i reklamy”.</string>
|
||||||
</resources>
|
</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>
|
||||||
@@ -1312,7 +1312,7 @@
|
|||||||
<string name="prot_cloudflare_sub">1.1.1.1 · через туннель</string>
|
<string name="prot_cloudflare_sub">1.1.1.1 · через туннель</string>
|
||||||
<string name="prot_adguard_sub">Со встроенной фильтрацией</string>
|
<string name="prot_adguard_sub">Со встроенной фильтрацией</string>
|
||||||
<string name="prot_custom">Свой адрес</string>
|
<string name="prot_custom">Свой адрес</string>
|
||||||
<string name="prot_custom_sub">IPv4 или IPv6 через запятую</string>
|
<string name="prot_custom_sub">IP, DoH (https://) или DoT (tls://)</string>
|
||||||
<string name="prot_note">Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети.</string>
|
<string name="prot_note">Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети.</string>
|
||||||
<string name="prot_applies">Применится при переподключении</string>
|
<string name="prot_applies">Применится при переподключении</string>
|
||||||
<string name="prot_list_ads">реклама</string>
|
<string name="prot_list_ads">реклама</string>
|
||||||
@@ -1347,4 +1347,13 @@
|
|||||||
<string name="server_needs_password">Нет сохранённого пароля: переключиться на этот сервер не получится. Сохраните пароль в его профиле.</string>
|
<string name="server_needs_password">Нет сохранённого пароля: переключиться на этот сервер не получится. Сохраните пароль в его профиле.</string>
|
||||||
<string name="event_auto_pw_title">«%1$s» не подключён автоматически</string>
|
<string name="event_auto_pw_title">«%1$s» не подключён автоматически</string>
|
||||||
<string name="event_auto_pw_text">Сетевое правило или сценарий хотели включить VPN, но в профиле нет сохранённого пароля. Сохраните пароль в профиле.</string>
|
<string name="event_auto_pw_text">Сетевое правило или сценарий хотели включить VPN, но в профиле нет сохранённого пароля. Сохраните пароль в профиле.</string>
|
||||||
|
<string name="event_crash_title">Приложение аварийно завершилось</string>
|
||||||
|
<string name="event_crash_text">В прошлый раз SHX VPN закрылся из-за сбоя. Отправьте отчёт, чтобы мы нашли причину.</string>
|
||||||
|
<string name="event_lockdown_off_title">Постоянный VPN выключили</string>
|
||||||
|
<string name="event_lockdown_off_text">Раньше он был включён. Теперь при обрыве VPN приложения на секунды выходят в сеть напрямую. Включите его снова в настройках VPN Android.</string>
|
||||||
|
<string name="prot_secure_ok">Сервер ответил за %1$d мс. DNS-запросы будут шифроваться.</string>
|
||||||
|
<string name="prot_secure_fail">Сервер не ответил (%1$s). Пока он недоступен, VPN использует DNS профиля.</string>
|
||||||
|
<string name="prot_secure_note">Запросы DoH/DoT идут к вашему серверу в зашифрованном виде через обычную сеть, а не через туннель. Если сервер не отвечает, VPN использует DNS профиля. При строгом «Частном DNS» Android использует свой сервер.</string>
|
||||||
|
<string name="event_secure_dns_title">Свой DoH/DoT-сервер не отвечает</string>
|
||||||
|
<string name="event_secure_dns_text">VPN подключился с DNS профиля. Проверьте адрес в «Настройки → DNS и реклама».</string>
|
||||||
</resources>
|
</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>
|
||||||
@@ -1151,7 +1151,7 @@
|
|||||||
<string name="prot_cloudflare_sub">1.1.1.1 · через тунель</string>
|
<string name="prot_cloudflare_sub">1.1.1.1 · через тунель</string>
|
||||||
<string name="prot_adguard_sub">З вбудованою фільтрацією</string>
|
<string name="prot_adguard_sub">З вбудованою фільтрацією</string>
|
||||||
<string name="prot_custom">Своя адреса</string>
|
<string name="prot_custom">Своя адреса</string>
|
||||||
<string name="prot_custom_sub">IPv4 або IPv6 через кому</string>
|
<string name="prot_custom_sub">IP, DoH (https://) або DoT (tls://)</string>
|
||||||
<string name="prot_note">Запити до цього DNS ідуть через тунель — провайдер не зможе підмінити відповідь. Застосунки, що йдуть повз VPN, використовують DNS мережі.</string>
|
<string name="prot_note">Запити до цього DNS ідуть через тунель — провайдер не зможе підмінити відповідь. Застосунки, що йдуть повз VPN, використовують DNS мережі.</string>
|
||||||
<string name="prot_applies">Застосується після перепідключення</string>
|
<string name="prot_applies">Застосується після перепідключення</string>
|
||||||
<string name="prot_list_ads">реклама</string>
|
<string name="prot_list_ads">реклама</string>
|
||||||
@@ -1246,4 +1246,13 @@
|
|||||||
<item quantity="many">%d довірених сертифікатів</item>
|
<item quantity="many">%d довірених сертифікатів</item>
|
||||||
<item quantity="other">%d довіреного сертифіката</item>
|
<item quantity="other">%d довіреного сертифіката</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="event_crash_title">Застосунок аварійно завершився</string>
|
||||||
|
<string name="event_crash_text">Минулого разу SHX VPN закрився через збій. Надішліть звіт, щоб ми знайшли причину.</string>
|
||||||
|
<string name="event_lockdown_off_title">Постійну VPN вимкнули</string>
|
||||||
|
<string name="event_lockdown_off_text">Раніше її було ввімкнено. Тепер у разі обриву VPN застосунки на секунди виходять у мережу напряму. Увімкніть її знову в налаштуваннях VPN Android.</string>
|
||||||
|
<string name="prot_secure_ok">Сервер відповів за %1$d мс. DNS-запити шифруватимуться.</string>
|
||||||
|
<string name="prot_secure_fail">Сервер не відповів (%1$s). Поки він недоступний, VPN використовує DNS профілю.</string>
|
||||||
|
<string name="prot_secure_note">Запити DoH/DoT ідуть до вашого сервера в зашифрованому вигляді через звичайну мережу, а не через тунель. Якщо сервер не відповідає, VPN використовує DNS профілю. За суворого «Приватного DNS» Android використовує свій сервер.</string>
|
||||||
|
<string name="event_secure_dns_title">Власний DoH/DoT-сервер не відповідає</string>
|
||||||
|
<string name="event_secure_dns_text">VPN підключився з DNS профілю. Перевірте адресу в «Налаштування → DNS і реклама».</string>
|
||||||
</resources>
|
</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>
|
||||||
@@ -1150,7 +1150,7 @@
|
|||||||
<string name="prot_cloudflare_sub">1.1.1.1 · 经隧道</string>
|
<string name="prot_cloudflare_sub">1.1.1.1 · 经隧道</string>
|
||||||
<string name="prot_adguard_sub">内置过滤</string>
|
<string name="prot_adguard_sub">内置过滤</string>
|
||||||
<string name="prot_custom">自定义地址</string>
|
<string name="prot_custom">自定义地址</string>
|
||||||
<string name="prot_custom_sub">IPv4 或 IPv6,以逗号分隔</string>
|
<string name="prot_custom_sub">IP、DoH (https://) 或 DoT (tls://)</string>
|
||||||
<string name="prot_note">对此 DNS 的查询经隧道发送,运营商无法篡改应答。绕过 VPN 的应用使用网络自身的 DNS。</string>
|
<string name="prot_note">对此 DNS 的查询经隧道发送,运营商无法篡改应答。绕过 VPN 的应用使用网络自身的 DNS。</string>
|
||||||
<string name="prot_applies">重新连接后生效</string>
|
<string name="prot_applies">重新连接后生效</string>
|
||||||
<string name="prot_list_ads">广告</string>
|
<string name="prot_list_ads">广告</string>
|
||||||
@@ -1215,4 +1215,13 @@
|
|||||||
<plurals name="settings_certs_n">
|
<plurals name="settings_certs_n">
|
||||||
<item quantity="other">%d 个受信任的证书</item>
|
<item quantity="other">%d 个受信任的证书</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="event_crash_title">应用意外关闭</string>
|
||||||
|
<string name="event_crash_text">SHX VPN 上次发生崩溃。请发送报告,帮助我们找到原因。</string>
|
||||||
|
<string name="event_lockdown_off_title">始终开启的 VPN 已被关闭</string>
|
||||||
|
<string name="event_lockdown_off_text">此前它处于开启状态。现在每次 VPN 断开时,应用都会直接联网几秒钟。请在 Android 的 VPN 设置中重新开启。</string>
|
||||||
|
<string name="prot_secure_ok">服务器在 %1$d 毫秒内响应。DNS 查询将被加密。</string>
|
||||||
|
<string name="prot_secure_fail">服务器没有响应(%1$s)。在它恢复之前,VPN 使用配置文件中的 DNS。</string>
|
||||||
|
<string name="prot_secure_note">DoH/DoT 查询会经由普通网络加密发送到你的服务器,而不经过隧道。如果服务器没有响应,VPN 会使用配置文件中的 DNS。启用严格的私人 DNS 时,Android 会使用其自己的服务器。</string>
|
||||||
|
<string name="event_secure_dns_title">自定义 DoH/DoT 服务器没有响应</string>
|
||||||
|
<string name="event_secure_dns_text">VPN 已使用配置文件中的 DNS 连接。请在“设置 → DNS 与广告”中检查地址。</string>
|
||||||
</resources>
|
</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>
|
||||||
@@ -1135,7 +1135,7 @@
|
|||||||
<string name="prot_cloudflare_sub">1.1.1.1 · 經通道</string>
|
<string name="prot_cloudflare_sub">1.1.1.1 · 經通道</string>
|
||||||
<string name="prot_adguard_sub">內建過濾</string>
|
<string name="prot_adguard_sub">內建過濾</string>
|
||||||
<string name="prot_custom">自訂位址</string>
|
<string name="prot_custom">自訂位址</string>
|
||||||
<string name="prot_custom_sub">IPv4 或 IPv6,以逗號分隔</string>
|
<string name="prot_custom_sub">IP、DoH (https://) 或 DoT (tls://)</string>
|
||||||
<string name="prot_note">對此 DNS 的查詢經通道傳送,電信業者無法竄改回應。繞過 VPN 的應用程式使用網路本身的 DNS。</string>
|
<string name="prot_note">對此 DNS 的查詢經通道傳送,電信業者無法竄改回應。繞過 VPN 的應用程式使用網路本身的 DNS。</string>
|
||||||
<string name="prot_applies">重新連線後生效</string>
|
<string name="prot_applies">重新連線後生效</string>
|
||||||
<string name="prot_list_ads">廣告</string>
|
<string name="prot_list_ads">廣告</string>
|
||||||
@@ -1200,4 +1200,13 @@
|
|||||||
<plurals name="settings_certs_n">
|
<plurals name="settings_certs_n">
|
||||||
<item quantity="other">%d 個信任的憑證</item>
|
<item quantity="other">%d 個信任的憑證</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="event_crash_title">應用程式意外關閉</string>
|
||||||
|
<string name="event_crash_text">SHX VPN 上次發生當機。請傳送報告,協助我們找出原因。</string>
|
||||||
|
<string name="event_lockdown_off_title">永久連線的 VPN 已被關閉</string>
|
||||||
|
<string name="event_lockdown_off_text">先前它處於開啟狀態。現在每次 VPN 中斷時,應用程式都會直接連網幾秒鐘。請在 Android 的 VPN 設定中重新開啟。</string>
|
||||||
|
<string name="prot_secure_ok">伺服器在 %1$d 毫秒內回應。DNS 查詢將會加密。</string>
|
||||||
|
<string name="prot_secure_fail">伺服器沒有回應(%1$s)。在恢復之前,VPN 會使用設定檔中的 DNS。</string>
|
||||||
|
<string name="prot_secure_note">DoH/DoT 查詢會經由一般網路加密傳送到你的伺服器,而不經過通道。如果伺服器沒有回應,VPN 會使用設定檔中的 DNS。啟用嚴格的私人 DNS 時,Android 會使用自己的伺服器。</string>
|
||||||
|
<string name="event_secure_dns_title">自訂 DoH/DoT 伺服器沒有回應</string>
|
||||||
|
<string name="event_secure_dns_text">VPN 已使用設定檔中的 DNS 連線。請在「設定 → DNS 與廣告」中檢查位址。</string>
|
||||||
</resources>
|
</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>
|
||||||
@@ -1298,7 +1298,7 @@
|
|||||||
<string name="prot_cloudflare_sub">1.1.1.1 · through the tunnel</string>
|
<string name="prot_cloudflare_sub">1.1.1.1 · through the tunnel</string>
|
||||||
<string name="prot_adguard_sub">With built-in filtering</string>
|
<string name="prot_adguard_sub">With built-in filtering</string>
|
||||||
<string name="prot_custom">Own address</string>
|
<string name="prot_custom">Own address</string>
|
||||||
<string name="prot_custom_sub">IPv4 or IPv6, comma separated</string>
|
<string name="prot_custom_sub">IPs, DoH (https://) or DoT (tls://)</string>
|
||||||
<string name="prot_note">Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS.</string>
|
<string name="prot_note">Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS.</string>
|
||||||
<string name="prot_applies">Applies on reconnect</string>
|
<string name="prot_applies">Applies on reconnect</string>
|
||||||
<string name="prot_list_ads">ads</string>
|
<string name="prot_list_ads">ads</string>
|
||||||
@@ -1333,4 +1333,13 @@
|
|||||||
<string name="server_needs_password">No saved password: switching to it will not work. Save it in its profile.</string>
|
<string name="server_needs_password">No saved password: switching to it will not work. Save it in its profile.</string>
|
||||||
<string name="event_auto_pw_title">«%1$s» was not connected automatically</string>
|
<string name="event_auto_pw_title">«%1$s» was not connected automatically</string>
|
||||||
<string name="event_auto_pw_text">A network rule or a scenario wanted the VPN on, but the profile has no saved password. Save it in the profile.</string>
|
<string name="event_auto_pw_text">A network rule or a scenario wanted the VPN on, but the profile has no saved password. Save it in the profile.</string>
|
||||||
|
<string name="event_crash_title">The app closed unexpectedly</string>
|
||||||
|
<string name="event_crash_text">SHX VPN crashed last time. Send a report so we can find the cause.</string>
|
||||||
|
<string name="event_lockdown_off_title">Always-on VPN was turned off</string>
|
||||||
|
<string name="event_lockdown_off_text">It was on before. Now apps go online directly for a few seconds whenever the VPN drops. Turn it back on in the Android VPN settings.</string>
|
||||||
|
<string name="prot_secure_ok">The server answered in %1$d ms. DNS queries will be encrypted.</string>
|
||||||
|
<string name="prot_secure_fail">The server did not answer (%1$s). Until it does, the VPN uses the profile’s DNS.</string>
|
||||||
|
<string name="prot_secure_note">DoH/DoT queries go to your server encrypted over the regular network, not through the tunnel. If it does not answer, the VPN uses the profile’s DNS. With strict Private DNS, Android uses its own server.</string>
|
||||||
|
<string name="event_secure_dns_title">Own DoH/DoT server is not answering</string>
|
||||||
|
<string name="event_secure_dns_text">The VPN connected with the profile’s DNS. Check the address in Settings → DNS and ads.</string>
|
||||||
</resources>
|
</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>
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* 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 SaCloseReasonTest
|
||||||
|
{
|
||||||
|
private fun classify(disconnecting: Boolean = false, reauth: Boolean = false,
|
||||||
|
timeout: Boolean = false, netAgo: Long = -1) =
|
||||||
|
SaClose.classify(disconnecting, reauth, timeout, netAgo)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userStopWinsOverEverything()
|
||||||
|
{
|
||||||
|
assertThat(classify(disconnecting = true, reauth = true, timeout = true, netAgo = 0)).isEqualTo(SaClose.USER)
|
||||||
|
assertThat(SaClose.USER.drop).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reauthIsNotADrop()
|
||||||
|
{
|
||||||
|
assertThat(classify(reauth = true, timeout = true, netAgo = 1000)).isEqualTo(SaClose.REAUTH)
|
||||||
|
assertThat(SaClose.REAUTH.drop).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retransmitTimeoutWithAndWithoutNetworkChange()
|
||||||
|
{
|
||||||
|
assertThat(classify(timeout = true, netAgo = 5000)).isEqualTo(SaClose.RETRANSMIT_AFTER_NETWORK_CHANGE)
|
||||||
|
assertThat(classify(timeout = true)).isEqualTo(SaClose.RETRANSMIT_TIMEOUT)
|
||||||
|
assertThat(classify(timeout = true, netAgo = 21_000)).isEqualTo(SaClose.RETRANSMIT_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun networkWindowBoundary()
|
||||||
|
{
|
||||||
|
assertThat(classify(netAgo = 0)).isEqualTo(SaClose.NETWORK_CHANGE)
|
||||||
|
assertThat(classify(netAgo = SaClose.NETWORK_WINDOW_MS)).isEqualTo(SaClose.NETWORK_CHANGE)
|
||||||
|
assertThat(classify(netAgo = SaClose.NETWORK_WINDOW_MS + 1)).isEqualTo(SaClose.OTHER)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noSignalsIsOther()
|
||||||
|
{
|
||||||
|
assertThat(classify()).isEqualTo(SaClose.OTHER)
|
||||||
|
assertThat(SaClose.OTHER.drop).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun counterCountsWithinADayAndResetsAtLocalMidnight()
|
||||||
|
{
|
||||||
|
val c = DailyCounter()
|
||||||
|
val day = 86_400_000L
|
||||||
|
assertThat(c.next(1000, 0)).isEqualTo(1)
|
||||||
|
assertThat(c.next(2000, 0)).isEqualTo(2)
|
||||||
|
assertThat(c.next(day - 1, 0)).isEqualTo(3)
|
||||||
|
assertThat(c.next(day, 0)).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun counterUsesTheLocalOffset()
|
||||||
|
{
|
||||||
|
val c = DailyCounter()
|
||||||
|
val hour = 3_600_000L
|
||||||
|
/* 23:30 UTC: past midnight at UTC+3 (21:30 and 23:30 UTC are one local day, the next hour is not),
|
||||||
|
* still the same local day an hour later at UTC-5 */
|
||||||
|
val t = 24 * hour - hour / 2
|
||||||
|
assertThat(c.next(t - 2 * hour, 3 * hour)).isEqualTo(1)
|
||||||
|
assertThat(c.next(t, 3 * hour)).isEqualTo(2)
|
||||||
|
assertThat(c.next(t + hour, 3 * hour)).isEqualTo(3)
|
||||||
|
val d = DailyCounter()
|
||||||
|
assertThat(d.next(t, -5 * hour)).isEqualTo(1)
|
||||||
|
assertThat(d.next(t + hour, -5 * hour)).isEqualTo(2)
|
||||||
|
val e = DailyCounter()
|
||||||
|
assertThat(e.next(t, 0)).isEqualTo(1)
|
||||||
|
assertThat(e.next(t + hour, 0)).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-5
@@ -132,21 +132,23 @@ class WatchdogReceiverTest
|
|||||||
/* retrying/connecting: the service tracks the connection */
|
/* retrying/connecting: the service tracks the connection */
|
||||||
val retrying = decide(expectedProfileSet = true, serviceTracking = true, processAlive = true)
|
val retrying = decide(expectedProfileSet = true, serviceTracking = true, processAlive = true)
|
||||||
assertThat(retrying).isEqualTo(Action.NONE)
|
assertThat(retrying).isEqualTo(Action.NONE)
|
||||||
assertThat(WatchdogReceiver.isAliveSteady(retrying, true, false)).isTrue()
|
assertThat(WatchdogReceiver.isAliveSteady(retrying, true, false, false)).isTrue()
|
||||||
|
/* a pending retry stands still while the device sleeps: keep the fast wakeup alarm */
|
||||||
|
assertThat(WatchdogReceiver.isAliveSteady(retrying, true, false, true)).isFalse()
|
||||||
/* whitelist blocks the server while the process is alive */
|
/* whitelist blocks the server while the process is alive */
|
||||||
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = true)
|
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = true)
|
||||||
assertThat(WatchdogReceiver.isAliveSteady(blocked, true, false)).isTrue()
|
assertThat(WatchdogReceiver.isAliveSteady(blocked, true, false, false)).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deadProcessOrRestartNeverBacksOff()
|
fun deadProcessOrRestartNeverBacksOff()
|
||||||
{
|
{
|
||||||
val restart = decide(expectedProfileSet = true, processAlive = false)
|
val restart = decide(expectedProfileSet = true, processAlive = false)
|
||||||
assertThat(WatchdogReceiver.isAliveSteady(restart, false, false)).isFalse()
|
assertThat(WatchdogReceiver.isAliveSteady(restart, false, false, false)).isFalse()
|
||||||
/* whitelist blocks, but no live process: keep the fast cadence */
|
/* whitelist blocks, but no live process: keep the fast cadence */
|
||||||
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = false)
|
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = false)
|
||||||
assertThat(WatchdogReceiver.isAliveSteady(blocked, false, false)).isFalse()
|
assertThat(WatchdogReceiver.isAliveSteady(blocked, false, false, false)).isFalse()
|
||||||
/* parked sessions use their own backoff */
|
/* parked sessions use their own backoff */
|
||||||
assertThat(WatchdogReceiver.isAliveSteady(Action.NONE, true, true)).isFalse()
|
assertThat(WatchdogReceiver.isAliveSteady(Action.NONE, true, true, false)).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* 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.protection
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
|
class SecureDnsTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
fun parsesDoh()
|
||||||
|
{
|
||||||
|
assertThat(SecureDnsConfig.parse(" https://dns.example.com/dns-query "))
|
||||||
|
.isEqualTo(SecureDnsConfig(true, "dns.example.com", 443, "https://dns.example.com/dns-query"))
|
||||||
|
/* no path: the RFC 8484 default */
|
||||||
|
assertThat(SecureDnsConfig.parse("https://dns.example.com")!!.url).isEqualTo("https://dns.example.com/dns-query")
|
||||||
|
assertThat(SecureDnsConfig.parse("https://dns.example.com:8443/q")!!.port).isEqualTo(8443)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesDot()
|
||||||
|
{
|
||||||
|
assertThat(SecureDnsConfig.parse("tls://dot.example.com")).isEqualTo(SecureDnsConfig(false, "dot.example.com", 853, "tls://dot.example.com:853"))
|
||||||
|
assertThat(SecureDnsConfig.parse("TLS://1.1.1.1:8853")!!.port).isEqualTo(8853)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsPlainAndBroken()
|
||||||
|
{
|
||||||
|
assertThat(SecureDnsConfig.parse("http://dns.example.com/dns-query")).isNull()
|
||||||
|
assertThat(SecureDnsConfig.parse("9.9.9.9")).isNull()
|
||||||
|
assertThat(SecureDnsConfig.parse("https://")).isNull()
|
||||||
|
assertThat(SecureDnsConfig.parse("https://user:pw@dns.example.com/q")).isNull()
|
||||||
|
assertThat(SecureDnsConfig.parse("https://dns.example.com/q?dns=x")).isNull()
|
||||||
|
assertThat(SecureDnsConfig.parse("tls://dot.example.com/path")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun customChoiceUsesSecureOnlyForUrls()
|
||||||
|
{
|
||||||
|
assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "tls://dot.example.com").secure).isNotNull()
|
||||||
|
assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "tls://dot.example.com").servers()).isNull()
|
||||||
|
assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "9.9.9.9").secure).isNull()
|
||||||
|
assertThat(ProtectionState(DnsChoice.CLOUDFLARE, custom = "tls://dot.example.com").secure).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun queryEncodesName()
|
||||||
|
{
|
||||||
|
val q = SecureDnsCodec.query(0x1234, "example.com", 1)
|
||||||
|
assertThat(q.copyOfRange(0, 6)).containsExactly(0x12, 0x34, 0x01, 0x00, 0, 1)
|
||||||
|
assertThat(q.copyOfRange(12, q.size)).containsExactly(
|
||||||
|
7, 'e'.code.toByte(), 'x'.code.toByte(), 'a'.code.toByte(), 'm'.code.toByte(), 'p'.code.toByte(), 'l'.code.toByte(), 'e'.code.toByte(),
|
||||||
|
3, 'c'.code.toByte(), 'o'.code.toByte(), 'm'.code.toByte(), 0, 0, 1, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun servfailKeepsIdAndQuestion()
|
||||||
|
{
|
||||||
|
val q = SecureDnsCodec.query(0xbeef, "example.com", 28)
|
||||||
|
val r = SecureDnsCodec.servfail(q)!!
|
||||||
|
assertThat(r.size).isEqualTo(q.size)
|
||||||
|
assertThat(SecureDnsCodec.sameId(q, r)).isTrue()
|
||||||
|
assertThat(r[2].toInt() and 0x80).isEqualTo(0x80) /* QR */
|
||||||
|
assertThat(r[2].toInt() and 0x01).isEqualTo(0x01) /* RD kept */
|
||||||
|
assertThat(r[3].toInt() and 0x0f).isEqualTo(2) /* SERVFAIL */
|
||||||
|
assertThat(r.copyOfRange(4, 12)).containsExactly(0, 1, 0, 0, 0, 0, 0, 0)
|
||||||
|
assertThat(r.copyOfRange(12, r.size)).isEqualTo(q.copyOfRange(12, q.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun servfailDropsAdditionalRecords()
|
||||||
|
{
|
||||||
|
/* EDNS OPT record after the question must not survive with ARCOUNT 0 */
|
||||||
|
val q = SecureDnsCodec.query(1, "a.b", 1).also { it[11] = 1 } + byteArrayOf(0, 0, 41, 4, 0xd0.toByte(), 0, 0, 0, 0, 0, 0)
|
||||||
|
val r = SecureDnsCodec.servfail(q)!!
|
||||||
|
assertThat(r.size).isEqualTo(q.size - 11)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun servfailNeedsHeader()
|
||||||
|
{
|
||||||
|
assertThat(SecureDnsCodec.servfail(ByteArray(5))).isNull()
|
||||||
|
/* header only, no question */
|
||||||
|
assertThat(SecureDnsCodec.servfail(ByteArray(12))!!.size).isEqualTo(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkReplyRejectsEmptyAndForeign()
|
||||||
|
{
|
||||||
|
val q = SecureDnsCodec.query(0x1234, "ya.ru", 1)
|
||||||
|
assertThatThrownBy { SecureDnsCodec.checkReply(q, ByteArray(0)) }.hasMessage("empty reply")
|
||||||
|
/* the query itself has no QR bit */
|
||||||
|
assertThatThrownBy { SecureDnsCodec.checkReply(q, q) }.hasMessage("not a DNS reply")
|
||||||
|
val other = SecureDnsCodec.servfail(SecureDnsCodec.query(0x4321, "ya.ru", 1))!!
|
||||||
|
assertThatThrownBy { SecureDnsCodec.checkReply(q, other) }.hasMessage("reply id mismatch")
|
||||||
|
SecureDnsCodec.checkReply(q, SecureDnsCodec.servfail(q)!!) /* any rcode is a reply */
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dotClosedConnectionIsReported()
|
||||||
|
{
|
||||||
|
assertThatThrownBy { SecureDnsCodec.readFrame(ByteArrayInputStream(ByteArray(0))) }
|
||||||
|
.hasMessage("connection closed without a reply")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dotFramingRoundTrip()
|
||||||
|
{
|
||||||
|
val msg = ByteArray(300) { it.toByte() }
|
||||||
|
val framed = SecureDnsCodec.frame(msg)
|
||||||
|
assertThat(framed.copyOfRange(0, 2)).containsExactly(1, 44)
|
||||||
|
assertThat(SecureDnsCodec.readFrame(ByteArrayInputStream(framed))).isEqualTo(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ReportContentTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
fun checksGoBetweenTheLogAndDiagAsAJsonArray()
|
||||||
|
{
|
||||||
|
val checks = listOf(
|
||||||
|
ReportContent.Check("Интернет", "Wi-Fi", true),
|
||||||
|
ReportContent.Check("Сервер \"nl1\"", "нет ответа\n15 с", false),
|
||||||
|
ReportContent.Check("DNS", "проверяется", null),
|
||||||
|
)
|
||||||
|
val text = ReportContent.build("a\n", "d\n", checks)
|
||||||
|
assertThat(text).isEqualTo(
|
||||||
|
"=== charon.log ===\na\n\n=== checks.json ===\n" +
|
||||||
|
"[{\"title\":\"Интернет\",\"value\":\"Wi-Fi\",\"ok\":true}," +
|
||||||
|
"{\"title\":\"Сервер \\\"nl1\\\"\",\"value\":\"нет ответа\\n15 с\",\"ok\":false}," +
|
||||||
|
"{\"title\":\"DNS\",\"value\":\"проверяется\",\"ok\":null}]\n" +
|
||||||
|
"\n=== diag.log ===\nd\n"
|
||||||
|
)
|
||||||
|
assertThat(ReportContent.build("a\n", null, emptyList())).doesNotContain("checks.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun apiBaseAddsTheApiPrefixOnlyWhenMissing()
|
||||||
|
{
|
||||||
|
/* the proxy routes only /api/v1/ and the HMAC covers the full path */
|
||||||
|
assertThat(ReportUploader.apiBase("https://get.shnt.top")).isEqualTo("https://get.shnt.top/api/v1")
|
||||||
|
assertThat(ReportUploader.apiBase("https://get.shnt.top/")).isEqualTo("https://get.shnt.top/api/v1")
|
||||||
|
assertThat(ReportUploader.apiBase("https://get.shnt.top/api/v1/")).isEqualTo("https://get.shnt.top/api/v1")
|
||||||
|
assertThat(java.net.URL(ReportUploader.apiBase("http://127.0.0.1:4000") + "/reports").path).isEqualTo("/api/v1/reports")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseResponseAcceptsOnlyA201WithAValidCode()
|
||||||
|
{
|
||||||
|
assertThat(ReportUploader.parseResponse(201, """{"code":"SHX-7F3K2"}""")).isEqualTo(ReportUploader.Result.Sent("SHX-7F3K2"))
|
||||||
|
val broken = ReportUploader.parseResponse(201, "<html>proxy</html>")
|
||||||
|
assertThat(broken).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||||
|
assertThat((broken as ReportUploader.Result.Failed).httpCode).isEqualTo(201)
|
||||||
|
assertThat(ReportUploader.parseResponse(201, """{"code":"nope"}""")).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||||
|
val limited = ReportUploader.parseResponse(429, """{"error":{"code":"rate_limited"}}""") as ReportUploader.Result.Failed
|
||||||
|
assertThat(limited.httpCode).isEqualTo(429)
|
||||||
|
assertThat(limited.detail).contains("rate_limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failureMapsServerAnswersToReasons()
|
||||||
|
{
|
||||||
|
assertThat(ReportContent.failure(0)).isEqualTo(ReportContent.Failure.NO_NETWORK)
|
||||||
|
assertThat(ReportContent.failure(401)).isEqualTo(ReportContent.Failure.SIGNATURE)
|
||||||
|
assertThat(ReportContent.failure(413)).isEqualTo(ReportContent.Failure.TOO_LARGE)
|
||||||
|
assertThat(ReportContent.failure(429)).isEqualTo(ReportContent.Failure.TOO_OFTEN)
|
||||||
|
assertThat(ReportContent.failure(502)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
|
||||||
|
assertThat(ReportContent.failure(404)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
|
||||||
|
assertThat(ReportContent.failure(400)).isEqualTo(ReportContent.Failure.REJECTED)
|
||||||
|
assertThat(ReportContent.failure(201)).isEqualTo(ReportContent.Failure.REJECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun todayOnlyKeepsTodaysLinesWithTheirContinuations()
|
||||||
|
{
|
||||||
|
val log = """
|
||||||
|
Nov 7 23:59:58 06[IKE] old line
|
||||||
|
old continuation
|
||||||
|
Nov 8 00:00:01 06[IKE] initiating IKE_SA
|
||||||
|
payload continuation
|
||||||
|
Nov 8 14:02:30 07[IKE] CHILD_SA established
|
||||||
|
""".trimIndent()
|
||||||
|
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(
|
||||||
|
"Nov 8 00:00:01 06[IKE] initiating IKE_SA\n payload continuation\nNov 8 14:02:30 07[IKE] CHILD_SA established\n"
|
||||||
|
)
|
||||||
|
assertThat(ReportContent.todayOnly("Dec 18 10:00:00 01[IKE] two-digit day\n", 12, 18)).contains("two-digit day")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun todayOnlyFallsBackToTheWholeLogWhenNothingIsFromToday()
|
||||||
|
{
|
||||||
|
val log = "Nov 7 10:00:00 06[IKE] yesterday\n"
|
||||||
|
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(log)
|
||||||
|
/* a cut log starts with an undated mark: it alone must not count as "today" */
|
||||||
|
val cut = ReportContent.CUT_MARK + log
|
||||||
|
assertThat(ReportContent.todayOnly(cut, 11, 8)).isEqualTo(cut)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tailBytesCutsTheStartAtALineBoundaryAndKeepsCyrillicWhole()
|
||||||
|
{
|
||||||
|
val text = (1..200).joinToString("") { "строка $it: нет ответа\n" }
|
||||||
|
val cut = ReportContent.tailBytes(text, 1000)
|
||||||
|
assertThat(cut).startsWith(ReportContent.CUT_MARK)
|
||||||
|
val body = cut.removePrefix(ReportContent.CUT_MARK)
|
||||||
|
assertThat(body.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(1000)
|
||||||
|
assertThat(body).startsWith("строка ").endsWith("строка 200: нет ответа\n")
|
||||||
|
assertThat(body).doesNotContain("�")
|
||||||
|
assertThat(ReportContent.tailBytes("short\n", 1000)).isEqualTo("short\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tailBytesOfOneHugeLineDoesNotSplitACharacter()
|
||||||
|
{
|
||||||
|
val cut = ReportContent.tailBytes("ж".repeat(1000), 101).removePrefix(ReportContent.CUT_MARK)
|
||||||
|
assertThat(cut).doesNotContain("�")
|
||||||
|
assertThat(cut).isEqualTo("ж".repeat(50))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readTailDropsThePartialFirstLine()
|
||||||
|
{
|
||||||
|
val file = File.createTempFile("charon", ".log")
|
||||||
|
try
|
||||||
|
{
|
||||||
|
file.writeText("first line\nsecond line\nthird line\n")
|
||||||
|
assertThat(ReportContent.readTail(file, 1000)).isEqualTo("first line\nsecond line\nthird line\n")
|
||||||
|
assertThat(ReportContent.readTail(file, 15)).isEqualTo(ReportContent.CUT_MARK + "third line\n")
|
||||||
|
assertThat(ReportContent.readTail(File(file.path + ".missing"), 100)).isEmpty()
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildAddsDiagOnlyWhenAskedAndStaysWithinTheLimits()
|
||||||
|
{
|
||||||
|
val plain = ReportContent.build("Nov 8 14:02:31 08[IKE] tunnel up\n", null)
|
||||||
|
assertThat(plain).isEqualTo("=== charon.log ===\nNov 8 14:02:31 08[IKE] tunnel up\n")
|
||||||
|
val withDiag = ReportContent.build("", "11-08 14:02:00.000 Watchdog: ok\n")
|
||||||
|
assertThat(withDiag).contains("=== charon.log ===\n(empty)").contains("=== diag.log ===\n11-08 14:02:00.000 Watchdog: ok")
|
||||||
|
val huge = "x".repeat(99) + "\n"
|
||||||
|
val big = ReportContent.build(huge.repeat(40_000), huge.repeat(10_000))
|
||||||
|
assertThat(big.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(ReportContent.CHARON_MAX_BYTES + ReportContent.DIAG_MAX_BYTES + 200)
|
||||||
|
/* what the panel unpacks for the report card (5 MB cap) */
|
||||||
|
assertThat(big.length).isLessThan(5 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metaDescribesThePhoneAndPassesTheServerSchema()
|
||||||
|
{
|
||||||
|
val meta = ReportContent.meta("2.19.2", 170, "Google", "Pixel 7", "14", 34, "Wi-Fi", " Не подключается в метро ", "nl1 · nl1.shx.one")
|
||||||
|
assertThat(meta.device).isEqualTo("Google Pixel 7")
|
||||||
|
assertThat(ReportContent.meta("2.19.2", 170, "samsung", "SAMSUNG SM-S911B", "14", 34, "mobile", "", "").device).isEqualTo("SAMSUNG SM-S911B")
|
||||||
|
assertThat(meta.os).isEqualTo("Android 14 (API 34)")
|
||||||
|
assertThat(meta.comment).isEqualTo("Не подключается в метро")
|
||||||
|
val json = JSONObject(ReportUploader.metaJson(meta))
|
||||||
|
/* ReportMetaSchema: source is "log" | "diagnostics", versionCode an integer */
|
||||||
|
assertThat(json.getString("source")).isEqualTo("log")
|
||||||
|
assertThat(json.getInt("versionCode")).isEqualTo(170)
|
||||||
|
assertThat(json.getString("network")).isEqualTo("Wi-Fi")
|
||||||
|
assertThat(json.getString("profile")).isEqualTo("nl1 · nl1.shx.one")
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
|
class ReportUploaderTest
|
||||||
|
{
|
||||||
|
private val meta = ReportUploader.Meta("2.19.1", 169, "Pixel 7", "Android 14", "LTE", "log", "Не подключается", "nl1")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gzipRoundTripsTheLogAndStartsWithTheGzipMagic()
|
||||||
|
{
|
||||||
|
val gz = ReportUploader.gzip("09:38:11 IKE_SA_INIT → nl1 :4500\n")
|
||||||
|
assertThat(gz[0]).isEqualTo(0x1f.toByte())
|
||||||
|
assertThat(gz[1]).isEqualTo(0x8b.toByte())
|
||||||
|
assertThat(GZIPInputStream(ByteArrayInputStream(gz)).readBytes().decodeToString()).isEqualTo("09:38:11 IKE_SA_INIT → nl1 :4500\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metaJsonCarriesTheFieldsTheServerValidatesAndTruncatesLongOnes()
|
||||||
|
{
|
||||||
|
val json = JSONObject(ReportUploader.metaJson(meta.copy(comment = "x".repeat(2000), device = "d".repeat(500))))
|
||||||
|
assertThat(json.getString("versionName")).isEqualTo("2.19.1")
|
||||||
|
assertThat(json.getInt("versionCode")).isEqualTo(169)
|
||||||
|
assertThat(json.getString("comment")).hasSize(1000)
|
||||||
|
assertThat(json.getString("device")).hasSize(120)
|
||||||
|
assertThat(json.getString("source")).isEqualTo("log")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipartBodyHasTheLogAndMetaPartsInTheExpectedShape()
|
||||||
|
{
|
||||||
|
val body = ReportUploader.multipartBody(byteArrayOf(0x1f, 0x8b.toByte(), 1, 2), "{\"a\":1}").decodeToString(throwOnInvalidSequence = false)
|
||||||
|
assertThat(body).contains("name=\"log\"; filename=\"log.gz\"")
|
||||||
|
assertThat(body).contains("name=\"meta\"")
|
||||||
|
assertThat(body).contains("{\"a\":1}")
|
||||||
|
/* the meta part must stay a plain field (see multipartBody) */
|
||||||
|
assertThat(body.substringAfter("name=\"meta\"").substringBefore("{")).doesNotContain("Content-Type")
|
||||||
|
assertThat(body).endsWith("--${ReportUploader.BOUNDARY}--\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOversizedReportIsRefusedBeforeAnyRequest()
|
||||||
|
{
|
||||||
|
/* 62 symbols are ~6 bits each: 4 MB of text stays above 2 MB after gzip */
|
||||||
|
val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||||
|
val huge = java.util.Random(1).let { r -> buildString { repeat(4 * 1024 * 1024) { append(alphabet[r.nextInt(alphabet.size)]) } } }
|
||||||
|
val result = ReportUploader("https://api.example/api/v1", "k", "install-0001").send(huge, meta)
|
||||||
|
assertThat(result).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||||
|
assertThat((result as ReportUploader.Result.Failed).httpCode).isEqualTo(413)
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the client against the shared vectors of the web panel
|
||||||
|
* (packages/contracts/vectors/signing.json). If the server contract changes,
|
||||||
|
* the vectors are regenerated there and copied to src/test/resources.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class UpdateApiTest
|
||||||
|
{
|
||||||
|
private val vectors = JSONObject(
|
||||||
|
javaClass.classLoader!!.getResourceAsStream("shx-signing-vectors.json")!!.use { it.readBytes().decodeToString() })
|
||||||
|
private val publicKeyRaw = vectors.getJSONObject("ed25519").getString("publicKeyRawBase64")
|
||||||
|
private val manifest = vectors.getJSONObject("manifest")
|
||||||
|
private val hmacKey = vectors.getJSONObject("request").getString("hmacKey")
|
||||||
|
|
||||||
|
private fun fields(o: JSONObject) = UpdateSigning.Fields(
|
||||||
|
update = o.getBoolean("update"),
|
||||||
|
mandatory = o.getBoolean("mandatory"),
|
||||||
|
versionCode = o.optLong("versionCode", 0L),
|
||||||
|
versionName = o.optString("versionName", ""),
|
||||||
|
sha256 = o.optString("sha256", ""),
|
||||||
|
size = o.optLong("size", 0L),
|
||||||
|
apkUrl = o.optString("apkUrl", ""),
|
||||||
|
issuedAt = o.getLong("issuedAt"),
|
||||||
|
checkHours = o.getInt("checkHours"),
|
||||||
|
wifiOnly = o.getBoolean("wifiOnly"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun responseJson(v: JSONObject, patch: (JSONObject) -> Unit = {}): String
|
||||||
|
{
|
||||||
|
val f = v.getJSONObject("fields")
|
||||||
|
val body = JSONObject(f.toString())
|
||||||
|
body.put("signature", v.getString("signatureBase64"))
|
||||||
|
if (f.getBoolean("update"))
|
||||||
|
{
|
||||||
|
body.put("notes", JSONObject().put("ru", "Исправления").put("en", "Fixes"))
|
||||||
|
}
|
||||||
|
patch(body)
|
||||||
|
return body.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeHttp(private val response: UpdateHttp.Response) : UpdateHttp
|
||||||
|
{
|
||||||
|
var lastUrl: String? = null
|
||||||
|
var lastHeaders: Map<String, String> = emptyMap()
|
||||||
|
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
|
||||||
|
{
|
||||||
|
lastUrl = url
|
||||||
|
lastHeaders = headers
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(http: UpdateHttp, nowSeconds: Long = manifest.getJSONObject("fields").getLong("issuedAt")) =
|
||||||
|
UpdateApiClient("https://api.example/api/v1", hmacKey, publicKeyRaw, "install-0001", http) { nowSeconds * 1000 }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun manifestSigningInputMatchesTheServerVector()
|
||||||
|
{
|
||||||
|
assertThat(UpdateSigning.manifestInput(fields(manifest.getJSONObject("fields")))).isEqualTo(manifest.getString("signingInput"))
|
||||||
|
val none = vectors.getJSONObject("noUpdate")
|
||||||
|
assertThat(UpdateSigning.manifestInput(fields(none.getJSONObject("fields")))).isEqualTo(none.getString("signingInput"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ed25519SignatureFromTheServerVerifiesAndTamperingIsRejected()
|
||||||
|
{
|
||||||
|
val key = Base64.Default.decode(publicKeyRaw)
|
||||||
|
val input = manifest.getString("signingInput")
|
||||||
|
val signature = manifest.getString("signatureBase64")
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(key, input, signature)).isTrue()
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(key, input + "x", signature)).isFalse()
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(key, input.replace("250003", "250004"), signature)).isFalse()
|
||||||
|
assertThat(UpdateSigning.verifyEd25519(ByteArray(32), input, signature)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestSignatureMatchesTheServerVector()
|
||||||
|
{
|
||||||
|
val r = vectors.getJSONObject("request")
|
||||||
|
val input = UpdateSigning.requestInput(r.getString("method"), r.getString("path"), r.getString("timestamp").toLong())
|
||||||
|
assertThat(input).isEqualTo(r.getString("signingInput"))
|
||||||
|
assertThat(UpdateSigning.hmacBase64(hmacKey, input)).isEqualTo(r.getString("signatureBase64"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBareHostGetsTheApiPrefix()
|
||||||
|
{
|
||||||
|
/* UPDATE_API_URL=https://get.shnt.top (no /api/v1) used to hit /updates/check -> 404 -> silent legacy fallback */
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||||
|
val bare = UpdateApiClient("https://api.example/", hmacKey, publicKeyRaw, "install-0001", http) {
|
||||||
|
manifest.getJSONObject("fields").getLong("issuedAt") * 1000
|
||||||
|
}
|
||||||
|
assertThat(bare.check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)).isInstanceOf(UpdateApiResult.Update::class.java)
|
||||||
|
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientReturnsTheOfferedUpdateAndSendsASignedRequest()
|
||||||
|
{
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||||
|
val result = client(http).check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)
|
||||||
|
assertThat(result).isInstanceOf(UpdateApiResult.Update::class.java)
|
||||||
|
val update = result as UpdateApiResult.Update
|
||||||
|
assertThat(update.info.versionName).isEqualTo("2.5.0-beta.3")
|
||||||
|
assertThat(update.info.versionCode).isEqualTo(250003L)
|
||||||
|
assertThat(update.info.fromApi).isTrue()
|
||||||
|
assertThat(update.info.notes["ru"]).isEqualTo("Исправления")
|
||||||
|
assertThat(update.policy).isEqualTo(UpdatePolicy(12, true))
|
||||||
|
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?package=one.shx.strongswan.ext&versionCode=168")
|
||||||
|
assertThat(http.lastHeaders["X-Install-Id"]).isEqualTo("install-0001")
|
||||||
|
val ts = http.lastHeaders["X-Timestamp"]!!.toLong()
|
||||||
|
assertThat(http.lastHeaders["X-Signature"]).isEqualTo(
|
||||||
|
UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", "/api/v1/updates/check", ts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientAcceptsASignedNoUpdateAnswer()
|
||||||
|
{
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(vectors.getJSONObject("noUpdate"))))
|
||||||
|
val result = client(http).check("one.shx.strongswan.ext", 169, "release", null, 34, null)
|
||||||
|
assertThat(result).isInstanceOf(UpdateApiResult.NoUpdate::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tamperedFieldsAreRejectedAsASignatureFailureAndNeverFallBack()
|
||||||
|
{
|
||||||
|
for (field in listOf("sha256", "apkUrl", "versionCode", "versionName", "size", "mandatory", "checkHours", "wifiOnly"))
|
||||||
|
{
|
||||||
|
val body = responseJson(manifest) { o ->
|
||||||
|
when (field)
|
||||||
|
{
|
||||||
|
"mandatory", "wifiOnly" -> o.put(field, !o.getBoolean(field))
|
||||||
|
"versionCode", "size", "checkHours" -> o.put(field, o.getLong(field) + 1)
|
||||||
|
"sha256" -> o.put(field, "cd".repeat(32))
|
||||||
|
else -> o.put(field, o.getString(field) + "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = client(FakeHttp(UpdateHttp.Response(200, body))).check("one.shx.strongswan.ext", 1, "release", null, 34, null)
|
||||||
|
assertThat(result).describedAs(field).isInstanceOf(UpdateApiResult.Failure::class.java)
|
||||||
|
assertThat((result as UpdateApiResult.Failure).kind).describedAs(field).isEqualTo(UpdateError.SIGNATURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aStaleManifestIsRejectedEvenWithAValidSignature()
|
||||||
|
{
|
||||||
|
val issued = manifest.getJSONObject("fields").getLong("issuedAt")
|
||||||
|
val result = client(FakeHttp(UpdateHttp.Response(200, responseJson(manifest))), nowSeconds = issued + UpdateApiClient.MAX_SKEW_SECONDS + 60)
|
||||||
|
.check("one.shx.strongswan.ext", 1, "release", null, 34, null)
|
||||||
|
assertThat((result as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWrongPublicKeyFailsAndServerErrorsAreClassified()
|
||||||
|
{
|
||||||
|
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||||
|
val wrongKey = UpdateApiClient("https://api.example/api/v1", hmacKey, Base64.Default.encode(ByteArray(32) { 7 }), "install-0001", http) { 1760438401_000L }
|
||||||
|
assertThat((wrongKey.check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
|
||||||
|
// 404/5xx: сеть или сервер, допускается запасной путь; 401/429: нет
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(503, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(404, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(401, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
|
||||||
|
assertThat((client(FakeHttp(UpdateHttp.Response(429, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theClientIsConfiguredOnlyWithAllThreeValues()
|
||||||
|
{
|
||||||
|
assertThat(UpdateApiClient.isConfigured("https://api", "key", "pub")).isTrue()
|
||||||
|
assertThat(UpdateApiClient.isConfigured("", "key", "pub")).isFalse()
|
||||||
|
assertThat(UpdateApiClient.isConfigured("https://api", "", "pub")).isFalse()
|
||||||
|
assertThat(UpdateApiClient.isConfigured("https://api", "key", "")).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
class UpdateNotesTest
|
||||||
|
{
|
||||||
|
private val notes = mapOf("ru" to "Русский", "en" to "English", "zh-tw" to "繁體", "zh" to "中文")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun picksAppLanguageThenRussianThenEnglish()
|
||||||
|
{
|
||||||
|
assertThat(pickNotes(notes, "en-US", "en")).isEqualTo("English")
|
||||||
|
assertThat(pickNotes(notes, "zh-TW", "zh")).isEqualTo("繁體")
|
||||||
|
assertThat(pickNotes(notes, "zh-CN", "zh")).isEqualTo("中文")
|
||||||
|
assertThat(pickNotes(notes, "de-DE", "de")).isEqualTo("Русский")
|
||||||
|
assertThat(pickNotes(mapOf("en" to "English", "ru" to " "), "de-DE", "de")).isEqualTo("English")
|
||||||
|
assertThat(pickNotes(emptyMap(), "ru-RU", "ru")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyNonEmptyLineIsAnItem()
|
||||||
|
{
|
||||||
|
assertThat(noteLines("Серверы\n\n - История трафика \r\n• Исправлен сбой\n")).containsExactly("Серверы", "История трафика", "Исправлен сбой")
|
||||||
|
assertThat(noteLines("Одна строка. Без переносов.")).containsExactly("Одна строка. Без переносов.")
|
||||||
|
assertThat(noteLines(null)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun copiesWithDigestAndProgress()
|
||||||
|
{
|
||||||
|
val data = ByteArray(200_000) { it.toByte() }
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
var last = 0L
|
||||||
|
val total = copyDownload(ByteArrayInputStream(data), out, digest, { false }) { last = it }
|
||||||
|
assertThat(total).isEqualTo(data.size.toLong())
|
||||||
|
assertThat(last).isEqualTo(data.size.toLong())
|
||||||
|
assertThat(out.toByteArray()).isEqualTo(data)
|
||||||
|
assertThat(digest.digest()).isEqualTo(MessageDigest.getInstance("SHA-256").digest(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelStopsBetweenReads()
|
||||||
|
{
|
||||||
|
var cancelled = false
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
assertThatThrownBy {
|
||||||
|
copyDownload(ByteArrayInputStream(ByteArray(300_000)), out, MessageDigest.getInstance("SHA-256"), { cancelled }) { cancelled = true }
|
||||||
|
}.isInstanceOf(CancellationException::class.java)
|
||||||
|
/* one 64 KiB chunk, then the loop stopped */
|
||||||
|
assertThat(out.size()).isEqualTo(64 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedReadAfterCancelIsACancellation()
|
||||||
|
{
|
||||||
|
var cancelled = false
|
||||||
|
/* what disconnect() does to a blocked read */
|
||||||
|
val broken = object : InputStream()
|
||||||
|
{
|
||||||
|
override fun read(): Int = throw IOException("socket closed")
|
||||||
|
override fun read(b: ByteArray, off: Int, len: Int): Int
|
||||||
|
{
|
||||||
|
cancelled = true
|
||||||
|
throw IOException("socket closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThatThrownBy {
|
||||||
|
copyDownload(broken, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { cancelled }) {}
|
||||||
|
}.isInstanceOf(CancellationException::class.java)
|
||||||
|
/* without a cancel the network error stays a network error */
|
||||||
|
assertThatThrownBy {
|
||||||
|
copyDownload(object : InputStream() { override fun read(): Int = throw IOException("reset") }, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { false }) {}
|
||||||
|
}.isInstanceOf(IOException::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"note": "ТЕСТОВЫЙ ключ, только для проверки формата подписи.",
|
||||||
|
"ed25519": {
|
||||||
|
"privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA9Mac/nzlc6O6s6/5p7z3BfaggTulVsGYSLJyiG9/H8\n-----END PRIVATE KEY-----\n",
|
||||||
|
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY=\n-----END PUBLIC KEY-----\n",
|
||||||
|
"publicKeyRawBase64": "SE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY="
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"fields": {
|
||||||
|
"update": true,
|
||||||
|
"mandatory": false,
|
||||||
|
"versionCode": 250003,
|
||||||
|
"versionName": "2.5.0-beta.3",
|
||||||
|
"checkHours": 12,
|
||||||
|
"wifiOnly": true,
|
||||||
|
"sha256": "abababababababababababababababababababababababababababababababab",
|
||||||
|
"size": 38900000,
|
||||||
|
"apkUrl": "https://s3.example/app.apk?sig=1",
|
||||||
|
"issuedAt": 1760438401
|
||||||
|
},
|
||||||
|
"signingInput": "shx-manifest-v1\n1\n0\n250003\n2.5.0-beta.3\nabababababababababababababababababababababababababababababababab\n38900000\nhttps://s3.example/app.apk?sig=1\n1760438401\n12\n1",
|
||||||
|
"signatureBase64": "KkPqAQX4DwqhgNWdJecOePHsnPzOGCYAGNG25mQganJGQiD2MTl9YSrcHHeO5hoGeLoOa4rEU40rV++YtODGCw=="
|
||||||
|
},
|
||||||
|
"noUpdate": {
|
||||||
|
"fields": {
|
||||||
|
"update": false,
|
||||||
|
"mandatory": false,
|
||||||
|
"issuedAt": 1760438401,
|
||||||
|
"checkHours": 12,
|
||||||
|
"wifiOnly": true
|
||||||
|
},
|
||||||
|
"signingInput": "shx-manifest-v1\n0\n0\n0\n\n\n0\n\n1760438401\n12\n1",
|
||||||
|
"signatureBase64": "8Q5+fb7t6ot5iXYBPbv9CCspNzJOEVmQqHLh/6k2lNNtMxeyuTfbldKaKFzQV4orknVKijbf5ChgYDlC5pNvDQ=="
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"hmacKey": "test-hmac-key",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/updates/check",
|
||||||
|
"timestamp": "1760438400",
|
||||||
|
"signingInput": "GET|/api/v1/updates/check|1760438400",
|
||||||
|
"signatureBase64": "m6eQoNLWfDApDH2E1/q3aW/qG06t01/2zvxL1RD+Ckw="
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SHX VPN для Android
|
# SHX VPN для Android
|
||||||
|
|
||||||
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.1 (versionCode 166)**.
|
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.9 (versionCode 177)**.
|
||||||
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
||||||
|
|
||||||
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
| Серверы | Один сервер = один профиль `.sswan`. Добавление по QR, ссылке, файлу или вручную; проверка профиля перед добавлением; «Поделиться» QR-кодом без пароля; страница сервера: источник профиля, ежедневная проверка ссылки и лист изменений, запасные порты, резервный сервер, мост при белых списках, DNS, MTU, фрагментация IKE |
|
| Серверы | Один сервер = один профиль `.sswan`. Добавление по QR, ссылке, файлу или вручную; проверка профиля перед добавлением; «Поделиться» QR-кодом без пароля; страница сервера: источник профиля, ежедневная проверка ссылки и лист изменений, запасные порты, резервный сервер, мост при белых списках, DNS, MTU, фрагментация IKE |
|
||||||
| Маршруты | Раздельный туннель, сайты РФ напрямую (база адресов, режимы «Стабильно»/«Гибко»), приложения напрямую, правила (сайт, адрес/сеть, шаблон пакета), недавние приложения, недоступные хосты, «Как ходит трафик» |
|
| Маршруты | Раздельный туннель, сайты РФ напрямую (база адресов, режимы «Стабильно»/«Гибко»), приложения напрямую, правила (сайт, адрес/сеть, шаблон пакета), недавние приложения, недоступные хосты, «Как ходит трафик» |
|
||||||
| Сценарии | Правила для сетей (доверенная Wi-Fi / мобильная или чужая сеть) и свои сценарии: Wi-Fi, мобильная сеть, приложение открыто, время, роуминг, белые списки → включить, выключить, пауза, сменить сервер. Общий порядок, верхний важнее; ручное действие перекрывает сценарий до его окончания |
|
| Сценарии | Правила для сетей (доверенная Wi-Fi / мобильная или чужая сеть) и свои сценарии: Wi-Fi, мобильная сеть, приложение открыто, время, роуминг, белые списки → включить, выключить, пауза, сменить сервер. Общий порядок, верхний важнее; ручное действие перекрывает сценарий до его окончания |
|
||||||
| Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; «Без VPN — без сети» (инструкция к системному Always-on) |
|
| Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; свой DoH (`https://…`) или DoT (`tls://…`): DNS туннеля — виртуальный `198.18.0.53`, нативный DNS-прокси отдаёт запросы локальному резолверу `SecureDns` (127.0.0.1), тот шифрует их к серверу пользователя через физическую сеть; проба при подключении, иначе DNS профиля и событие; ошибки → SERVFAIL; всё пишется в diag.log без имён запросов; «Без VPN — без сети» (инструкция к системному Always-on) |
|
||||||
| Статистика | Трафик по дням/неделям/месяцам: через VPN и напрямую, по приложениям (нужен доступ к статистике использования), итоги недели |
|
| Статистика | Трафик по дням/неделям/месяцам: через VPN и напрямую, по приложениям (нужен доступ к статистике использования), итоги недели |
|
||||||
| Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку |
|
| Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку |
|
||||||
| События | Центр событий в приложении и push по каналам (статус, проблемы, новые приложения, обновления, итоги недели) |
|
| События | Центр событий в приложении и push по каналам (статус, проблемы, новые приложения, обновления, итоги недели); «Приложение аварийно завершилось» с кнопкой «Отправить отчёт» (`CrashWatch`: Java, нативный демон, ANR — на Android 11+ по истории завершений процесса); «Постоянный VPN выключили», если блокировка без VPN была включена (`AlwaysOnVpn`: статус спрашивает работающий `VpnService`, Android 10+) |
|
||||||
| Система | Виджеты трёх размеров (Glance), плитка быстрых настроек, ярлыки профилей, автообновление с проверкой SHA-256 и подписи |
|
| Система | Виджеты трёх размеров (Glance), плитка быстрых настроек, ярлыки профилей, автообновление с проверкой SHA-256 и подписи |
|
||||||
|
|
||||||
Профиль без сохранённого пароля никогда не запускается сам (watchdog, правила, сценарии, резерв, мост): вместо этого приходит событие со ссылкой на сервер.
|
Профиль без сохранённого пароля никогда не запускается сам (watchdog, правила, сценарии, резерв, мост): вместо этого приходит событие со ссылкой на сервер.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ a{color:#B69DFF}a:hover{color:#D0C2FF}
|
|||||||
class Component extends DCLogic {
|
class Component extends DCLogic {
|
||||||
renderVals() {
|
renderVals() {
|
||||||
var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]];
|
var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]];
|
||||||
var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IPv4 или IPv6 через запятую', false]];
|
var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IP, DoH (https://) или DoT (tls://)', false]];
|
||||||
return {
|
return {
|
||||||
toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#48454E', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #2B2930' : '0' }; }),
|
toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#48454E', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #2B2930' : '0' }; }),
|
||||||
dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #2B2930' : '0' }; })
|
dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #2B2930' : '0' }; })
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
|||||||
class Component extends DCLogic {
|
class Component extends DCLogic {
|
||||||
renderVals() {
|
renderVals() {
|
||||||
var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]];
|
var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]];
|
||||||
var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IPv4 или IPv6 через запятую', false]];
|
var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IP, DoH (https://) или DoT (tls://)', false]];
|
||||||
return {
|
return {
|
||||||
toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#C9C5D0', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #F0ECF4' : '0' }; }),
|
toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#C9C5D0', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #F0ECF4' : '0' }; }),
|
||||||
dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #F0ECF4' : '0' }; })
|
dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #F0ECF4' : '0' }; })
|
||||||
|
|||||||
@@ -139,3 +139,91 @@
|
|||||||
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
|
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
|
||||||
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
|
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
|
||||||
- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`.
|
- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`.
|
||||||
|
|
||||||
|
## 7. Версия 2.19.0: надёжность и лишняя работа (по результатам разбора багов)
|
||||||
|
|
||||||
|
**Стабильность соединения**
|
||||||
|
- `Scheduler` (будильники keepalive и rekey): `ELAPSED_REALTIME_WAKEUP` вместо `RTC_WAKEUP` — сдвиг часов (NTP, ручная смена, пояс)
|
||||||
|
больше не уводит срабатывание на часы вперёд. Очередь вынесена в `ScheduledJobQueue` (тесты): задания в окне 2 с делят один будильник
|
||||||
|
(раньше срабатывает не более чем на 2 с, поздно — никогда; charon ставит задания дольше 3 с). Каждый сэкономленный будильник — слот в квоте
|
||||||
|
allow-while-idle в Doze. Интервалы и keepalive 45 с не менялись. В `diag.log` один раз пишется, точные ли будильники.
|
||||||
|
- `BootReceiver` (`BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`): после перезагрузки и смены времени заново взводит будильник пауз
|
||||||
|
(раньше пауза после перезагрузки не заканчивалась никогда) и будильник окон сценариев, правила решают заново. Туннель сам не стартует;
|
||||||
|
профиль без сохранённого пароля по-прежнему не запускается.
|
||||||
|
- `ScenarioReceiver` ждёт привязки `VpnStateService` (до 8 с) через `goAsync`; раньше срабатывание свежего процесса терялось. `restoreIfApplied`
|
||||||
|
стирает запись о применённом сценарии только когда сервис есть.
|
||||||
|
- Будильник окон сценариев: точный, если разрешено (как у паузы).
|
||||||
|
- Сетевые сценарии (Wi-Fi, мобильная сеть, роуминг) слушают сеть и без включённых правил сетей (`AutoConnectManager.scenariosChanged`).
|
||||||
|
- Watchdog, поднявший туннель авто-сессии в свежем процессе, помечает сессию авто (`markAutoSessionRestored`): туннель паркуется на доверенной
|
||||||
|
Wi-Fi, а не работает всю ночь. Постоянный флаг не вводился сознательно: устаревший флаг запарковал бы туннель, запущенный вручную.
|
||||||
|
- `NetworkManager`: набор сетей вместо счётчика (повтор `onAvailable` или пропуск `onLost` не сдвигают состояние); подряд идущие одинаковые
|
||||||
|
события схлопываются в одно.
|
||||||
|
|
||||||
|
**Расход ресурсов**
|
||||||
|
- `direct_engine.c`: `tcp_active` сбрасывается после холостого раунда без TCP-потоков (`flow_count` в `direct_tcp.c` объявлен, но не был
|
||||||
|
реализован — теперь реализован), холостой интервал снова растягивается до 4 с; ветка «TUN не привязан» наращивает интервал, а не опрашивает
|
||||||
|
4 раза в секунду; `reprotect` очищает отрицательный кэш и при спящем lwIP.
|
||||||
|
- `TrafficHistory`: история в памяти, на диск раз в 5 мин, при остановке и смене дня; с погашенным экраном опрос раз в минуту; виджет обновляется
|
||||||
|
только при включённом экране. Теряется не более 5 мин статистики при убийстве процесса.
|
||||||
|
|
||||||
|
**Сознательно не делали (нет выигрыша или он не стоит риска)**
|
||||||
|
- Проверка здоровья уже растягивает паузы до штатного интервала и выключена по умолчанию, отдельная «остановка на мёртвом туннеле» не нужна.
|
||||||
|
- Проверка белых списков: цепочка идёт только пока белый список обнаружен, шаг не больше 5 мин; удлинение при погашенном экране отложило бы
|
||||||
|
возврат туннеля на минуты ради долей процента.
|
||||||
|
- Сеть без процесса (`registerNetworkCallback` на `PendingIntent`) — гипотеза не проверена на устройстве.
|
||||||
|
- `UnderlyingNetworkTracker` и SSID для сценариев из кэша `NetworkMonitor` — нужен лог с реальных сетей.
|
||||||
|
|
||||||
|
**Известное ограничение:** без «Будильников и напоминаний» keepalive в глубоком Doze остаётся под квотой; склейка заданий лишь освобождает слоты.
|
||||||
|
|
||||||
|
**Найдено при smoke-тесте 2.19.0:** `CharonVpnService` при старте по `Intent` без пароля (watchdog, конец паузы, `FgsStartRetry`) затирал сохранённый в профиле пароль значением `null`;
|
||||||
|
туннель после смерти процесса не возвращался («Password unavailable»). Теперь пароль из `Intent` подставляется, только если он там есть.
|
||||||
|
Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного
|
||||||
|
будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс,
|
||||||
|
`ScenarioReceiver` ждёт сервис и возвращает прежнее состояние VPN. Без реального сервера не проверены keepalive/rekey, NAT-таймауты и энергопотребление.
|
||||||
|
|
||||||
|
## 8. Версия 2.20: стабильность соединения и расход энергии (разбор 2.19.1)
|
||||||
|
|
||||||
|
Основа — чтение `NetworkManager`, `UnderlyingNetworkTracker`, `VpnStateService`, `android_net.c`, `charonservice.c`, `android_service.c`
|
||||||
|
и исходников charon. `CharonVpnService` и `direct_engine.c` целиком не разбирались: пункты ниже — гипотезы, пока их не подтвердит лог с телефона.
|
||||||
|
Условие раздела 5 (туннель не падает в фоне) действует для каждого пункта.
|
||||||
|
|
||||||
|
### 8.1. Шаг 0 — наблюдаемость (поведение не меняется)
|
||||||
|
- В `DiagLog` писать причину закрытия IKE SA: DPD/ретрансмиты, смена сети, закрыто сервером, действие пользователя; считать реконнекты в сутки.
|
||||||
|
Новых строк интерфейса нет. Без этих данных 8.2–8.4 решаются вслепую.
|
||||||
|
- Замеры на телефоне делает владелец: ночь с погашенным экраном на Wi-Fi и на мобильной сети; до и после — `dumpsys batterystats --reset` /
|
||||||
|
`dumpsys batterystats one.shx.strongswan.ext`, `dumpsys alarm`, `dumpsys deviceidle force-idle`.
|
||||||
|
|
||||||
|
### 8.2. Окно ретрансмитов (стабильность и энергия)
|
||||||
|
- Сейчас `retransmit_tries=3`, `timeout=2,0`, `base=1,4` (`charonservice.c:59-61`): первая отправка и 3 повтора, после ожидания третьего
|
||||||
|
повтора (`task_manager_v2.c:403`) IKE SA закрывается. Итого 2 + 2,8 + 3,9 + 5,5 ≈ 14 с без ответа; 5 повторов дали бы ≈ 33 с.
|
||||||
|
- Это относится к обычным запросам (DPD, rekey). У MOBIKE-проверки пути свой бюджет (`ROUTABILITY_CHECK_TRIES`), он от этих настроек не зависит.
|
||||||
|
- После закрытия SA charon поднимает его заново сам: `dpd_action` и `close_action` = `ACTION_START`, `keyingtries = 6` (`android_service.c:885-896`).
|
||||||
|
Java-backoff (5 с × 2ⁿ, до 120 с) включается, только когда попытки исчерпаны. Цена короткого провала связи — полный IKE_SA_INIT + AUTH
|
||||||
|
(радио, CPU, туннель недоступен на время переустановки). Как Java реагирует на `CHILD_STATE_DOWN` при этом, не разбиралось.
|
||||||
|
- Настройки читаются при создании IKE SA: «больше попыток только на мобильной сети» при роуминге не включится — либо выбор по транспорту
|
||||||
|
при подключении, либо одно значение для всех.
|
||||||
|
- Решать по логу 8.1: много падений по DPD на коротких провалах — поднять; мало — не трогать. Цена: дольше обнаружение реально мёртвого пути.
|
||||||
|
|
||||||
|
### 8.3. Привязка сокета IKE после смены сети (гипотеза, только по логу)
|
||||||
|
- Состояние `connected` из `NetworkManager` в нативе нужно одному: `android_net.c` ставит повторную проверку через 1 с после `ENETUNREACH`.
|
||||||
|
Roam-событие поднимается при любом изменении, поэтому версия «Wi-Fi без интернета считается связью и IKE уходит в пустоту» из кода не следует.
|
||||||
|
- Остаётся вопрос, на какую сеть привязан сокет IKE после roam (`bypass_socket` → `protect`) и совпадает ли она с `UnderlyingNetworkTracker`,
|
||||||
|
который требует `VALIDATED`. Риск: в сетях с белыми списками (`WhiteListManager`) проверка системы может не проходить при доступном сервере.
|
||||||
|
`NetworkManager` и условия сети не менять без лога с реальным случаем; приёмка по разделу 5.
|
||||||
|
|
||||||
|
### 8.4. Keepalive по транспорту (энергия, слоты квоты Doze) — пересматривает решение «45 с»
|
||||||
|
- 45 с ≈ 80 будильников/ч при квоте 72/ч (7/ч без точных будильников). На Wi-Fi 90–120 с вдвое сократили бы число будильников.
|
||||||
|
- `keep_alive` читается при создании IKE SA (`ike_sa.c:3283`): после смены Wi-Fi ↔ LTE значение не обновится без переподключения.
|
||||||
|
- Риск: домашние роутеры с UDP-таймаутом 30 с. Только после замера, как необязательная настройка профиля; умолчание не менять.
|
||||||
|
|
||||||
|
### 8.5. Нативный роутер и логи (после Perfetto)
|
||||||
|
- `direct_engine.c`: при погашенном экране растянуть холостой интервал с 4 до 10–30 с (пробуждение через `wake`-fd), не вызывать
|
||||||
|
`check_timers` вхолостую. Только при включённом режиме «сайты РФ напрямую».
|
||||||
|
- `DiagLog`: при погашенном экране не писать повторяющиеся строки (watchdog, смена сети); `flush` оставить.
|
||||||
|
|
||||||
|
### 8.6. Порядок и граница
|
||||||
|
1. Релиз 1: шаг 0 (8.1), поведение не меняется. Владелец проверяет сборку из S3 и присылает `diag.log` и цифры.
|
||||||
|
2. Релизы 2–3: 8.2 и 8.3 — только если лог это оправдывает; каждый отдельно, с приёмкой раздела 5.
|
||||||
|
3. 8.4 — после замера на Wi-Fi и мобильной сети. 8.5 — после Perfetto.
|
||||||
|
- Не трогать: keepalive 45 с на мобильной сети, foreground-сервис, будильники `Scheduler`/`WatchdogReceiver`, DPD,
|
||||||
|
`NetworkManager`/`UnderlyingNetworkTracker` без приёмки.
|
||||||
@@ -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