diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle
index 431f58385..f71c383f5 100644
--- a/src/frontends/android/app/build.gradle
+++ b/src/frontends/android/app/build.gradle
@@ -168,6 +168,9 @@ dependencies {
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23 */
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
implementation 'androidx.compose.material:material-icons-extended'
+ /* home screen widget (status + connect button) */
+ implementation 'androidx.glance:glance-appwidget:1.2.0'
+ implementation 'androidx.glance:glance-material3:1.2.0'
implementation 'androidx.activity:activity-compose:1.11.0'
debugImplementation 'androidx.compose.ui:ui-tooling'
diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index 40f2b91ac..a0e3cdd7c 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -247,6 +247,18 @@
+
+
+
+
+
+
+
= Build.VERSION_CODES.Q)
+ { /* keep the action label, show the profile as the tile subtitle */
+ tile.setSubtitle(profile.getName());
+ }
+ else
+ {
+ tile.setLabel(profile.getName());
+ }
}
tile.updateTile();
}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt
new file mode 100644
index 000000000..4f9209594
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt
@@ -0,0 +1,210 @@
+/*
+ * 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.widget
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.Button
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.appWidgetBackground
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.appwidget.updateAll
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import androidx.preference.PreferenceManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.strongswan.android.R
+import org.strongswan.android.data.VpnProfileSource
+import org.strongswan.android.logic.VpnStateService
+import org.strongswan.android.ui.VpnProfileControlActivity
+import org.strongswan.android.utils.DefaultVpnProfile
+
+/** What the widget shows; persisted by [ShxWidgetState] so it survives process death. */
+internal data class WidgetSnapshot(
+ val state: String,
+ val error: Boolean,
+ val profileName: String?,
+ val profileUuid: String?,
+)
+
+/**
+ * Bridges the VPN state (VpnStateService) to the home screen widget. The widget
+ * itself never binds the service: the service publishes every change here.
+ */
+object ShxWidgetState
+{
+ private const val KEY_STATE = "widget_state"
+ private const val KEY_ERROR = "widget_error"
+ private const val KEY_NAME = "widget_profile_name"
+ private const val KEY_UUID = "widget_profile_uuid"
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ @JvmStatic
+ fun publish(
+ context: Context,
+ state: VpnStateService.State,
+ error: VpnStateService.ErrorState,
+ profileName: String?,
+ profileUuid: String?,
+ )
+ {
+ val app = context.applicationContext
+ val editor = PreferenceManager.getDefaultSharedPreferences(app).edit()
+ .putString(KEY_STATE, state.name)
+ .putBoolean(KEY_ERROR, error != VpnStateService.ErrorState.NO_ERROR)
+ if (profileUuid != null)
+ {
+ editor.putString(KEY_NAME, profileName).putString(KEY_UUID, profileUuid)
+ }
+ editor.apply()
+ scope.launch {
+ try
+ {
+ if (GlanceAppWidgetManager(app).getGlanceIds(ShxWidget::class.java).isNotEmpty())
+ {
+ ShxWidget().updateAll(app)
+ }
+ }
+ catch (_: Exception)
+ {
+ }
+ }
+ }
+
+ internal suspend fun snapshot(context: Context): WidgetSnapshot = withContext(Dispatchers.IO)
+ {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ var name = prefs.getString(KEY_NAME, null)
+ var uuid = prefs.getString(KEY_UUID, null)
+ if (uuid == null)
+ { /* never connected yet: fall back to the configured default profile */
+ val source = VpnProfileSource(context)
+ source.open()
+ try
+ {
+ source.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(context))?.let {
+ name = it.name
+ uuid = it.uuid.toString()
+ }
+ }
+ finally
+ {
+ source.close()
+ }
+ }
+ val state = prefs.getString(KEY_STATE, null) ?: VpnStateService.State.DISABLED.name
+ WidgetSnapshot(state, prefs.getBoolean(KEY_ERROR, false), name, uuid)
+ }
+}
+
+/** 2x1 widget: protection status, current profile and a connect / disconnect button. */
+class ShxWidget : GlanceAppWidget()
+{
+ override val sizeMode = SizeMode.Single
+
+ override suspend fun provideGlance(context: Context, id: GlanceId)
+ {
+ val snapshot = ShxWidgetState.snapshot(context)
+ provideContent {
+ GlanceTheme {
+ WidgetContent(context, snapshot)
+ }
+ }
+ }
+}
+
+@Composable
+private fun WidgetContent(context: Context, snapshot: WidgetSnapshot)
+{
+ val connected = snapshot.state == VpnStateService.State.CONNECTED.name && !snapshot.error
+ val connecting = snapshot.state == VpnStateService.State.CONNECTING.name && !snapshot.error
+ val status = context.getString(
+ when
+ {
+ connected -> R.string.status_protected
+ connecting -> R.string.status_connecting
+ else -> R.string.status_unprotected
+ }
+ )
+ val buttonLabel = context.getString(if (connected || connecting) R.string.disconnect else R.string.connect)
+ /* same entry point as the quick settings tile */
+ val intent = Intent(context, VpnProfileControlActivity::class.java)
+ if (connected || connecting)
+ {
+ intent.action = VpnProfileControlActivity.DISCONNECT
+ }
+ else
+ {
+ intent.action = VpnProfileControlActivity.START_PROFILE
+ intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, snapshot.profileUuid)
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ Row(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .appWidgetBackground()
+ .background(GlanceTheme.colors.widgetBackground)
+ .cornerRadius(24.dp)
+ .padding(12.dp),
+ verticalAlignment = Alignment.Vertical.CenterVertically,
+ )
+ {
+ Column(modifier = GlanceModifier.defaultWeight())
+ {
+ Text(
+ text = status,
+ style = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ color = GlanceTheme.colors.onSurface,
+ ),
+ maxLines = 1,
+ )
+ Text(
+ text = snapshot.profileName ?: context.getString(R.string.status_choose_profile),
+ style = TextStyle(fontSize = 12.sp, color = GlanceTheme.colors.onSurfaceVariant),
+ maxLines = 1,
+ )
+ }
+ Button(
+ text = buttonLabel,
+ onClick = actionStartActivity(intent),
+ )
+ }
+}
+
+class ShxWidgetReceiver : GlanceAppWidgetReceiver()
+{
+ override val glanceAppWidget: GlanceAppWidget = ShxWidget()
+}
diff --git a/src/frontends/android/app/src/main/res/layout/shx_widget_loading.xml b/src/frontends/android/app/src/main/res/layout/shx_widget_loading.xml
new file mode 100644
index 000000000..03da6d677
--- /dev/null
+++ b/src/frontends/android/app/src/main/res/layout/shx_widget_loading.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml
index 2c56d480f..860d12604 100644
--- a/src/frontends/android/app/src/main/res/values-ru/strings.xml
+++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml
@@ -499,4 +499,6 @@
Системная
Светлая
Тёмная
+ SHX VPN
+ Статус защиты и подключение одним касанием
diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml
index bfcad5962..a627a1b64 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -504,4 +504,6 @@
System
Light
Dark
+ SHX VPN
+ Protection status and one-tap connect
diff --git a/src/frontends/android/app/src/main/res/xml/shx_widget_info.xml b/src/frontends/android/app/src/main/res/xml/shx_widget_info.xml
new file mode 100644
index 000000000..de0e3dd04
--- /dev/null
+++ b/src/frontends/android/app/src/main/res/xml/shx_widget_info.xml
@@ -0,0 +1,11 @@
+
+