android: home screen widget (Glance), tile subtitle with profile name

This commit is contained in:
Denozordec committed 2026-10-10 00:13:07 +07:00
1 parent 0c3ea75cac
commit 22e4082825
9 files changed
+258 -1

No files matched your search

+3
View File
@@ -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'
@@ -247,6 +247,18 @@
</intent-filter>
</service>
<receiver
android:name=".ui.widget.ShxWidgetReceiver"
android:label="@string/widget_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/shx_widget_info" />
</receiver>
<provider
android:name=".data.LogContentProvider"
android:authorities="one.shx.strongswan.ext.content.log"
@@ -31,6 +31,7 @@ import android.util.Log;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.ui.widget.ShxWidgetState;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.logic.imc.ImcState;
@@ -592,6 +593,11 @@ public class VpnStateService extends Service
{
listener.stateChanged();
}
/* the home screen widget mirrors the state */
VpnProfile widgetProfile = mProfile;
ShxWidgetState.publish(VpnStateService.this, mState, getErrorState(),
widgetProfile != null ? widgetProfile.getName() : null,
widgetProfile != null ? widgetProfile.getUUID().toString() : null);
}
}
catch (Exception e)
@@ -269,7 +269,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
}
if (profile != null && !isSecure())
{
tile.setLabel(profile.getName());
if (Build.VERSION.SDK_INT >= 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();
}
@@ -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()
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />
@@ -499,4 +499,6 @@
<string name="theme_system">Системная</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Тёмная</string>
<string name="widget_label">SHX VPN</string>
<string name="widget_description">Статус защиты и подключение одним касанием</string>
</resources>
@@ -504,4 +504,6 @@
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="widget_label">SHX VPN</string>
<string name="widget_description">Protection status and one-tap connect</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:minWidth="110dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:resizeMode="horizontal"
android:widgetCategory="home_screen"
android:initialLayout="@layout/shx_widget_loading" />