android: home screen widget (Glance), tile subtitle with profile name
This commit is contained in:
1 parent
0c3ea75cac
commit
22e4082825
9 files changed
+258
-1
No files matched your search
@@ -168,6 +168,9 @@ dependencies {
|
|||||||
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23 */
|
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23 */
|
||||||
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
|
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
|
||||||
implementation 'androidx.compose.material:material-icons-extended'
|
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'
|
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||||
|
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
|
|||||||
@@ -247,6 +247,18 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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
|
<provider
|
||||||
android:name=".data.LogContentProvider"
|
android:name=".data.LogContentProvider"
|
||||||
android:authorities="one.shx.strongswan.ext.content.log"
|
android:authorities="one.shx.strongswan.ext.content.log"
|
||||||
|
|||||||
+6
@@ -31,6 +31,7 @@ import android.util.Log;
|
|||||||
|
|
||||||
import org.strongswan.android.R;
|
import org.strongswan.android.R;
|
||||||
import org.strongswan.android.data.VpnProfile;
|
import org.strongswan.android.data.VpnProfile;
|
||||||
|
import org.strongswan.android.ui.widget.ShxWidgetState;
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
import org.strongswan.android.data.VpnProfileDataSource;
|
||||||
import org.strongswan.android.data.VpnType;
|
import org.strongswan.android.data.VpnType;
|
||||||
import org.strongswan.android.logic.imc.ImcState;
|
import org.strongswan.android.logic.imc.ImcState;
|
||||||
@@ -592,6 +593,11 @@ public class VpnStateService extends Service
|
|||||||
{
|
{
|
||||||
listener.stateChanged();
|
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)
|
catch (Exception e)
|
||||||
|
|||||||
+8
-1
@@ -269,7 +269,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
|||||||
}
|
}
|
||||||
if (profile != null && !isSecure())
|
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();
|
tile.updateTile();
|
||||||
}
|
}
|
||||||
|
|||||||
+210
@@ -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_system">Системная</string>
|
||||||
<string name="theme_light">Светлая</string>
|
<string name="theme_light">Светлая</string>
|
||||||
<string name="theme_dark">Тёмная</string>
|
<string name="theme_dark">Тёмная</string>
|
||||||
|
<string name="widget_label">SHX VPN</string>
|
||||||
|
<string name="widget_description">Статус защиты и подключение одним касанием</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -504,4 +504,6 @@
|
|||||||
<string name="theme_system">System</string>
|
<string name="theme_system">System</string>
|
||||||
<string name="theme_light">Light</string>
|
<string name="theme_light">Light</string>
|
||||||
<string name="theme_dark">Dark</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>
|
</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" />
|
||||||
Reference in new issue
Block a user