Skip to content

Commit 3c7ba50

Browse files
authored
add widget (#210)
* perform simple widget rendering # Conflicts: # app/src/main/java/org/blitzortung/android/alert/handler/AlertHandler.kt # app/src/main/java/org/blitzortung/android/app/WidgetProvider.kt * wip * small updates * update widget every 5 minutes * improve widget view integration * update * update * working widget * working widget * update * mostly working * update * try to fix the empty widget after loading * mostly there * add preview * update * update * fixed image * update * update version * update * fix version name * update * migrate to AGP 9 * need to disable detekt for now * lighter background * widget update on tap and progress indicator * fix location handler integration * review fixes * review fixes * add tests and disable detekt * add a location info overlay to the widget * fix formatting * revert to AGP 8 * revert to AGP 8 * fix formatting * enable detekt * fix detekt * add missing files * extend memory * cap bitmap allocation * add tests * add tests * remove location overlay * fix data time interval
1 parent 08f20a7 commit 3c7ba50

33 files changed

Lines changed: 1655 additions & 97 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,6 @@ jobs:
4747
- name: Run static analysis (Detekt)
4848
run: ./gradlew detekt
4949

50-
- name: Run code formatting check (ktlint)
51-
run: ./gradlew ktlintCheck
52-
5350
- name: Upload Detekt reports
5451
if: always()
5552
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -59,6 +56,9 @@ jobs:
5956
build/reports/detekt/
6057
app/build/reports/detekt/
6158
59+
- name: Run code formatting check (ktlint)
60+
run: ./gradlew ktlintCheck
61+
6262
- name: Upload ktlint reports
6363
if: always()
6464
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

app/build.gradle.kts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ android {
1414
applicationId = "org.blitzortung.android.app"
1515
minSdk = 23
1616
targetSdk = 35
17-
versionCode = 347
18-
versionName = "2.4.6"
17+
versionCode = 348
18+
versionName = "2.5-beta1"
1919
multiDexEnabled = false
2020
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2121
}
@@ -72,11 +72,9 @@ android {
7272
}
7373

7474
namespace = "org.blitzortung.android.app"
75-
76-
buildToolsVersion = "35.0.0"
7775
}
7876

79-
val daggerVersion = "2.57.2"
77+
val daggerVersion = "2.59.2"
8078

8179
dependencies {
8280
implementation("androidx.appcompat:appcompat:1.7.1")
@@ -99,9 +97,9 @@ dependencies {
9997

10098
// Unit Testing
10199
testImplementation("junit:junit:4.13.2")
102-
testImplementation("org.assertj:assertj-core:3.27.6")
103-
testImplementation("io.mockk:mockk:1.14.6")
104-
testImplementation("org.robolectric:robolectric:4.16")
100+
testImplementation("org.assertj:assertj-core:3.27.7")
101+
testImplementation("io.mockk:mockk:1.14.9")
102+
testImplementation("org.robolectric:robolectric:4.16.1")
105103
testImplementation("androidx.test:core:1.7.0")
106104
testImplementation("androidx.test:core-ktx:1.7.0")
107105
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -131,19 +129,20 @@ dependencies {
131129
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
132130
androidTestImplementation("androidx.test.ext:junit:1.3.0")
133131
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
134-
androidTestImplementation("io.mockk:mockk-android:1.14.6")
132+
androidTestImplementation("io.mockk:mockk-android:1.14.9")
135133
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
136134

137135
// Compose Testing (if needed in future)
138-
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.5")
139-
debugImplementation("androidx.compose.ui:ui-test-manifest:1.9.5")
136+
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.10.2")
137+
debugImplementation("androidx.compose.ui:ui-test-manifest:1.10.2")
140138
}
141139

142140
kapt {
143141
includeCompileClasspath = false
144142
}
145143

146144
tasks.withType<Test> {
145+
jvmArgs("-Xmx4g", "-XX:MaxMetaspaceSize=1g")
147146
configure<JacocoTaskExtension> {
148147
isIncludeNoLocationClasses = true
149148
excludes = listOf("jdk.internal.*")

app/src/main/AndroidManifest.xml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,23 @@
5151
<action android:name="android.intent.action.BOOT_COMPLETED" />
5252
</intent-filter>
5353
</receiver>
54-
<!--<receiver android:name=".WidgetProvider">
55-
<meta-data android:name="android.appwidget.provider"
56-
android:resource="@xml/widget_provider" />
54+
<receiver
55+
android:name=".WidgetClickReceiver"
56+
android:exported="false">
57+
<intent-filter>
58+
<action android:name="org.blitzortung.android.app.ACTION_WIDGET_CLICK" />
59+
</intent-filter>
60+
</receiver>
61+
<receiver
62+
android:name=".WidgetProvider"
63+
android:exported="true">
64+
<meta-data
65+
android:name="android.appwidget.provider"
66+
android:resource="@xml/widget_provider" />
5767
<intent-filter>
5868
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
5969
</intent-filter>
60-
</receiver>-->
70+
</receiver>
6171
</application>
6272
<queries>
6373
<intent>

app/src/main/java/org/blitzortung/android/alert/Warning.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ data class LocalActivity(
5252
get() = sectorWithClosestStrike?.label ?: "n/a"
5353

5454
override fun toString(): String {
55-
return "%s %.1f %s".format(bearingName, closestStrikeDistance, parameters.measurementSystem)
55+
return if (closestStrikeDistance == Float.POSITIVE_INFINITY) {
56+
bearingName
57+
} else {
58+
"%s %.1f %s".format(bearingName, closestStrikeDistance, parameters.measurementSystem.unitSymbol)
59+
}
5660
}
5761
}

app/src/main/java/org/blitzortung/android/alert/handler/AlertHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ constructor(
8484

8585
private var signalingLastTimestamp: Long = 0
8686

87-
private val locationEventConsumer: (LocationEvent) -> Unit = { event ->
87+
internal val locationEventConsumer: (LocationEvent) -> Unit = { event ->
8888
Log.v(Main.LOG_TAG, "AlertHandler.locationEventConsumer ${event}")
8989

9090
if (event is LocationUpdate) {

app/src/main/java/org/blitzortung/android/app/BOApplication.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import dagger.android.AndroidInjector
55
import dagger.android.DispatchingAndroidInjector
66
import dagger.android.HasAndroidInjector
77
import javax.inject.Inject
8+
import org.blitzortung.android.dagger.component.AppComponent
89
import org.blitzortung.android.dagger.component.DaggerAppComponent
910
import org.blitzortung.android.dagger.module.AppModule
1011
import org.blitzortung.android.dagger.module.ServiceModule
@@ -13,17 +14,21 @@ class BOApplication : Application(), HasAndroidInjector {
1314
@set:Inject
1415
lateinit var androidInjector: DispatchingAndroidInjector<Any>
1516

17+
lateinit var component: AppComponent
18+
private set
19+
1620
override fun androidInjector(): AndroidInjector<Any> = androidInjector
1721

1822
override fun onCreate() {
1923
super.onCreate()
2024

21-
DaggerAppComponent
25+
component = DaggerAppComponent
2226
.builder()
2327
.appModule(AppModule(this))
2428
.serviceModule(ServiceModule())
2529
.build()
26-
.inject(this)
30+
31+
component.inject(this)
2732
}
2833

2934
companion object {

app/src/main/java/org/blitzortung/android/app/Main.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener {
8888
private var backgroundAlertEnabled: Boolean = false
8989
private lateinit var statusComponent: StatusComponent
9090

91-
private lateinit var strikeColorHandler: StrikeColorHandler
91+
@set:Inject
92+
internal lateinit var strikeColorHandler: StrikeColorHandler
93+
9294
private lateinit var strikeListOverlay: StrikeListOverlay
9395
private lateinit var ownLocationOverlay: OwnLocationOverlay
9496
private lateinit var fadeOverlay: FadeOverlay
@@ -235,8 +237,6 @@ class Main : FragmentActivity(), OnSharedPreferenceChangeListener {
235237
PreferenceManager.setDefaultValues(this, R.xml.preferences, false)
236238
preferences.registerOnSharedPreferenceChangeListener(this)
237239

238-
strikeColorHandler = StrikeColorHandler(preferences)
239-
240240
statusComponent =
241241
StatusComponent(
242242
findViewById(R.id.warning),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package org.blitzortung.android.app
2+
3+
import android.app.PendingIntent
4+
import android.appwidget.AppWidgetManager
5+
import android.content.BroadcastReceiver
6+
import android.content.Context
7+
import android.content.Intent
8+
import android.util.Log
9+
import android.view.View
10+
import android.widget.RemoteViews
11+
import androidx.work.OneTimeWorkRequestBuilder
12+
import androidx.work.WorkManager
13+
14+
class WidgetClickReceiver : BroadcastReceiver() {
15+
16+
companion object {
17+
const val ACTION_WIDGET_CLICK = "org.blitzortung.android.app.ACTION_WIDGET_CLICK"
18+
private const val DOUBLE_CLICK_THRESHOLD_MS = 500L
19+
20+
@Volatile
21+
private var lastClickTime = 0L
22+
}
23+
24+
override fun onReceive(context: Context, intent: Intent) {
25+
Log.v(Main.LOG_TAG, "WidgetClickReceiver.onReceive() action=${intent.action}")
26+
if (intent.action != ACTION_WIDGET_CLICK) {
27+
return
28+
}
29+
30+
val currentTime = System.currentTimeMillis()
31+
val previousClickTime = lastClickTime
32+
lastClickTime = currentTime
33+
34+
if (currentTime - previousClickTime < DOUBLE_CLICK_THRESHOLD_MS) {
35+
// Double click detected - open the main app
36+
Log.v(Main.LOG_TAG, "WidgetClickReceiver: double-click detected, opening main app")
37+
val mainIntent = Intent(context, Main::class.java).apply {
38+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
39+
}
40+
context.startActivity(mainIntent)
41+
} else {
42+
// Single click - trigger widget update
43+
Log.v(Main.LOG_TAG, "WidgetClickReceiver: single click detected, triggering update")
44+
showProgressIndicator(context)
45+
val workRequest = OneTimeWorkRequestBuilder<WidgetUpdateWorker>().build()
46+
WorkManager.getInstance(context).enqueue(workRequest)
47+
}
48+
}
49+
50+
private fun showProgressIndicator(context: Context) {
51+
val appWidgetManager = AppWidgetManager.getInstance(context)
52+
val appWidgetIds = appWidgetManager.getAppWidgetIds(
53+
android.content.ComponentName(context, WidgetProvider::class.java)
54+
)
55+
if (appWidgetIds.isEmpty()) return
56+
57+
for (appWidgetId in appWidgetIds) {
58+
val views = RemoteViews(context.packageName, R.layout.widget)
59+
views.setViewVisibility(R.id.widget_progress, View.VISIBLE)
60+
appWidgetManager.partiallyUpdateAppWidget(appWidgetId, views)
61+
}
62+
}
63+
}

app/src/main/java/org/blitzortung/android/app/WidgetProvider.kt

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,33 +21,83 @@ package org.blitzortung.android.app
2121
import android.appwidget.AppWidgetManager
2222
import android.appwidget.AppWidgetProvider
2323
import android.content.Context
24-
import android.widget.RemoteViews
25-
import org.blitzortung.android.app.view.AlarmView
24+
import android.util.Log
25+
import androidx.work.Constraints
26+
import androidx.work.ExistingPeriodicWorkPolicy
27+
import androidx.work.ExistingWorkPolicy
28+
import androidx.work.NetworkType
29+
import androidx.work.OneTimeWorkRequestBuilder
30+
import androidx.work.PeriodicWorkRequestBuilder
31+
import androidx.work.WorkManager
32+
import java.util.concurrent.TimeUnit
2633

27-
class WidgetProvider : AppWidgetProvider() {
28-
override fun onUpdate(
34+
open class WidgetProvider : AppWidgetProvider() {
35+
36+
companion object {
37+
const val WIDGET_UPDATE_WORK_NAME = "widget_update_work"
38+
const val WIDGET_IMMEDIATE_UPDATE_WORK_NAME = "widget_immediate_update_work"
39+
const val UPDATE_INTERVAL_MINUTES = 15L
40+
}
41+
42+
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
43+
super.onUpdate(context, appWidgetManager, appWidgetIds)
44+
Log.v(Main.LOG_TAG, "WidgetProvider.onUpdate() - re-rendering with new size")
45+
scheduleImmediateUpdate(context)
46+
scheduleNextUpdate(context)
47+
}
48+
49+
override fun onEnabled(context: Context) {
50+
super.onEnabled(context)
51+
Log.v(Main.LOG_TAG, "WidgetProvider.onEnabled() - Scheduling immediate and periodic updates")
52+
scheduleImmediateUpdate(context)
53+
scheduleNextUpdate(context)
54+
}
55+
56+
protected open fun getWorkManager(context: Context): WorkManager = WorkManager.getInstance(context)
57+
58+
private fun scheduleImmediateUpdate(context: Context) {
59+
val constraints = Constraints.Builder()
60+
.setRequiredNetworkType(NetworkType.CONNECTED)
61+
.build()
62+
val workRequest = OneTimeWorkRequestBuilder<WidgetUpdateWorker>()
63+
.setConstraints(constraints)
64+
.build()
65+
getWorkManager(context).enqueueUniqueWork(
66+
WIDGET_IMMEDIATE_UPDATE_WORK_NAME,
67+
ExistingWorkPolicy.REPLACE,
68+
workRequest
69+
)
70+
}
71+
72+
override fun onDisabled(context: Context) {
73+
super.onDisabled(context)
74+
Log.v(Main.LOG_TAG, "WidgetProvider.onDisabled() - Cancelling periodic updates")
75+
getWorkManager(context).cancelUniqueWork(WIDGET_UPDATE_WORK_NAME)
76+
}
77+
78+
override fun onAppWidgetOptionsChanged(
2979
context: Context,
3080
appWidgetManager: AppWidgetManager,
31-
appWidgetIds: IntArray,
81+
appWidgetId: Int,
82+
newOptions: android.os.Bundle?
3283
) {
33-
for (element in appWidgetIds) {
34-
element
35-
updateAppWidget(context)
36-
}
84+
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
85+
Log.v(Main.LOG_TAG, "WidgetProvider.onAppWidgetOptionsChanged() - re-rendering with new size")
86+
scheduleImmediateUpdate(context)
3787
}
3888

39-
private fun updateAppWidget(context: Context) {
40-
val alarmView = AlarmView(context)
41-
alarmView.measure(150, 150)
42-
alarmView.layout(0, 0, 150, 150)
43-
alarmView.isDrawingCacheEnabled = true
44-
val bitmap = alarmView.drawingCache
45-
46-
val remoteViews =
47-
RemoteViews(
48-
context.packageName,
49-
R.layout.widget,
50-
)
51-
remoteViews.setImageViewBitmap(R.layout.widget, bitmap)
89+
private fun scheduleNextUpdate(context: Context) {
90+
val constraints = Constraints.Builder()
91+
.setRequiredNetworkType(NetworkType.CONNECTED)
92+
.build()
93+
val workRequest = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(UPDATE_INTERVAL_MINUTES, TimeUnit.MINUTES)
94+
.setConstraints(constraints)
95+
.build()
96+
97+
getWorkManager(context).enqueueUniquePeriodicWork(
98+
WIDGET_UPDATE_WORK_NAME,
99+
ExistingPeriodicWorkPolicy.KEEP,
100+
workRequest
101+
)
52102
}
53103
}

0 commit comments

Comments
 (0)