diff --git a/.devcontainer/mcfly_history.db b/.devcontainer/mcfly_history.db index 8c60796c2..a91358b26 100644 Binary files a/.devcontainer/mcfly_history.db and b/.devcontainer/mcfly_history.db differ diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 52bad041e..85808d646 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -344,7 +344,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: `Build artifacts for this PR are available: + body: `Build artifacts for PR #${context.issue.number} (commit ${context.sha}) are available: - [Debug APKs (arm64-v8a, x86_64)](${workflowUrl}#artifacts) - [Release APKs (arm64-v8a, x86_64)](${workflowUrl}#artifacts) diff --git a/App/SetupSync.tsx b/App/SetupSync.tsx index 628f404ab..30d8b46ef 100644 --- a/App/SetupSync.tsx +++ b/App/SetupSync.tsx @@ -1,23 +1,26 @@ import React, { useContext, useEffect, useState } from 'react'; import { StyleSheet, Text, View, Button, TouchableOpacity, ScrollView, Linking } from 'react-native'; -import { hello, MyModuleView, setValueAsync, addChangeListener } from '../modules/my-module'; +import { hello, MyModuleView, setValueAsync, sendRescheduleConfirmations, addChangeListener } from '../modules/my-module'; import { open } from '@op-engineering/op-sqlite'; import { useQuery } from '@powersync/react'; import { PowerSyncContext } from "@powersync/react"; import { installCrsqliteOnTable } from '@lib/cr-sqlite/install'; import { psInsertDbTable, psClearTable } from '@lib/orm'; import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from './index'; import { useSettings } from '@lib/hooks/SettingsContext'; import { GITHUB_README_URL } from '@lib/constants'; +// Split out type imports for better readability +import type { RawRescheduleConfirmation } from '../modules/my-module'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import type { RootStackParamList } from './index'; + type NavigationProp = NativeStackNavigationProp; export const SetupSync = () => { const navigation = useNavigation(); const { settings } = useSettings(); - const debugDisplayKeys = ['id', 'ttl', 'loc']; + const debugDisplayKeys = ['id', 'ttl', 'istart' ,'loc']; const [showDangerZone, setShowDangerZone] = useState(false); const [showDebugOutput, setShowDebugOutput] = useState(false); const [isConnected, setIsConnected] = useState(null); @@ -34,6 +37,8 @@ export const SetupSync = () => { const debugDisplayQuery = `select ${debugDisplayKeys.join(', ')} from eventsV9 limit ${numEventsToDisplay}`; const { data: psEvents } = useQuery(debugDisplayQuery); + const { data: rawConfirmations } = useQuery(`select event_id, calendar_id, original_instance_start_time, title, new_instance_start_time, is_in_future, created_at, updated_at from reschedule_confirmations`); + const [sqliteEvents, setSqliteEvents] = useState([]); const [tempTableEvents, setTempTableEvents] = useState([]); const [dbStatus, setDbStatus] = useState(''); @@ -163,12 +168,12 @@ export const SetupSync = () => { {showDebugOutput && ( - Sample Local SQLite Events eventsV9: {JSON.stringify(sqliteEvents)} - - Sample PowerSync Remote Events: {JSON.stringify(psEvents)} + Sample Local SQLite Events eventsV9: {JSON.stringify(sqliteEvents)} + Sample PowerSync Remote Events: {JSON.stringify(psEvents)} + Sample PowerSync Remote Events reschedule_confirmations: {JSON.stringify(rawConfirmations?.slice(0, numEventsToDisplay))} {settings.syncEnabled && settings.syncType === 'bidirectional' && ( - Events V9 Temp Table: {JSON.stringify(tempTableEvents)} + Events V9 Temp Table: {JSON.stringify(tempTableEvents)} )} )} @@ -202,6 +207,24 @@ export const SetupSync = () => { {showDangerZone && isConnected !== false && ( <> + + + ⚠️ WARNING: This will dismiss potentially many events from your local device!{'\n'} + You can restore them from the bin. + + + + { + if (rawConfirmations) { + await sendRescheduleConfirmations(rawConfirmations); + } + }} + > + Send Reschedule Confirmations + + ⚠️ WARNING: This will only delete events from the remote PowerSync database.{'\n'} @@ -224,17 +247,13 @@ export const SetupSync = () => { )} - {/* TODO: this native module can be used to communicate with the kolin code */} + + {/* this native module can be used to communicate with the kolin code */} {/* I want to use it to get things like the mute status of a notification */} {/* or whatever other useful things. so dont delete it so I remember to use it later */} - {/* - */} + + {/* */} ); @@ -311,6 +330,15 @@ const styles = StyleSheet.create({ deleteButton: { backgroundColor: '#FF3B30', }, + yellowButton: { + backgroundColor: '#FFD700', + }, + yellowButtonText: { + color: '#000000', + fontSize: 16, + textAlign: 'center', + fontWeight: '600', + }, warningContainer: { backgroundColor: '#fff', padding: 10, diff --git a/android/app/build.gradle b/android/app/build.gradle index 57dd6f49a..a30d0529f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' apply plugin: "com.facebook.react" apply plugin: 'jacoco' @@ -254,8 +255,8 @@ dependencies { strictly '3.45.0' } } - - // Unit test dependencies + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + // Unit test dependencies testImplementation 'junit:junit:4.13.2' // Test dependencies - use test-compatible versions diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/dismissedeventsstorage/EventDismissTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/dismissedeventsstorage/EventDismissTest.kt new file mode 100644 index 000000000..af82e2efe --- /dev/null +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/dismissedeventsstorage/EventDismissTest.kt @@ -0,0 +1,617 @@ +package com.github.quarck.calnotify.dismissedeventsstorage + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.quarck.calnotify.app.ApplicationController +import com.github.quarck.calnotify.calendar.EventAlertRecord +import com.github.quarck.calnotify.calendar.EventDisplayStatus +import com.github.quarck.calnotify.calendar.EventOrigin +import com.github.quarck.calnotify.calendar.EventStatus +import com.github.quarck.calnotify.calendar.AttendanceStatus +import com.github.quarck.calnotify.eventsstorage.EventsStorage +import com.github.quarck.calnotify.eventsstorage.EventsStorageInterface +import com.github.quarck.calnotify.logs.DevLog +import com.github.quarck.calnotify.testutils.MockApplicationComponents +import com.github.quarck.calnotify.testutils.MockCalendarProvider +import com.github.quarck.calnotify.testutils.MockContextProvider +import com.github.quarck.calnotify.testutils.MockTimeProvider +import expo.modules.mymodule.JsRescheduleConfirmationObject +import io.mockk.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.Assert.* +import org.junit.Ignore + +@RunWith(AndroidJUnit4::class) +class EventDismissTest { + private val LOG_TAG = "EventDismissTest" + + private lateinit var mockContext: Context + private lateinit var mockDb: EventsStorageInterface + private lateinit var mockComponents: MockApplicationComponents + private lateinit var mockTimeProvider: MockTimeProvider + + @Before + fun setup() { + DevLog.info(LOG_TAG, "Setting up EventDismissTest") + + // Setup mock time provider + mockTimeProvider = MockTimeProvider(1635724800000) // 2021-11-01 00:00:00 UTC + mockTimeProvider.setup() + + // Setup mock database + mockDb = mockk(relaxed = true) + + // Setup mock providers + val mockContextProvider = MockContextProvider(mockTimeProvider) + mockContextProvider.setup() + val mockCalendarProvider = MockCalendarProvider(mockContextProvider, mockTimeProvider) + mockCalendarProvider.setup() + + // Setup mock components + mockComponents = MockApplicationComponents( + contextProvider = mockContextProvider, + timeProvider = mockTimeProvider, + calendarProvider = mockCalendarProvider + ) + mockComponents.setup() + + mockContext = mockContextProvider.fakeContext + } + + @Test + @Ignore("figure out how to pass the mock context to Events corectly to call the mock that skips the file id. can proabably have a mock db like in this class but not needed for now") + fun testOriginalDismissEventWithValidEvent() { + // Given + val event = createTestEvent() + every { EventsStorage(mockContext).getEvent(event.eventId, event.instanceStartTime) } returns event + every { EventsStorage(mockContext).deleteEvent(event.eventId, event.instanceStartTime) } returns true + + // When + ApplicationController.dismissEvent( + mockContext, + EventDismissType.ManuallyDismissedFromActivity, + event.eventId, + event.instanceStartTime, + 0, + false + ) + + // Then + verify { EventsStorage(mockContext).getEvent(event.eventId, event.instanceStartTime) } + verify { EventsStorage(mockContext).deleteEvent(event.eventId, event.instanceStartTime) } + } + + @Test + @Ignore("figure out how to pass the mock context to Events corectly to call the mock that skips the file id. can proabably have a mock db like in this class but not needed for now") + fun testOriginalDismissEventWithNonExistentEvent() { + // Given + val event = createTestEvent() + every { EventsStorage(mockContext).getEvent(event.eventId, event.instanceStartTime) } returns null + + // When + ApplicationController.dismissEvent( + mockContext, + EventDismissType.ManuallyDismissedFromActivity, + event.eventId, + event.instanceStartTime, + 0, + false + ) + + // Then + verify { EventsStorage(mockContext).getEvent(event.eventId, event.instanceStartTime) } + verify(exactly = 0) { EventsStorage(mockContext).deleteEvent(any(), any()) } + } + + @Test + fun testSafeDismissEventsWithValidEvents() { + // Given + val events = listOf(createTestEvent(1), createTestEvent(2)) + every { mockDb.getEvent(any(), any()) } returns events[0] + every { mockDb.deleteEvents(any()) } returns events.size + + // When + val results = ApplicationController.safeDismissEvents( + mockContext, + mockDb, + events, + EventDismissType.ManuallyDismissedFromActivity, + false + ) + + // Then + assertEquals(events.size, results.size) + results.forEach { (event, result) -> + assertEquals(EventDismissResult.Success, result) + } + verify { mockDb.deleteEvents(events) } + } + + @Test + fun testSafeDismissEventsWithMixedValidAndInvalidEvents() { + // Given + val validEvent = createTestEvent(1) + val invalidEvent = createTestEvent(2) + val events = listOf(validEvent, invalidEvent) + + every { mockDb.getEvent(validEvent.eventId, validEvent.instanceStartTime) } returns validEvent + every { mockDb.getEvent(invalidEvent.eventId, invalidEvent.instanceStartTime) } returns null + every { mockDb.deleteEvents(listOf(validEvent)) } returns 1 + + // When + val results = ApplicationController.safeDismissEvents( + mockContext, + mockDb, + events, + EventDismissType.ManuallyDismissedFromActivity, + false + ) + + // Then + assertEquals(events.size, results.size) + val validResult = results.find { it.first == validEvent }?.second + val invalidResult = results.find { it.first == invalidEvent }?.second + + assertNotNull(validResult) + assertNotNull(invalidResult) + assertEquals(EventDismissResult.Success, validResult) + assertEquals(EventDismissResult.EventNotFound, invalidResult) + } + + @Test + fun testSafeDismissEventsWithDeletionWarning() { + // Given + val event = createTestEvent() + every { mockDb.getEvent(any(), any()) } returns event + every { mockDb.deleteEvents(any()) } returns 0 // Simulate deletion failure + + // When + val results = ApplicationController.safeDismissEvents( + mockContext, + mockDb, + listOf(event), + EventDismissType.ManuallyDismissedFromActivity, + false + ) + + // Then + assertEquals(1, results.size) + assertEquals(EventDismissResult.DeletionWarning, results[0].second) + verify { mockDb.deleteEvents(listOf(event)) } + } + + @Test + fun testSafeDismissEventsByIdWithValidEvents() { + // Given + val eventIds = listOf(1L, 2L) + val events = eventIds.map { createTestEvent(it) } + + every { mockDb.getEventInstances(any()) } returns events + every { mockDb.getEvent(any(), any()) } returns events[0] + every { mockDb.deleteEvents(any()) } returns events.size + + // When + val results = ApplicationController.safeDismissEventsById( + mockContext, + mockDb, + eventIds, + EventDismissType.ManuallyDismissedFromActivity, + false + ) + + // Then + assertEquals(eventIds.size, results.size) + results.forEach { (_, result) -> + assertEquals(EventDismissResult.Success, result) + } + } + + @Test + fun testSafeDismissEventsByIdWithNonExistentEvents() { + // Given + val eventIds = listOf(1L, 2L) + every { mockDb.getEventInstances(any()) } returns emptyList() + + // When + val results = ApplicationController.safeDismissEventsById( + mockContext, + mockDb, + eventIds, + EventDismissType.ManuallyDismissedFromActivity, + false + ) + + // Then + assertEquals(eventIds.size, results.size) + results.forEach { (_, result) -> + assertEquals(EventDismissResult.EventNotFound, result) + } + } + + @Test + fun testSafeDismissEventsWithStorageError() { + // Given + val event = createTestEvent() + every { mockDb.getEvent(any(), any()) } returns event + every { mockDb.deleteEvents(any()) } throws RuntimeException("Storage error") + + // When + val results = ApplicationController.safeDismissEvents( + mockContext, + mockDb, + listOf(event), + EventDismissType.ManuallyDismissedFromActivity, + false + ) + + // Then + assertEquals(1, results.size) + assertEquals(EventDismissResult.DeletionWarning, results[0].second) + } + + @Test + fun testSafeDismissEventsFromRescheduleConfirmationsWithFutureEvents() { + // Given + val currentTime = mockTimeProvider.testClock.currentTimeMillis() + val futureEvents = listOf( + JsRescheduleConfirmationObject( + event_id = 1L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 1", + new_instance_start_time = currentTime + 3600000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = true + ), + JsRescheduleConfirmationObject( + event_id = 2L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 2", + new_instance_start_time = currentTime + 7200000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = true + ) + ) + val events = futureEvents.map { createTestEvent(it.event_id) } + + // Add events to existing mock components for retrieval + futureEvents.forEach { confirmation -> + val event = events.find { it.eventId == confirmation.event_id }!! + mockComponents.addEventToStorage(event) + } + + // Mock our mock database to return these events when queried + futureEvents.forEach { confirmation -> + val event = events.find { it.eventId == confirmation.event_id }!! + every { mockDb.getEventInstances(confirmation.event_id) } returns listOf(event) + every { mockDb.getEvent(confirmation.event_id, any()) } returns event + } + + // Mock successful deletion + every { mockDb.deleteEvents(any()) } returns events.size + + // Clear any previous toast messages + mockComponents.clearToastMessages() + + // When + val results = ApplicationController.safeDismissEventsFromRescheduleConfirmations( + mockContext, + futureEvents, + false + ) + + // Then + assertEquals(futureEvents.size, results.size) + results.forEach { (_, result) -> + assertEquals(EventDismissResult.Success, result) + } + + // Verify toast messages - be more lenient about exact message content + val toastMessages = mockComponents.getToastMessages() + assertTrue(toastMessages.isNotEmpty()) + assertTrue(toastMessages.any { it.contains("Attempting to dismiss") }) + } + + @Test + fun testSafeDismissEventsFromRescheduleConfirmationsWithMixedEvents() { + // Given + val currentTime = mockTimeProvider.testClock.currentTimeMillis() + val confirmations = listOf( + JsRescheduleConfirmationObject( + event_id = 1L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 1", + new_instance_start_time = currentTime + 3600000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = true + ), + JsRescheduleConfirmationObject( + event_id = 2L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 2", + new_instance_start_time = currentTime - 3600000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = false + ), + JsRescheduleConfirmationObject( + event_id = 3L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 3", + new_instance_start_time = currentTime + 7200000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = true + ) + ) + val futureEvents = confirmations.filter { it.is_in_future } + val events = futureEvents.map { createTestEvent(it.event_id) } + + // Add events to existing mock components for retrieval + futureEvents.forEach { confirmation -> + val event = events.find { it.eventId == confirmation.event_id }!! + mockComponents.addEventToStorage(event) + } + + // Mock our mock database to return these events when queried + futureEvents.forEach { confirmation -> + val event = events.find { it.eventId == confirmation.event_id }!! + every { mockDb.getEventInstances(confirmation.event_id) } returns listOf(event) + every { mockDb.getEvent(confirmation.event_id, any()) } returns event + } + + // Mock successful deletion + every { mockDb.deleteEvents(any()) } returns events.size + + // Clear any previous toast messages + mockComponents.clearToastMessages() + + // When + val results = ApplicationController.safeDismissEventsFromRescheduleConfirmations( + mockContext, + confirmations, + false + ) + + // Then + assertEquals(futureEvents.size, results.size) + results.forEach { (_, result) -> + assertEquals(EventDismissResult.Success, result) + } + + // Verify toast messages - be more lenient about exact message content + val toastMessages = mockComponents.getToastMessages() + assertTrue(toastMessages.isNotEmpty()) + assertTrue(toastMessages.any { it.contains("Attempting to dismiss") }) + } + + @Test + fun testSafeDismissEventsFromRescheduleConfirmationsWithNonExistentEvents() { + // Given + val currentTime = mockTimeProvider.testClock.currentTimeMillis() + val confirmations = listOf( + JsRescheduleConfirmationObject( + event_id = 1L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 1", + new_instance_start_time = currentTime + 3600000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = true + ), + JsRescheduleConfirmationObject( + event_id = 2L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 2", + new_instance_start_time = currentTime + 7200000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = true + ) + ) + + every { mockDb.getEventInstances(any()) } returns emptyList() + + // Clear any previous toast messages + mockComponents.clearToastMessages() + + // When + val results = ApplicationController.safeDismissEventsFromRescheduleConfirmations( + mockContext, + confirmations, + false + ) + + // Then + assertEquals(confirmations.size, results.size) + results.forEach { (_, result) -> + assertEquals(EventDismissResult.EventNotFound, result) + } + + // Verify toast messages + val toastMessages = mockComponents.getToastMessages() + assertEquals(2, toastMessages.size) + assertEquals("Attempting to dismiss ${confirmations.size} events", toastMessages[0]) + assertEquals("Dismissed 0 events successfully, ${confirmations.size} events not found, 0 events failed", toastMessages[1]) + } + + @Test + @Ignore("figure out how to pass the mock context to Events corectly to call the mock that skips the file id. can proabably have a mock db like in this class but not needed for now") + fun testSafeDismissEventsFromRescheduleConfirmationsWithStorageError() { + // Given + val currentTime = mockTimeProvider.testClock.currentTimeMillis() + val confirmations = listOf( + JsRescheduleConfirmationObject( + event_id = 1L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 1", + new_instance_start_time = currentTime + 3600000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = true + ) + ) + val events = confirmations.map { createTestEvent(it.event_id) } + + // Add the event to existing mock components for retrieval + confirmations.forEach { confirmation -> + val event = events.find { it.eventId == confirmation.event_id }!! + mockComponents.addEventToStorage(event) + } + + // Mock our mock database to return these events when queried + confirmations.forEach { confirmation -> + val event = events.find { it.eventId == confirmation.event_id }!! + every { mockDb.getEventInstances(confirmation.event_id) } returns listOf(event) + every { mockDb.getEvent(confirmation.event_id, any()) } returns event + } + + // Simulate storage errors for all database operations + every { mockDb.deleteEvents(any()) } throws RuntimeException("Storage error") + every { mockDb.deleteEvent(any(), any()) } throws RuntimeException("Storage error") + every { mockDb.addEvent(any()) } throws RuntimeException("Storage error") + every { mockDb.addEvents(any()) } throws RuntimeException("Storage error") + every { mockDb.updateEvent(any(), any(), any()) } throws RuntimeException("Storage error") + + // Also ensure DismissedEventsStorage operations fail + mockkConstructor(DismissedEventsStorage::class) + every { anyConstructed().addEvent(any(), any()) } throws RuntimeException("Storage error") + every { anyConstructed().addEvents(any(), any()) } throws RuntimeException("Storage error") + + // Clear any previous toast messages + mockComponents.clearToastMessages() + + // When + val results = ApplicationController.safeDismissEventsFromRescheduleConfirmations( + mockContext, + confirmations, + false + ) + + // Then + assertEquals(confirmations.size, results.size) + results.forEach { (_, result) -> + assertTrue( + "Expected error result but got $result", + result == EventDismissResult.StorageError || + result == EventDismissResult.DatabaseError || + result == EventDismissResult.DeletionWarning + ) + } + + // Verify toast messages are shown about the error + val toastMessages = mockComponents.getToastMessages() + assertTrue(toastMessages.size >= 1) + assertTrue(toastMessages.any { it.contains("Attempting to dismiss") }) + assertTrue(toastMessages.any { it.contains("failed") || it.contains("error") || it.contains("Warning") }) + } + + @Test + fun testSafeDismissEventsFromRescheduleConfirmationsWithAllPastEvents() { + // Given + val currentTime = mockTimeProvider.testClock.currentTimeMillis() + val confirmations = listOf( + JsRescheduleConfirmationObject( + event_id = 1L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 1", + new_instance_start_time = currentTime - 3600000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = false + ), + JsRescheduleConfirmationObject( + event_id = 2L, + calendar_id = 1L, + original_instance_start_time = currentTime, + title = "Test Event 2", + new_instance_start_time = currentTime - 7200000, + created_at = currentTime.toString(), + updated_at = currentTime.toString(), + is_in_future = false + ) + ) + + // Clear any previous toast messages + mockComponents.clearToastMessages() + + // When + val results = ApplicationController.safeDismissEventsFromRescheduleConfirmations( + mockContext, + confirmations, + false + ) + + // Then + assertTrue(results.isEmpty()) + + // Verify toast messages + val toastMessages = mockComponents.getToastMessages() + assertEquals(1, toastMessages.size) + assertEquals("No future events to dismiss", toastMessages[0]) + } + + @Test + fun testSafeDismissEventsFromRescheduleConfirmationsWithEmptyList() { + // Given + val confirmations = emptyList() + + // Clear any previous toast messages + mockComponents.clearToastMessages() + + // When + val results = ApplicationController.safeDismissEventsFromRescheduleConfirmations( + mockContext, + confirmations, + false + ) + + // Then + assertTrue(results.isEmpty()) + + // Verify toast messages + val toastMessages = mockComponents.getToastMessages() + assertEquals(1, toastMessages.size) + assertEquals("No future events to dismiss", toastMessages[0]) + } + + private fun createTestEvent(id: Long = 1L): EventAlertRecord { + return EventAlertRecord( + calendarId = 1L, + eventId = id, + isAllDay = false, + isRepeating = false, + alertTime = System.currentTimeMillis(), + notificationId = 0, + title = "Test Event $id", + desc = "Test Description", + startTime = System.currentTimeMillis(), + endTime = System.currentTimeMillis() + 3600000, + instanceStartTime = System.currentTimeMillis(), + instanceEndTime = System.currentTimeMillis() + 3600000, + location = "", + lastStatusChangeTime = System.currentTimeMillis(), + snoozedUntil = 0L, + displayStatus = EventDisplayStatus.Hidden, + color = 0xffff0000.toInt(), + origin = EventOrigin.ProviderBroadcast, + timeFirstSeen = System.currentTimeMillis(), + eventStatus = EventStatus.Confirmed, + attendanceStatus = AttendanceStatus.None, + flags = 0 + ) + } +} diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockApplicationComponents.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockApplicationComponents.kt index a1d23e76c..f5a70d5ca 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockApplicationComponents.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockApplicationComponents.kt @@ -565,4 +565,16 @@ class MockApplicationComponents( DevLog.info(LOG_TAG, "App resume simulated") } + + /** + * Gets the list of Toast messages that would have been shown + */ + fun getToastMessages(): List = contextProvider.getToastMessages() + + /** + * Clears the list of Toast messages + */ + fun clearToastMessages() { + contextProvider.clearToastMessages() + } } diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockContextProvider.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockContextProvider.kt index 0e88b8836..4f5e4aee8 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockContextProvider.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/MockContextProvider.kt @@ -37,6 +37,9 @@ class MockContextProvider( private val sharedPreferencesMap = mutableMapOf() private val sharedPreferencesDataMap = mutableMapOf>() + // Track Toast messages that would have been shown + private val toastMessages = mutableListOf() + // Service is created once and reused lateinit var mockService: CalendarMonitorService private set @@ -47,6 +50,18 @@ class MockContextProvider( // Track initialization state private var isInitialized = false + /** + * Gets the list of Toast messages that would have been shown + */ + fun getToastMessages(): List = toastMessages.toList() + + /** + * Clears the list of Toast messages + */ + fun clearToastMessages() { + toastMessages.clear() + } + /** * Sets up the mock context and related components */ @@ -113,6 +128,19 @@ class MockContextProvider( private fun setupContext(realContext: Context) { DevLog.info(LOG_TAG, "Setting up mock context") + // Mock Toast static methods + mockkStatic(android.widget.Toast::class) + every { + android.widget.Toast.makeText(any(), any(), any()) + } answers { + val message = secondArg() + toastMessages.add(message) + DevLog.info(LOG_TAG, "Mock Toast would have shown: $message") + mockk(relaxed = true) { + every { show() } just Runs + } + } + // Create mock package manager with enhanced functionality val mockPackageManager = mockk { every { resolveActivity(any(), any()) } answers { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 02abfb504..dfae9c070 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -233,6 +233,12 @@ + + + + + + diff --git a/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt b/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt index 08f8344cc..0366357d9 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt @@ -23,6 +23,7 @@ import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.provider.CalendarContract +import android.util.Log import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.Settings import com.github.quarck.calnotify.calendareditor.CalendarChangeRequestMonitor @@ -53,6 +54,9 @@ import com.github.quarck.calnotify.utils.CNPlusClockInterface import com.github.quarck.calnotify.utils.CNPlusSystemClock import com.github.quarck.calnotify.database.SQLiteDatabaseExtensions.customUse +import com.github.quarck.calnotify.dismissedeventsstorage.EventDismissResult +import expo.modules.mymodule.JsRescheduleConfirmationObject +import kotlinx.serialization.json.Json interface ApplicationControllerInterface { // Clock interface for time-related operations @@ -89,6 +93,23 @@ interface ApplicationControllerInterface { fun applyCustomQuietHoursForSeconds(ctx: Context, quietForSeconds: Int) fun onReminderAlarmLate(context: Context, currentTime: Long, alarmWasExpectedAt: Long) fun onSnoozeAlarmLate(context: Context, currentTime: Long, alarmWasExpectedAt: Long) + + // New safe dismiss methods + fun safeDismissEvents( + context: Context, + db: EventsStorageInterface, + events: Collection, + dismissType: EventDismissType, + notifyActivity: Boolean + ): List> + + fun safeDismissEventsById( + context: Context, + db: EventsStorageInterface, + eventIds: Collection, + dismissType: EventDismissType, + notifyActivity: Boolean + ): List> } object ApplicationController : ApplicationControllerInterface, EventMovedHandler { @@ -214,6 +235,15 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler ) } + fun onReceivedRescheduleConfirmations(context: Context, value: String) { + DevLog.info(LOG_TAG, "onReceivedRescheduleConfirmations") + + val rescheduleConfirmations = Json.decodeFromString>(value) + Log.i(LOG_TAG, "onReceivedRescheduleConfirmations example info: ${rescheduleConfirmations.take(3)}" ) + + safeDismissEventsFromRescheduleConfirmations(context, rescheduleConfirmations) + } + override fun onCalendarRescanForRescheduledFromService(context: Context, userActionUntil: Long) { DevLog.info(LOG_TAG, "onCalendarRescanForRescheduledFromService") @@ -1219,4 +1249,281 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler // notificationManager.postNotificationsSnoozeAlarmDelayDebugMessage(context, "Snooze alarm was late!", warningMessage) // } } + + /** + * Safely dismisses a collection of events with detailed error handling and result reporting. + * This method: + * 1. Validates that each event exists in the database + * 2. Stores dismissed events in the dismissed events storage if the dismiss type requires it + * 3. Notifies about the dismissal process + * 4. Deletes the events from the database + * 5. Updates notifications and reschedules alarms + * + * @param context The application context + * @param db The events storage interface + * @param events The collection of events to dismiss + * @param dismissType The type of dismissal (e.g., manual, auto, etc.) + * @param notifyActivity Whether to notify the UI about the dismissal + * @return A list of pairs containing each event and its dismissal result (Success, EventNotFound, etc.) + */ + override fun safeDismissEvents( + context: Context, + db: EventsStorageInterface, + events: Collection, + dismissType: EventDismissType, + notifyActivity: Boolean + ): List> { + val results = mutableListOf>() + + try { + // First validate all events exist in the database + val validEvents = events.filter { event -> + val exists = db.getEvent(event.eventId, event.instanceStartTime) != null + results.add(Pair(event, if (exists) EventDismissResult.Success else EventDismissResult.EventNotFound)) + exists + } + + if (validEvents.isEmpty()) { + DevLog.info(LOG_TAG, "No valid events to dismiss") + return results + } + + DevLog.info(LOG_TAG, "Attempting to dismiss ${validEvents.size} events") + + // Store dismissed events if needed + val successfullyStoredEvents = if (dismissType.shouldKeep) { + try { + DismissedEventsStorage(context).classCustomUse { + it.addEvents(dismissType, validEvents) + } + validEvents + } catch (ex: Exception) { + DevLog.error(LOG_TAG, "Error storing dismissed events: ${ex.detailed}") + validEvents.forEach { event -> + val index = results.indexOfFirst { it.first == event } + if (index != -1) { + results[index] = Pair(event, EventDismissResult.StorageError) + } + } + return results + } + } else { + validEvents + } + + // Notify about dismissing + try { + notificationManager.onEventsDismissing(context, successfullyStoredEvents) + } catch (ex: Exception) { + DevLog.error(LOG_TAG, "Error notifying about dismissing events: ${ex.detailed}") + successfullyStoredEvents.forEach { event -> + val index = results.indexOfFirst { it.first == event } + if (index != -1) { + results[index] = Pair(event, EventDismissResult.NotificationError) + } + } + return results + } + + // Try to delete events from main storage - only for events that were successfully stored + val deleteSuccess = try { + db.deleteEvents(successfullyStoredEvents) == successfullyStoredEvents.size + } catch (ex: Exception) { + DevLog.warn(LOG_TAG, "Warning: Failed to delete events from main storage: ${ex.detailed}") + false + } + + if (!deleteSuccess) { + // Update results to indicate deletion warning + successfullyStoredEvents.forEach { event -> + val index = results.indexOfFirst { it.first == event } + if (index != -1) { + results[index] = Pair(event, EventDismissResult.DeletionWarning) + } + } + DevLog.warn(LOG_TAG, "Warning: Failed to delete some events from main storage") + } + + // Notify about dismissal + try { + val hasActiveEvents = db.events.any { it.snoozedUntil != 0L && !it.isSpecial } + notificationManager.onEventsDismissed( + context, + EventFormatter(context), + successfullyStoredEvents, + true, + hasActiveEvents + ) + } catch (ex: Exception) { + DevLog.error(LOG_TAG, "Error notifying about dismissed events: ${ex.detailed}") + successfullyStoredEvents.forEach { event -> + val index = results.indexOfFirst { it.first == event } + if (index != -1) { + results[index] = Pair(event, EventDismissResult.NotificationError) + } + } + return results + } + + ReminderState(context).onUserInteraction(clock.currentTimeMillis()) + alarmScheduler.rescheduleAlarms(context, getSettings(context), getQuietHoursManager(context)) + + if (notifyActivity) { + UINotifier.notify(context, true) + } + } catch (ex: Exception) { + DevLog.error(LOG_TAG, "Unexpected error in safeDismissEvents: ${ex.detailed}") + // Update all results to indicate error + events.forEach { event -> + val index = results.indexOfFirst { it.first == event } + if (index != -1) { + results[index] = Pair(event, EventDismissResult.StorageError) + } + } + } + + return results + } + + /** + * Safely dismisses events by their IDs with detailed error handling and result reporting. + * This method: + * 1. Looks up each event ID in the database + * 2. Calls safeDismissEvents with the found events + * 3. Returns results for all provided IDs, even if the event wasn't found + * + * @param context The application context + * @param db The events storage interface + * @param eventIds The collection of event IDs to dismiss + * @param dismissType The type of dismissal (e.g., manual, auto, etc.) + * @param notifyActivity Whether to notify the UI about the dismissal + * @return A list of pairs containing each event ID and its dismissal result (Success, EventNotFound, etc.) + */ + override fun safeDismissEventsById( + context: Context, + db: EventsStorageInterface, + eventIds: Collection, + dismissType: EventDismissType, + notifyActivity: Boolean + ): List> { + val results = mutableListOf>() + + try { + // Get all events for these IDs + val events = eventIds.mapNotNull { eventId -> + val event = db.getEventInstances(eventId).firstOrNull() + results.add(Pair(eventId, if (event != null) EventDismissResult.Success else EventDismissResult.EventNotFound)) + event + } + + if (events.isEmpty()) { + DevLog.info(LOG_TAG, "No events found for the provided IDs") + return results + } + + DevLog.info(LOG_TAG, "Found ${events.size} events to dismiss out of ${eventIds.size} IDs") + + // Call the other version with the found events + val dismissResults = safeDismissEvents(context, db, events, dismissType, notifyActivity) + + // Update our results based on the dismiss results + dismissResults.forEach { (event, result) -> + val index = results.indexOfFirst { it.first == event.eventId } + if (index != -1) { + results[index] = Pair(event.eventId, result) + } + } + } catch (ex: Exception) { + DevLog.error(LOG_TAG, "Unexpected error in safeDismissEvents by ID: ${ex.detailed}") + // Update all results to indicate error + eventIds.forEach { eventId -> + val index = results.indexOfFirst { it.first == eventId } + if (index != -1) { + results[index] = Pair(eventId, EventDismissResult.DatabaseError) + } + } + } + + return results + } + + /** + * Safely dismisses events based on reschedule confirmations with detailed error handling and result reporting. + * This method: + * 1. Filters for future events + * 2. Uses safeDismissEventsById for the actual dismissal + * 3. Updates the dismissal reason with the new time + * 4. Provides detailed feedback about the operation + * + * @param context The application context + * @param confirmations The list of reschedule confirmations + * @param notifyActivity Whether to notify the UI about the dismissal + * @return A list of pairs containing each event ID and its dismissal result + */ + fun safeDismissEventsFromRescheduleConfirmations( + context: Context, + confirmations: List, + notifyActivity: Boolean = false + ): List> { + DevLog.info(LOG_TAG, "Processing ${confirmations.size} reschedule confirmations") + + // Filter for future events + val futureEvents = confirmations.filter { it.is_in_future } + if (futureEvents.isEmpty()) { + DevLog.info(LOG_TAG, "No future events to dismiss") + android.widget.Toast.makeText(context, "No future events to dismiss", android.widget.Toast.LENGTH_SHORT).show() + return emptyList() + } + + // Get event IDs to dismiss + val eventIds = futureEvents.map { it.event_id } + + android.widget.Toast.makeText(context, "Attempting to dismiss ${eventIds.size} events", android.widget.Toast.LENGTH_SHORT).show() + + var results: List> = emptyList() + + // Use safeDismissEventsById to handle the dismissals + EventsStorage(context).classCustomUse { db -> + results = safeDismissEventsById( + context, + db, + eventIds, + EventDismissType.AutoDismissedDueToRescheduleConfirmation, + notifyActivity + ) + + // Log results + val successCount = results.count { it.second == EventDismissResult.Success } + val warningCount = results.count { it.second == EventDismissResult.DeletionWarning } + val notFoundCount = results.count { it.second == EventDismissResult.EventNotFound } + val errorCount = results.count { it.second == EventDismissResult.StorageError || it.second == EventDismissResult.NotificationError } + + // Main success/failure message + val mainMessage = "Dismissed $successCount events successfully, $notFoundCount events not found, $errorCount events failed" + DevLog.info(LOG_TAG, mainMessage) + android.widget.Toast.makeText(context, mainMessage, android.widget.Toast.LENGTH_LONG).show() + + // Separate warning message for deletion issues + if (warningCount > 0) { + val warningMessage = "Warning: Failed to delete $warningCount events from events storage (they were safely stored in dismissed storage)" + DevLog.warn(LOG_TAG, warningMessage) + android.widget.Toast.makeText(context, warningMessage, android.widget.Toast.LENGTH_LONG).show() + } + + // Group and log failures by reason + if (errorCount > 0) { + val failuresByReason = results + .filter { it.second == EventDismissResult.StorageError || it.second == EventDismissResult.NotificationError } + .groupBy { it.second } + .mapValues { it.value.size } + + failuresByReason.forEach { (reason, count) -> + DevLog.warn(LOG_TAG, "Failed to dismiss $count events: $reason") + android.widget.Toast.makeText(context, "Failed to dismiss $count events: $reason", android.widget.Toast.LENGTH_LONG).show() + } + } + } + + return results + } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/broadcastreceivers/RescheduleConfirmationsBroadcastReceiver.kt b/android/app/src/main/java/com/github/quarck/calnotify/broadcastreceivers/RescheduleConfirmationsBroadcastReceiver.kt new file mode 100644 index 000000000..26d5cfe4c --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/broadcastreceivers/RescheduleConfirmationsBroadcastReceiver.kt @@ -0,0 +1,27 @@ +package com.github.quarck.calnotify.broadcastreceivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.github.quarck.calnotify.app.ApplicationController +import com.github.quarck.calnotify.logs.DevLog + +class RescheduleConfirmationsBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) { + DevLog.error(LOG_TAG, "either context or intent is null!!") + return + } + + val value = intent.getStringExtra("reschedule_confirmations") + if (value != null) { + ApplicationController.onReceivedRescheduleConfirmations(context, value) + } else { + DevLog.error(LOG_TAG, "No reschedule confirmations data received") + } + } + + companion object { + private const val LOG_TAG = "BroadcastReceiverRescheduleConfirmations" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/quarck/calnotify/dismissedeventsstorage/DismissedEventAlertRecord.kt b/android/app/src/main/java/com/github/quarck/calnotify/dismissedeventsstorage/DismissedEventAlertRecord.kt index 203953900..074e1b083 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/dismissedeventsstorage/DismissedEventAlertRecord.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/dismissedeventsstorage/DismissedEventAlertRecord.kt @@ -25,7 +25,8 @@ enum class EventDismissType(val code: Int) { ManuallyDismissedFromNotification(0), ManuallyDismissedFromActivity(1), AutoDismissedDueToCalendarMove(2), - EventMovedUsingApp(3); + EventMovedUsingApp(3), + AutoDismissedDueToRescheduleConfirmation(4); companion object { @JvmStatic @@ -35,12 +36,22 @@ enum class EventDismissType(val code: Int) { val shouldKeep: Boolean get() = true; // this != EventMovedUsingApp + /** + * Indicates whether an event dismissed with this type can be restored by the user. + * for now I like the current behavior. where you can restore to the main events list and notification + * but have no expectation of being able to restore the event to the calendar db + * + * we could do more with it in the future if we wanted though + * + * For discussion on the behavior and implications of this flag, see: + * docs/dev_todo/event_restore_behavior.md + */ val canBeRestored: Boolean - get() = this != AutoDismissedDueToCalendarMove && this != EventMovedUsingApp + get() = this != AutoDismissedDueToCalendarMove && this != EventMovedUsingApp && this != AutoDismissedDueToRescheduleConfirmation } data class DismissedEventAlertRecord( val event: EventAlertRecord, // actual event that was dismissed val dismissTime: Long, // when dismissal happened val dismissType: EventDismissType // type of dismiss -) \ No newline at end of file +) diff --git a/android/app/src/main/java/com/github/quarck/calnotify/dismissedeventsstorage/EventDismissResult.kt b/android/app/src/main/java/com/github/quarck/calnotify/dismissedeventsstorage/EventDismissResult.kt new file mode 100644 index 000000000..d156908b0 --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/dismissedeventsstorage/EventDismissResult.kt @@ -0,0 +1,21 @@ +package com.github.quarck.calnotify.dismissedeventsstorage + +/** + * Represents the result of attempting to dismiss an event. + * Success is determined by whether the event was properly stored in the dismissed events storage. + * Deletion from the main events storage is considered a warning rather than a failure. + */ +enum class EventDismissResult(val code: Int) { + Success(0), // Event was found and stored in dismissed events storage + EventNotFound(1), // Event was not found in the database + DatabaseError(2), // Error occurred deleting from main storage + InvalidEvent(3), // Event is invalid + NotificationError(4), // Error occurred during notification handling + StorageError(5), // Failed to store in dismissed events storage + DeletionWarning(6); // Event was stored but failed to delete from main storage + + companion object { + @JvmStatic + fun fromInt(v: Int) = values()[v] + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/DismissedEventListAdapter.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/DismissedEventListAdapter.kt index 438ac250c..3ba7b9377 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/DismissedEventListAdapter.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/DismissedEventListAdapter.kt @@ -55,6 +55,9 @@ fun DismissedEventAlertRecord.formatReason(ctx: Context): String = EventDismissType.EventMovedUsingApp -> String.format(ctx.resources.getString(R.string.event_moved_new_time), dateToStr(ctx, this.event.startTime)) + + EventDismissType.AutoDismissedDueToRescheduleConfirmation -> + String.format(ctx.resources.getString(R.string.event_rescheduled_new_time), dateToStr(ctx, this.dismissTime)) } interface DismissedEventListCallback { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 46ee0189f..633aa7e70 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -190,6 +190,7 @@ Dismissed from the app on %s Dismissed via notification %s Moved, new time: %s + Confirmed Rescheduled on %s Restore notification Swipe to delete from history Remove all @@ -532,4 +533,4 @@ Only forward #alarm events to PebbleAPI Ignore expired events Do not process events if end time is already in the past - \ No newline at end of file + diff --git a/android/build.gradle b/android/build.gradle index 18840611d..5b4e022bc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -42,6 +42,7 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:$android_gradle_plugin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath("com.facebook.react:react-native-gradle-plugin") // NOTE: Do not place your application dependencies here; they belong diff --git a/docs/dev_todo/event_deletion_issues.md b/docs/dev_todo/event_deletion_issues.md new file mode 100644 index 000000000..f3fa2b7c7 --- /dev/null +++ b/docs/dev_todo/event_deletion_issues.md @@ -0,0 +1,61 @@ +# Event Deletion Issues and Cleanup + +## Current Issues + +### Failed Event Deletions +When an event fails to delete from EventsStorage during dismissal, several issues can occur: + +1. **Notification State Mismatch** + - The notification system continues to think the event is active + - Can lead to duplicate or stale notifications + +2. **Alarm Scheduling Issues** + - Alarms for the event continue to be scheduled + - Results in unnecessary notifications/reminders + +3. **State Inconsistency** + - Events might be added to DismissedEventsStorage even if not deleted from EventsStorage + - Creates inconsistent state where event exists in both active and dismissed storage + +4. **UI Inconsistency** + - UI might be notified of changes even though event wasn't actually deleted + - Can lead to incorrect display of events + +5. **Memory and Performance Impact** + - Failed deletions can lead to accumulation of stale events + - Impacts performance as database grows with invalid entries + +6. **Error Recovery** + - Current error handling only logs issues + - No automatic retry mechanism or cleanup process + - No comprehensive recovery system for failed deletions + +## TODO Items + +### 1. Implement purgeDismissed Functionality +Similar to `purgeOld` in DismissedEventsStorage, we need a cleanup mechanism for EventsStorage: + +```kotlin +// TODO: Implement similar to DismissedEventsStorage.purgeOld +fun purgeDismissed(context: Context) { + // Should: + // 1. Find events that failed to delete + // 2. Attempt to clean them up + // 3. Log any persistent issues + // 4. Potentially notify user of cleanup results +} +``` + +### 2. Improve Error Recovery +- Add retry mechanism for failed deletions +- Implement automatic cleanup of orphaned events +- Add better error reporting to UI + +### 3. Add State Validation +- Add periodic validation of EventsStorage and DismissedEventsStorage consistency +- Implement repair mechanisms for detected inconsistencies + +### 4. Improve Error Handling +- Add more detailed error reporting +- Implement proper rollback mechanisms for failed operations +- Add user notification for critical failures \ No newline at end of file diff --git a/docs/dev_todo/event_restore_behavior.md b/docs/dev_todo/event_restore_behavior.md new file mode 100644 index 000000000..baea97194 --- /dev/null +++ b/docs/dev_todo/event_restore_behavior.md @@ -0,0 +1,75 @@ +# Event Restoration Behavior + +## Background + +The application includes a feature to restore dismissed events. This is controlled by the `canBeRestored` property in the `EventDismissType` enum: + +```kotlin +val canBeRestored: Boolean + get() = this != AutoDismissedDueToCalendarMove && this != EventMovedUsingApp && this != AutoDismissedDueToRescheduleConfirmation +``` + +## Current Implementation + +Events can be dismissed in various ways: +- `ManuallyDismissedFromNotification` - User dismissed from notification +- `ManuallyDismissedFromActivity` - User dismissed from within the app +- `AutoDismissedDueToCalendarMove` - System detected the event was moved in calendar +- `EventMovedUsingApp` - Event was moved using the app +- `AutoDismissedDueToRescheduleConfirmation` - Event was dismissed due to rescheduling confirmation + +Currently, the last three types are marked as non-restorable, preventing them from being restored by the user. + +## Issue + +`AutoDismissedDueToRescheduleConfirmation` was recently added to the non-restorable list. However, there are cases where these events could potentially be restored, especially if the original event still exists in the device calendar database. + +The `canBeRestored` property appears to be designed for filtering which dismissed events can be shown to users for restoration, but its actual usage in the codebase is minimal. + +## Considerations + +### Technical Feasibility + +- Events dismissed due to rescheduling can technically be restored if they still exist in the calendar database +- Restoration works best when done on the same device where the dismissal occurred + +### User Experience + +- Allowing restoration of rescheduled events might lead to duplicate events (both original and rescheduled versions) +- Users might expect the ability to undo a rescheduling action if they made a mistake + +### Use Cases + +Legitimate reasons for restoring a rescheduled event: +- Accidental or incorrect rescheduling +- User changed their mind about the reschedule +- Rescheduling failed but the event was already dismissed + +## Options + +1. **Keep Current Behavior**: Events dismissed due to rescheduling confirmation cannot be restored. + - Pros: Cleaner experience, prevents potential duplicates + - Cons: Users cannot undo mistaken rescheduling actions + +2. **Allow Restoration**: Remove `AutoDismissedDueToRescheduleConfirmation` from the non-restorable list. + - Pros: More flexibility for users, allows recovery from mistakes + - Cons: Potential for confusion with duplicate events + +## Recommendation + +Consider the primary use case for the rescheduling confirmation feature: + +- If rescheduling is expected to be a definitive action with low error rates, keep it non-restorable +- If users might frequently make mistakes or change their minds about rescheduling, make it restorable + +Based on usage patterns and user feedback, the appropriate option can be implemented with a simple code change to the `canBeRestored` property. + +## Implementation + +To mark events dismissed due to rescheduling confirmation as restorable: + +```kotlin +val canBeRestored: Boolean + get() = this != AutoDismissedDueToCalendarMove && this != EventMovedUsingApp + // AutoDismissedDueToRescheduleConfirmation removed from non-restorable list +``` \ No newline at end of file diff --git a/docs/dev_todo/reschedule_confirmation_handling.md b/docs/dev_todo/reschedule_confirmation_handling.md new file mode 100644 index 000000000..a7a617664 --- /dev/null +++ b/docs/dev_todo/reschedule_confirmation_handling.md @@ -0,0 +1,80 @@ +# Reschedule Confirmation Handling + +## Overview + +This document summarizes the findings and considerations around handling reschedule confirmations in the Calendar Notifications app. The primary focus is on safely dismissing events based on reschedule confirmations and displaying accurate rescheduled time information to users. + +## Current Implementation + +- A new `safeDismissEventsFromRescheduleConfirmations` method has been added to `ApplicationController` to handle reschedule confirmations +- `JsRescheduleConfirmationObject` contains information about the original event and the new scheduled time +- When events are dismissed due to reschedule confirmation, they use the `AutoDismissedDueToRescheduleConfirmation` dismiss type + +## Challenge: Displaying the New Time + +Currently, when an event is rescheduled, the UI displays the time when the dismissal happened (the `dismissTime` field), not the actual new time the event was rescheduled to. + +The `DismissedEventAlertRecord` class can be updated with a `rescheduleConfirmation` field that contains the `JsRescheduleConfirmationObject` with the actual new time (`new_instance_start_time`). However, this data is not persistent beyond the current session. + +### Storage Considerations + +Several options were considered for making the reschedule time persistent: + +1. **Add a dedicated column to the database**: + - Requires creating a new implementation and database upgrade path + - Most robust but most complex solution + +2. **Repurpose an existing unused column**: + - Several reserved columns exist in the schema (`KEY_RESERVED_INT2`, `KEY_RESERVED_STR2`, etc.) + - Would change the select queries and semantics of these fields + - Lacks tests for database reading and writing + - Not to mention worrying about serializing the confirmation properly though we could probably use the same `JsRescheduleConfirmationObject` we already have + +3. **Create a separate reschedule confirmations database**: + - Would require additional infrastructure + - Increases complexity for a relatively simple feature + +4. **Keep the current transient approach**: + - Display the accurate new time only during the current session + - Fall back to showing the dismissal time after app restart + +## Current Decision + +For now, we've implemented a simpler approach: +- Added the `safeDismissEventsFromRescheduleConfirmations` method to safely handle reschedule confirmations +- This method provides better error handling and logging than the previous implementation +- The accurate display of the new scheduled time remains an enhancement to be addressed in a future update + +## Future Work + +1. **Database Schema Update**: + - Plan a proper database schema update to store the rescheduled time + - Add tests for database reading and writing before making such changes + +2. **UI Enhancements**: + - Update the UI to clearly distinguish between: + - Events manually dismissed by users + - Events auto-dismissed due to rescheduling + - Events moved using the app + +3. **Testing**: + - Add end-to-end tests for the reschedule confirmation flow + - Ensure proper error handling for edge cases + +## Implementation Notes + +When eventually implementing persistent storage of reschedule confirmation data, we should: + +1. Create a new database version (`DATABASE_VERSION_V3`) +2. Add a migration path from V2 to V3 +3. Add a dedicated field for storing the rescheduled time +4. Update the UI to display this time +5. Add comprehensive tests for the feature + +## References + +- `DismissedEventAlertRecord` class +- `DismissedEventsStorageImplV2.kt` +- `ApplicationController.kt` - `safeDismissEventsFromRescheduleConfirmations` method +- `DismissedEventListAdapter.kt` - `formatReason` method +- `JsRescheduleConfirmationObject` class \ No newline at end of file diff --git a/lib/powersync/Schema.tsx b/lib/powersync/Schema.tsx index eaaac8b46..aef292d57 100644 --- a/lib/powersync/Schema.tsx +++ b/lib/powersync/Schema.tsx @@ -37,8 +37,30 @@ const eventsV9 = new Table( { indexes: {} } ); +const reschedule_confirmations = new Table( + { + // id column (text) is automatically included + event_id: column.integer, + calendar_id: column.integer, + original_instance_start_time: column.integer, + title: column.text, + new_instance_start_time: column.integer, + + // 0 past, 1 future + // https://docs.powersync.com/usage/sync-rules/types#postgres-type-mapping + // to quote the powersync docs: + // "There is no dedicated boolean data type. Boolean values are represented as 1 (true) or 0 (false)." + is_in_future: column.integer, + meta: column.text, + created_at: column.text, + updated_at: column.text + }, + { indexes: {} } +); + export const AppSchema = new Schema({ - eventsV9 + eventsV9, + reschedule_confirmations }); export type Database = (typeof AppSchema)['types']; diff --git a/modules/my-module/android/build.gradle b/modules/my-module/android/build.gradle index 5c3c6c71e..adf34f071 100644 --- a/modules/my-module/android/build.gradle +++ b/modules/my-module/android/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' // Add this line apply plugin: 'maven-publish' group = 'expo.modules.mymodule' @@ -32,6 +33,7 @@ buildscript { dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}") + classpath "org.jetbrains.kotlin:kotlin-serialization:${getKotlinVersion()}" } } @@ -88,4 +90,5 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") } diff --git a/modules/my-module/android/src/main/java/expo/modules/mymodule/JsRescheduleConfirmationObject.kt b/modules/my-module/android/src/main/java/expo/modules/mymodule/JsRescheduleConfirmationObject.kt new file mode 100644 index 000000000..a034d3ba0 --- /dev/null +++ b/modules/my-module/android/src/main/java/expo/modules/mymodule/JsRescheduleConfirmationObject.kt @@ -0,0 +1,16 @@ +package expo.modules.mymodule + +import kotlinx.serialization.Serializable + +@Serializable +data class JsRescheduleConfirmationObject( + val event_id: Long, + val calendar_id: Long, + val original_instance_start_time: Long, + val title: String, + val new_instance_start_time: Long?, + val is_in_future: Boolean, + val meta: String? = null, + val created_at: String, + val updated_at: String +) diff --git a/modules/my-module/android/src/main/java/expo/modules/mymodule/MyModule.kt b/modules/my-module/android/src/main/java/expo/modules/mymodule/MyModule.kt index d0aa6a24e..7564f9fbb 100644 --- a/modules/my-module/android/src/main/java/expo/modules/mymodule/MyModule.kt +++ b/modules/my-module/android/src/main/java/expo/modules/mymodule/MyModule.kt @@ -1,8 +1,14 @@ package expo.modules.mymodule +import android.content.Intent +import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import kotlinx.serialization.json.Json + class MyModule : Module() { // Each module class must implement the definition function. The definition consists of components @@ -39,6 +45,21 @@ class MyModule : Module() { )) } + AsyncFunction("sendRescheduleConfirmations") { value: String -> + val rescheduleConfirmations = Json.decodeFromString>(value) + + Log.i(TAG, rescheduleConfirmations.take(3).toString()) + + // Send intent with reschedule confirmations data + val intent = Intent("com.github.quarck.calnotify.RESCHEDULE_CONFIRMATIONS_RECEIVED") + intent.putExtra("reschedule_confirmations", value) + appContext.reactContext?.sendBroadcast(intent) + + sendEvent("onChange", mapOf( + "value" to value + )) + } + // Enables the module to be used as a native view. Definition components that are accepted as part of // the view definition: Prop, Events. View(MyModuleView::class) { diff --git a/modules/my-module/index.ts b/modules/my-module/index.ts index f5ee2b7b8..337909061 100644 --- a/modules/my-module/index.ts +++ b/modules/my-module/index.ts @@ -5,7 +5,7 @@ import { NativeModulesProxy, EventEmitter, Subscription } from 'expo-modules-cor // and on native platforms to MyModule.ts import MyModule from './src/MyModule'; import MyModuleView from './src/MyModuleView'; -import { ChangeEventPayload, MyModuleViewProps } from './src/MyModule.types'; +import type { ChangeEventPayload, MyModuleViewProps } from './src/MyModule.types'; // Get the native constant value. export const PI = MyModule.PI; @@ -14,16 +14,57 @@ export function hello(): string { return MyModule.hello(); } +export interface RescheduleConfirmation { + event_id: number; + calendar_id: number; + original_instance_start_time: number; + title: string; + new_instance_start_time: number; + is_in_future: boolean; + meta?: string; + created_at: string; + updated_at: string; +} + +export interface RawRescheduleConfirmation { + event_id: number; + calendar_id: number; + original_instance_start_time: number; + title: string; + new_instance_start_time: number; + is_in_future: number; + meta?: string; + created_at: string; + updated_at: string; +} + +function convertToRescheduleConfirmation(raw: RawRescheduleConfirmation): RescheduleConfirmation { + return { + ...raw, + is_in_future: raw.is_in_future === 1 + }; +} + export async function setValueAsync(value: string) { return await MyModule.setValueAsync(value); } +export async function sendRescheduleConfirmations(value: RawRescheduleConfirmation[]) { + try { + const converted = value.map(convertToRescheduleConfirmation); + return await MyModule.sendRescheduleConfirmations(JSON.stringify(converted)); + } catch (error) { + console.error('Error in sendRescheduleConfirmations:', error); + throw error; + } +} + const emitter = new EventEmitter(MyModule ?? NativeModulesProxy.MyModule); export function addChangeListener(listener: (event: ChangeEventPayload) => void): Subscription { return emitter.addListener('onChange', listener); } -export { MyModuleView, type MyModuleViewProps, type ChangeEventPayload }; +export { MyModuleView, MyModuleViewProps, ChangeEventPayload }; export default MyModule; diff --git a/scripts/setup_dev_container_android_toolchain.sh b/scripts/setup_dev_container_android_toolchain.sh new file mode 100755 index 000000000..e615a177e --- /dev/null +++ b/scripts/setup_dev_container_android_toolchain.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Setup script for devcontainer aka GitHub Codespace android development environment +brew install ccache + +## begin ephemeral android sdk setup. will have to do this every time. +# so I can run this everytime if I want +export ANDROID_HOME=/tmp/Android/Sdk +export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin +export PATH=$PATH:$ANDROID_HOME/platform-tools +export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH +export ANDROID_AVD_HOME=/tmp/my_avd_home + +# why becuase they give you hella temp space but a small of permanent +wget https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip +mv commandlinetools-linux-13114758_latest.zip /tmp/ +mkdir -p $ANDROID_HOME +unzip /tmp/commandlinetools-linux-13114758_latest.zip -d $ANDROID_HOME +mkdir -p $ANDROID_HOME/cmdline-tools/ +mkdir -p $ANDROID_HOME/cmdline-tools/latest/ +mkdir -p $ANDROID_AVD_HOME +mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ + +yes | sdkmanager --sdk_root=${ANDROID_HOME} "platform-tools" "platforms;android-34" "build-tools;34.0.0" +yes | sdkmanager --install "system-images;android-34;google_apis_playstore;x86_64" + +mkdir -p $ANDROID_AVD_HOME + +sudo apt -y install qemu-kvm +sudo groupadd -r kvm +sudo adduser $USER kvm +sudo chown $USER /dev/kvm + +avdmanager create avd --name 7.6_Fold-in_with_outer_display_API_34 --package "system-images;android-34;google_apis_playstore;x86_64" --device "7.6in Foldable" +# you can run with emulator -avd 7.6_Fold-in_with_outer_display_API_34 -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot + +echo "" +echo "======================================================================" +echo "Setup complete!" +echo "Don't forget to run:" +echo "" +echo " scripts/start_the_android_party.sh" +echo "" +echo "======================================================================" \ No newline at end of file diff --git a/scripts/setup_github_codespace.sh b/scripts/setup_github_codespace.sh index 993b31e7d..89e4af4fc 100644 --- a/scripts/setup_github_codespace.sh +++ b/scripts/setup_github_codespace.sh @@ -1,10 +1,24 @@ #!/bin/bash # Setup script for GitHub Codespace environment +# Setup Git configuration +# username and email are setup by github +echo "Setting up Git configuration..." +git config --global color.ui true +git config --global color.branch true +git config --global color.diff true +git config --global color.status true +git config --global color.log true +git config --global alias.co checkout +git config --global alias.ci commit +git config --global alias.st status +git config --global alias.br branch +git config --global alias.log "log --color=always" + # Install McFly using Homebrew +touch $HOME/.bash_history echo "Installing McFly..." brew install mcfly - cp .devcontainer/mcfly_history.db ~/.local/share/mcfly/history.db # Configure McFly for bash @@ -19,57 +33,20 @@ echo 'esac' >> ~/.bashrc echo '' >> ~/.bashrc echo 'eval "$(mcfly init bash)"' >> ~/.bashrc -echo 'export ANDROID_HOME=/tmp/Android/Sdk' >> ~/.bashrc -echo 'export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' >> ~/.bashrc -echo 'export PATH=$PATH:$ANDROID_HOME/platform-tools' >> ~/.bashrc -echo 'export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH' >> ~/.bashrc -echo 'export ANDROID_AVD_HOME=/tmp/my_avd_home' >> ~/.bashrc - -## begin ephemeral android sdk setup. will have to do this every time. -# why becuase they give you hella temp space but a small of permanent -wget https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip -mv commandlinetools-linux-13114758_latest.zip /tmp/ -mkdir -p $ANDROID_HOME -unzip /tmp/commandlinetools-linux-13114758_latest.zip -d $ANDROID_HOME -mkdir -p $ANDROID_HOME/cmdline-tools/ -mkdir -p $ANDROID_HOME/cmdline-tools/latest/ -mkdir -p $ANDROID_AVD_HOME -mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ - -yes | sdkmanager --sdk_root=${ANDROID_HOME} "platform-tools" "platforms;android-34" "build-tools;34.0.0" -yes | sdkmanager --install "system-images;android-34;google_apis_playstore;x86_64" - -sudo apt install qemu-kvm -sudo groupadd -r kvm -sudo adduser $USER kvm -sudo chown $USER /dev/kvm - - -avdmanager create avd --name 7.6_Fold-in_with_outer_display_API_34 --package "system-images;android-34;google_apis_playstore;x86_64" --device "7.6in Foldable" -# you can run with emulator -avd 7.6_Fold-in_with_outer_display_API_34 -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot - - - -# end ephemeral android sdk setup # begin ephemeral gradle setup export GRADLE_USER_HOME=/tmp/gradle-cache mkdir -p $GRADLE_USER_HOME # end ephemeral gradle setup - -# Setup Git configuration -echo "Setting up Git configuration..." -git config --global color.ui true -git config --global color.branch true -git config --global color.diff true -git config --global color.status true -git config --global color.log true -git config --global alias.co checkout -git config --global alias.ci commit -git config --global alias.st status -git config --global alias.br branch -git config --global alias.log "log --color=always" +## begin ephemeral android sdk setup. will have to do this every time. +echo 'export ANDROID_HOME=/tmp/Android/Sdk' >> ~/.bashrc +echo 'export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' >> ~/.bashrc +echo 'export PATH=$PATH:$ANDROID_HOME/platform-tools' >> ~/.bashrc +echo 'export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH' >> ~/.bashrc +echo 'export ANDROID_AVD_HOME=/tmp/my_avd_home' >> ~/.bashrc +./scripts/setup_dev_container_android_toolchain.sh +# end ephemeral android sdk setup # Source bashrc to apply changes in current session echo "Applying changes to current session..." diff --git a/scripts/start_the_android_party.sh b/scripts/start_the_android_party.sh new file mode 100755 index 000000000..a8ae36894 --- /dev/null +++ b/scripts/start_the_android_party.sh @@ -0,0 +1,5 @@ +NUM_CPUS=$(nproc) +yarn +./scripts/ccachify_native_modules.sh +cd android +./gradlew :app:assembleX8664Debug :app:bundleX8664Release :app:assembleX8664DebugAndroidTest --parallel --max-workers=$NUM_CPUS --build-cache --configure-on-demand --info \ No newline at end of file