Skip to content

Commit b5bb18a

Browse files
authored
feat: swipe to refresh calendars (#168)
* docs: inital plan * docs: tests * feat: swipe to refresh calendar * docs: handled calendars plan * feat: handled calendars events visual indicator * fix: build and bug bot Test returns early without executing assertions Medium Severity The test testRefreshMenuItemTriggersRefresh attempts to find a menu item using activity.findViewById(R.id.action_refresh_calendars), but menu items are not part of the activity's view hierarchy, so findViewById always returns null. This causes the ?.let block to be skipped and the ?: return clause to execute, making the test return early without ever calling onOptionsItemSelected or reaching the final assertion. The test silently passes without actually testing the refresh behavior. Compare to testHelpMenuItemShowsDialog which correctly retrieves the menu item from shadowActivity.optionsMenu. * test: pass
1 parent 27b7630 commit b5bb18a

12 files changed

Lines changed: 937 additions & 51 deletions

File tree

android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockCalendarProvider.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ class MockCalendarProvider(
124124
DevLog.info(LOG_TAG, "Delegating updateEvent to real implementation: eventId=$eventId, calendarId=$calendarId")
125125
callOriginal()
126126
}
127+
128+
every { CalendarProvider.getUpcomingEventCountsByCalendar(any(), any()) } answers {
129+
callOriginal()
130+
}
127131
}
128132

129133
/**

android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProvider.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,4 +1840,38 @@ object CalendarProvider : CalendarProviderInterface {
18401840
private fun checkPermissions(context: Context): Boolean {
18411841
return PermissionsManager.hasAllCalendarPermissionsNoCache(context)
18421842
}
1843+
1844+
override fun getUpcomingEventCountsByCalendar(context: Context, daysAhead: Int): Map<Long, Int> {
1845+
val counts = mutableMapOf<Long, Int>()
1846+
1847+
if (!PermissionsManager.hasReadCalendar(context)) {
1848+
DevLog.error(LOG_TAG, "getUpcomingEventCountsByCalendar: no permissions")
1849+
return counts
1850+
}
1851+
1852+
try {
1853+
val now = clock.currentTimeMillis()
1854+
val endTime = now + daysAhead * Consts.DAY_IN_MILLISECONDS
1855+
1856+
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon()
1857+
.appendPath(now.toString())
1858+
.appendPath(endTime.toString())
1859+
.build()
1860+
1861+
val projection = arrayOf(CalendarContract.Events.CALENDAR_ID)
1862+
1863+
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
1864+
while (cursor.moveToNext()) {
1865+
val calendarId = cursor.getLong(0)
1866+
counts[calendarId] = (counts[calendarId] ?: 0) + 1
1867+
}
1868+
}
1869+
1870+
DevLog.info(LOG_TAG, "getUpcomingEventCountsByCalendar: found events in ${counts.size} calendars")
1871+
} catch (ex: SecurityException) {
1872+
DevLog.error(LOG_TAG, "getUpcomingEventCountsByCalendar: SecurityException ${ex.message}")
1873+
}
1874+
1875+
return counts
1876+
}
18431877
}

android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProviderInterface.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,6 @@ interface CalendarProviderInterface {
8383
fun getCalendarBackupInfo(context: Context, calendarId: Long): CalendarBackupInfo?
8484

8585
fun findMatchingCalendarId(context: Context, backupInfo: CalendarBackupInfo): Long
86+
87+
fun getUpcomingEventCountsByCalendar(context: Context, daysAhead: Int = 7): Map<Long, Int>
8688
}

android/app/src/main/java/com/github/quarck/calnotify/prefs/CalendarsActivity.kt

Lines changed: 140 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//
22
// Calendar Notifications Plus
33
// Copyright (C) 2016 Sergey Parshin (s.parshin.sc@gmail.com)
4+
// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com)
45
//
56
// This program is free software; you can redistribute it and/or modify
67
// it under the terms of the GNU General Public License as published by
@@ -18,26 +19,34 @@
1819
//
1920
package com.github.quarck.calnotify.prefs
2021

22+
import android.content.ActivityNotFoundException
23+
import android.content.ContentResolver
2124
import android.content.Context
25+
import android.content.Intent
2226
import android.graphics.drawable.ColorDrawable
2327
import android.os.Bundle
24-
import androidx.appcompat.app.AppCompatActivity
25-
import androidx.recyclerview.widget.RecyclerView
26-
import androidx.recyclerview.widget.StaggeredGridLayoutManager
27-
import androidx.appcompat.widget.Toolbar
28+
import android.provider.CalendarContract
29+
import android.provider.Settings
2830
import android.view.LayoutInflater
31+
import android.view.Menu
32+
import android.view.MenuItem
2933
import android.view.View
3034
import android.view.ViewGroup
3135
import android.widget.CheckBox
3236
import android.widget.LinearLayout
3337
import android.widget.TextView
38+
import androidx.appcompat.app.AlertDialog
39+
import androidx.appcompat.app.AppCompatActivity
40+
import androidx.appcompat.widget.Toolbar
41+
import androidx.recyclerview.widget.RecyclerView
42+
import androidx.recyclerview.widget.StaggeredGridLayoutManager
43+
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
3444
import com.github.quarck.calnotify.Consts
3545
import com.github.quarck.calnotify.R
36-
import com.github.quarck.calnotify.Settings
46+
import com.github.quarck.calnotify.Settings as AppSettings
3747
import com.github.quarck.calnotify.calendar.CalendarProvider
3848
import com.github.quarck.calnotify.calendar.CalendarRecord
3949
import com.github.quarck.calnotify.logs.DevLog
40-
//import com.github.quarck.calnotify.logs.Logger
4150
import com.github.quarck.calnotify.utils.background
4251
import com.github.quarck.calnotify.utils.find
4352
import com.github.quarck.calnotify.utils.findOrThrow
@@ -48,7 +57,8 @@ class CalendarListEntry(
4857
val type: CalendarListEntryType,
4958
val headerTitle: String? = null,
5059
val calendar: CalendarRecord? = null,
51-
var isHandled: Boolean = true
60+
var isHandled: Boolean = true,
61+
val upcomingEventCount: Int = 0
5262
)
5363

5464

@@ -113,7 +123,14 @@ class CalendarListAdapter(val context: Context, var entries: Array<CalendarListE
113123
}
114124

115125
CalendarListEntryType.Calendar -> {
116-
holder.checkboxCalendarName.text = entry.calendar?.name ?: ""
126+
val calendarName = entry.calendar?.name ?: ""
127+
// Show upcoming event count for unhandled calendars with events
128+
val displayText = if (!entry.isHandled && entry.upcomingEventCount > 0) {
129+
context.getString(R.string.calendar_name_with_event_count, calendarName, entry.upcomingEventCount)
130+
} else {
131+
calendarName
132+
}
133+
holder.checkboxCalendarName.text = displayText
117134
holder.calendarAccountName.visibility = View.GONE
118135
holder.calendarEntryLayout.visibility = View.VISIBLE
119136
holder.colorView.background = ColorDrawable(entry.calendar?.color ?: Consts.DEFAULT_CALENDAR_EVENT_COLOR)
@@ -142,8 +159,9 @@ class CalendarsActivity : AppCompatActivity() {
142159
private lateinit var staggeredLayoutManager: StaggeredGridLayoutManager
143160
private lateinit var recyclerView: RecyclerView
144161
private lateinit var noCalendarsText: TextView
162+
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
145163

146-
private lateinit var settings: Settings
164+
private lateinit var settings: AppSettings
147165

148166
override fun onCreate(savedInstanceState: Bundle?) {
149167
super.onCreate(savedInstanceState)
@@ -155,7 +173,7 @@ class CalendarsActivity : AppCompatActivity() {
155173
supportActionBar?.setDisplayHomeAsUpEnabled(true)
156174
supportActionBar?.setDisplayShowHomeEnabled(true)
157175

158-
settings = Settings(this)
176+
settings = AppSettings(this)
159177

160178
adapter = CalendarListAdapter(this, arrayOf<CalendarListEntry>())
161179

@@ -172,53 +190,126 @@ class CalendarsActivity : AppCompatActivity() {
172190
recyclerView.adapter = adapter;
173191

174192
noCalendarsText = findOrThrow<TextView>(R.id.no_calendars_text)
193+
194+
swipeRefreshLayout = findOrThrow<SwipeRefreshLayout>(R.id.swipe_refresh_calendars)
195+
swipeRefreshLayout.setOnRefreshListener {
196+
requestCalendarSyncAndRefresh()
197+
}
175198
}
176199

177-
override fun onResume() {
178-
super.onResume()
200+
override fun onCreateOptionsMenu(menu: Menu): Boolean {
201+
menuInflater.inflate(R.menu.menu_calendars, menu)
202+
return true
203+
}
179204

180-
background {
181-
// load the data here
182-
val calendars = CalendarProvider.getCalendars(this).toTypedArray()
183-
184-
val entries = mutableListOf<CalendarListEntry>()
185-
186-
// Arrange entries by accountName calendar
187-
for ((accountName, type) in calendars.map { Pair(it.accountName, it.accountType) }.toSet()) {
188-
189-
// Add group title
190-
entries.add(CalendarListEntry(type = CalendarListEntryType.Header, headerTitle = accountName))
191-
192-
// Add all the calendars for this accountName
193-
entries.addAll(
194-
calendars
195-
.filter { it.accountName == accountName && it.accountType == type }
196-
.sortedBy { it.calendarId }
197-
.map {
198-
CalendarListEntry(
199-
type = CalendarListEntryType.Calendar,
200-
calendar = it,
201-
isHandled = settings.getCalendarIsHandled(it.calendarId))
202-
})
203-
204-
// Add a divider
205-
entries.add(CalendarListEntry(type = CalendarListEntryType.Divider))
205+
override fun onOptionsItemSelected(item: MenuItem): Boolean {
206+
return when (item.itemId) {
207+
R.id.action_refresh_calendars -> {
208+
swipeRefreshLayout.isRefreshing = true
209+
requestCalendarSyncAndRefresh()
210+
true
211+
}
212+
R.id.action_calendar_sync_help -> {
213+
showCalendarSyncHelp()
214+
true
206215
}
216+
else -> super.onOptionsItemSelected(item)
217+
}
218+
}
207219

208-
// remove last divider
209-
if (entries.size >= 1 && entries[entries.size - 1].type == CalendarListEntryType.Divider)
210-
entries.removeAt(entries.size - 1)
220+
private fun requestCalendarSyncAndRefresh() {
221+
background {
222+
try {
223+
val extras = Bundle().apply {
224+
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
225+
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
226+
}
227+
ContentResolver.requestSync(null, CalendarContract.AUTHORITY, extras)
228+
DevLog.info(LOG_TAG, "Requested calendar sync")
211229

212-
val entriesFinal = entries.toTypedArray()
230+
// Wait for sync to start/complete
231+
Thread.sleep(SYNC_WAIT_MS)
232+
} catch (ex: SecurityException) {
233+
DevLog.error(LOG_TAG, "SecurityException requesting sync: ${ex.message}")
234+
}
213235

214-
runOnUiThread {
215-
// update activity finally
236+
loadCalendars()
216237

217-
noCalendarsText.visibility = if (entriesFinal.isNotEmpty()) View.GONE else View.VISIBLE
238+
runOnUiThread {
239+
swipeRefreshLayout.isRefreshing = false
240+
}
241+
}
242+
}
218243

219-
adapter.entries = entriesFinal
220-
adapter.notifyDataSetChanged();
244+
private fun showCalendarSyncHelp() {
245+
AlertDialog.Builder(this)
246+
.setTitle(R.string.calendar_sync_help_title)
247+
.setMessage(R.string.calendar_sync_help_message)
248+
.setPositiveButton(android.R.string.ok, null)
249+
.setNeutralButton(R.string.open_sync_settings) { _, _ ->
250+
openSyncSettings()
221251
}
252+
.show()
253+
}
254+
255+
private fun openSyncSettings() {
256+
try {
257+
val intent = Intent(Settings.ACTION_SYNC_SETTINGS)
258+
startActivity(intent)
259+
} catch (ex: ActivityNotFoundException) {
260+
DevLog.error(LOG_TAG, "Could not open sync settings: ${ex.message}")
261+
startActivity(Intent(Settings.ACTION_SETTINGS))
262+
}
263+
}
264+
265+
private fun loadCalendars() {
266+
val calendars = CalendarProvider.getCalendars(this).toTypedArray()
267+
val upcomingCounts = CalendarProvider.getUpcomingEventCountsByCalendar(this, UPCOMING_EVENTS_DAYS)
268+
269+
val entries = mutableListOf<CalendarListEntry>()
270+
271+
// Arrange entries by accountName calendar
272+
for ((accountName, type) in calendars.map { Pair(it.accountName, it.accountType) }.toSet()) {
273+
274+
// Add group title
275+
entries.add(CalendarListEntry(type = CalendarListEntryType.Header, headerTitle = accountName))
276+
277+
// Add all the calendars for this accountName
278+
entries.addAll(
279+
calendars
280+
.filter { it.accountName == accountName && it.accountType == type }
281+
.sortedBy { it.calendarId }
282+
.map {
283+
CalendarListEntry(
284+
type = CalendarListEntryType.Calendar,
285+
calendar = it,
286+
isHandled = settings.getCalendarIsHandled(it.calendarId),
287+
upcomingEventCount = upcomingCounts[it.calendarId] ?: 0)
288+
})
289+
290+
// Add a divider
291+
entries.add(CalendarListEntry(type = CalendarListEntryType.Divider))
292+
}
293+
294+
// remove last divider
295+
if (entries.size >= 1 && entries[entries.size - 1].type == CalendarListEntryType.Divider)
296+
entries.removeAt(entries.size - 1)
297+
298+
val entriesFinal = entries.toTypedArray()
299+
300+
runOnUiThread {
301+
noCalendarsText.visibility = if (entriesFinal.isNotEmpty()) View.GONE else View.VISIBLE
302+
303+
adapter.entries = entriesFinal
304+
adapter.notifyDataSetChanged();
305+
}
306+
}
307+
308+
override fun onResume() {
309+
super.onResume()
310+
311+
background {
312+
loadCalendars()
222313
}
223314
}
224315

@@ -229,5 +320,7 @@ class CalendarsActivity : AppCompatActivity() {
229320

230321
companion object {
231322
private const val LOG_TAG = "CalendarsActivity"
323+
private const val SYNC_WAIT_MS = 2000L
324+
private const val UPCOMING_EVENTS_DAYS = 7
232325
}
233326
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24"
6+
android:tint="?attr/colorControlNormal">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
10+
</vector>

android/app/src/main/res/layout/content_calendars.xml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,18 @@
1111
tools:showIn="@layout/activity_calendars"
1212
>
1313

14-
<androidx.recyclerview.widget.RecyclerView
15-
android:id="@+id/list_calendars"
14+
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
15+
android:id="@+id/swipe_refresh_calendars"
1616
android:layout_width="match_parent"
17-
android:layout_height="match_parent"
18-
android:background="@color/cardview_light_background" />
17+
android:layout_height="match_parent">
18+
19+
<androidx.recyclerview.widget.RecyclerView
20+
android:id="@+id/list_calendars"
21+
android:layout_width="match_parent"
22+
android:layout_height="match_parent"
23+
android:background="@color/cardview_light_background" />
24+
25+
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
1926

2027
<TextView
2128
android:id="@+id/no_calendars_text"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<menu
3+
xmlns:android="http://schemas.android.com/apk/res/android"
4+
xmlns:app="http://schemas.android.com/apk/res-auto"
5+
xmlns:tools="http://schemas.android.com/tools"
6+
tools:context=".prefs.CalendarsActivity">
7+
8+
<item
9+
android:id="@+id/action_refresh_calendars"
10+
android:icon="@drawable/ic_refresh_white_24dp"
11+
android:orderInCategory="100"
12+
android:title="@string/refresh_calendars"
13+
app:showAsAction="ifRoom" />
14+
15+
<item
16+
android:id="@+id/action_calendar_sync_help"
17+
android:orderInCategory="200"
18+
android:title="@string/calendar_sync_help"
19+
app:showAsAction="never" />
20+
21+
</menu>

android/app/src/main/res/values/strings.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,4 +624,14 @@
624624

625625
<!-- Bluetooth Permission (Car Mode) -->
626626
<string name="bluetooth_permission_required">Bluetooth permission is required to configure Car Mode devices</string>
627+
628+
<!-- Calendar Sync Refresh -->
629+
<string name="refresh_calendars">Refresh calendars</string>
630+
<string name="calendar_sync_help">Troubleshoot missing calendars</string>
631+
<string name="calendar_sync_help_title">Missing calendars?</string>
632+
<string name="calendar_sync_help_message">If some calendars aren\'t showing:\n\n1. Pull down to refresh this list\n\n2. Check that calendar sync is enabled in Android Settings → Accounts → Google → Calendar\n\n3. Open the Google Calendar app and verify your calendars appear there\n\n4. If calendars still don\'t appear, try restarting your device</string>
633+
<string name="open_sync_settings">Open Sync Settings</string>
634+
635+
<!-- Unhandled Calendar Event Count -->
636+
<string name="calendar_name_with_event_count">%1$s (%2$d upcoming)</string>
627637
</resources>

0 commit comments

Comments
 (0)