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 */
|
||||
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"
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+8
-1
@@ -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();
|
||||
}
|
||||
|
||||
+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_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" />
|
||||
Reference in new issue
Block a user