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 @@ + +