Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
906cf0a
Fix mobile realtime refresh parsing
ohmzi May 22, 2026
a3a3c66
Fix mobile reminder deep links
ohmzi May 22, 2026
30a6113
Refine Android calendar paging
ohmzi May 22, 2026
b2075e5
Extract mobile calendar pager helpers
ohmzi May 22, 2026
e986563
Align mobile contracts and sync fetch
ohmzi May 22, 2026
f02452e
Remember mobile server URL credentials
ohmzi May 22, 2026
e946caf
Set the development team and reorder file references in the iOS Xcode…
ohmzi May 22, 2026
f1ab0cb
Fix iOS reinstall server URL cleanup
ohmzi May 22, 2026
97aa54a
Keep Android server URL out of password manager
ohmzi May 22, 2026
b4731cb
Fix iOS server URL password prompt
ohmzi May 22, 2026
c04a669
Restore iOS password prompts
ohmzi May 22, 2026
936d306
Target iOS saved login prompts
ohmzi May 22, 2026
ecc6cf5
Restore iOS password prompts
ohmzi May 22, 2026
c2fbbf8
Restore Android server URL credentials
ohmzi May 22, 2026
1c4b1e1
Sequence Android credential prompts
ohmzi May 22, 2026
7f4ad1f
Set the development team and reorder file references in the iOS Xcode…
ohmzi May 22, 2026
e98e28d
Enable automatic iOS signing
ohmzi May 22, 2026
33a83d1
Update the development team ID in the Xcode project settings.
ohmzi May 22, 2026
72d399c
Rename mobile app display name
ohmzi May 22, 2026
3186b2d
Include Sentry dSYM in iOS archives
ohmzi May 22, 2026
df535fe
Settle Android credential handoff
ohmzi May 22, 2026
e827ca6
Use dynamic Sentry iOS package
ohmzi May 22, 2026
d83bb9a
Update iOS signing team
ohmzi May 22, 2026
7b4a732
Align the iOS project signing team with the Apple Development certifi…
ohmzi May 22, 2026
9731a8a
Configure iOS webcredentials server association
ohmzi May 23, 2026
3ba340b
Refine iOS onboarding credential prompts
ohmzi May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -162,5 +162,5 @@ TDAY_PROBE_ENCRYPTION_KEY=
# Apple Developer Team ID used for Tday's canonical Apple Passwords webcredentials payload.
# Native iOS saves Tday credentials under tday.ohmz.cloud regardless of the connected server URL.
# Required in production for iOS Passwords / iCloud Keychain to trust the native app.
APPLE_TEAM_ID=
APPLE_TEAM_ID=THT5Z8K3TF
IOS_BUNDLE_ID=com.ohmz.tday.ios
2 changes: 1 addition & 1 deletion android-compose/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="T'Day"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Tday"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.Manifest
import android.app.NotificationManager
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
Expand All @@ -29,7 +30,9 @@ class MainActivity : ComponentActivity() {
setTheme(R.style.Theme_Tday)
super.onCreate(savedInstanceState)
enableEdgeToEdge()
_deepLinkIntent.value = intent
val launchIntent = intent.withTdayDeepLinkData()
setIntent(launchIntent)
dispatchDeepLinkIntent(launchIntent)
setContent {
TdayApp(
onFirstFrameDrawn = {
Expand All @@ -43,9 +46,14 @@ class MainActivity : ComponentActivity() {

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val deepLinkIntent = intent.withTdayDeepLinkData()
setIntent(deepLinkIntent)
dismissUpdateReadyNotification()
_deepLinkIntent.value = intent
dispatchDeepLinkIntent(deepLinkIntent)
}

private fun dispatchDeepLinkIntent(intent: Intent) {
_deepLinkIntent.value = intent.withTdayDeepLinkData()
}

private fun dismissUpdateReadyNotification() {
Expand All @@ -61,3 +69,13 @@ class MainActivity : ComponentActivity() {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

internal fun Intent.withTdayDeepLinkData(): Intent {
if (data != null) return this
val deepLink = getStringExtra(EXTRA_DEEP_LINK)?.takeIf { it.isNotBlank() } ?: return this
return Intent(this).apply {
data = Uri.parse(deepLink)
}
}

private const val EXTRA_DEEP_LINK = "deepLink"
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,9 @@ fun TdayApp(
onConnectServer = { rawUrl, onResult ->
appViewModel.saveServerUrl(
rawUrl = rawUrl,
onSuccess = { onResult(Result.success(Unit)) },
onSuccess = { serverUrl ->
onResult(Result.success(serverUrl))
},
onFailure = { message ->
onResult(Result.failure(IllegalStateException(message)))
},
Expand Down Expand Up @@ -425,6 +427,8 @@ fun TdayApp(
}
},
onRequestSavedCredential = authViewModel::requestSavedCredential,
onRequestSavedServerUrl = authViewModel::requestSavedServerUrl,
onSaveServerUrlCredential = authViewModel::offerSaveOrUpdateServerUrl,
onClearAuthStatus = {
authViewModel.clearStatus()
appViewModel.clearPendingApprovalNotice()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,25 +37,47 @@ enum class LoginCredentialSource {
}

interface SystemCredentialServicing {
suspend fun requestSavedCredential(context: Context): SystemCredential?
suspend fun requestSavedCredential(
context: Context,
preferredEmail: String? = null,
): SystemCredential?

suspend fun offerSaveOrUpdateCredential(
context: Context,
credential: SystemCredential,
): SystemCredentialSaveResult

suspend fun requestSavedServerUrl(context: Context): String?
suspend fun offerSaveOrUpdateServerUrl(
context: Context,
serverUrl: String,
): SystemCredentialSaveResult

suspend fun clearCredentialState()
}

@Singleton
class SystemCredentialService @Inject constructor(
@ApplicationContext private val appContext: Context,
) : SystemCredentialServicing {
override suspend fun requestSavedCredential(context: Context): SystemCredential? {
override suspend fun requestSavedCredential(
context: Context,
preferredEmail: String?,
): SystemCredential? {
val activity = context.findActivity() ?: return null
val credentialManager = CredentialManager.create(activity)
val allowedUserIds = preferredEmail
?.trim()
?.lowercase(Locale.US)
?.takeIf { it.isNotBlank() }
?.let { setOf(it) }
?: emptySet()
val request = GetCredentialRequest(
credentialOptions = listOf(
GetPasswordOption(isAutoSelectAllowed = true),
GetPasswordOption(
allowedUserIds = allowedUserIds,
isAutoSelectAllowed = false,
),
),
)

Expand All @@ -65,8 +87,8 @@ class SystemCredentialService @Inject constructor(
request = request,
).credential
when (credential) {
is PasswordCredential -> SystemCredential(
email = credential.id,
is PasswordCredential -> SystemCredentialRecords.loginCredential(
id = credential.id,
password = credential.password,
)

Expand Down Expand Up @@ -111,6 +133,70 @@ class SystemCredentialService @Inject constructor(
}
}

override suspend fun requestSavedServerUrl(context: Context): String? {
val activity = context.findActivity() ?: return null
val credentialManager = CredentialManager.create(activity)
val request = GetCredentialRequest(
credentialOptions = listOf(
GetPasswordOption(
allowedUserIds = setOf(SystemCredentialRecords.SERVER_URL_CREDENTIAL_ID),
isAutoSelectAllowed = false,
),
),
)

return try {
val credential = credentialManager.getCredential(
context = activity,
request = request,
).credential
when (credential) {
is PasswordCredential -> SystemCredentialRecords.serverUrl(
id = credential.id,
password = credential.password,
)

else -> null
}
} catch (_: GetCredentialException) {
null
}
}

override suspend fun offerSaveOrUpdateServerUrl(
context: Context,
serverUrl: String,
): SystemCredentialSaveResult {
val normalizedServerUrl = serverUrl.trim()
if (normalizedServerUrl.isBlank()) {
return SystemCredentialSaveResult.SKIPPED
}

val activity = context.findActivity() ?: return SystemCredentialSaveResult.FAILED
val credentialManager = CredentialManager.create(activity)
val request = CreatePasswordRequest(
id = SystemCredentialRecords.SERVER_URL_CREDENTIAL_ID,
password = normalizedServerUrl,
)

return try {
credentialManager.createCredential(
context = activity,
request = request,
)
SystemCredentialSaveResult.SAVED
} catch (_: CreateCredentialCancellationException) {
SystemCredentialSaveResult.CANCELLED
} catch (error: CreateCredentialException) {
Log.w(
LOG_TAG,
"Android Password Manager could not save server URL: ${error.type}",
error
)
SystemCredentialSaveResult.FAILED
}
Comment on lines +190 to +197
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch block is unreachable.


Unreachable catch blocks should be removed, because they serve no useful purpose and can lead to confusing and potentially incorrect behavior in exception handling.

}

override suspend fun clearCredentialState() {
try {
val credentialManager = CredentialManager.create(appContext)
Expand All @@ -125,6 +211,26 @@ class SystemCredentialService @Inject constructor(
}
}

internal object SystemCredentialRecords {
const val SERVER_URL_CREDENTIAL_ID = "T'Day Server URL"

fun loginCredential(id: String, password: String): SystemCredential? {
val normalizedId = id.trim()
// Older builds briefly saved server URLs as password records; never treat those as logins.
if (normalizedId == SERVER_URL_CREDENTIAL_ID) return null
if (normalizedId.isBlank() || password.isBlank()) return null
return SystemCredential(
email = normalizedId,
password = password,
)
}

fun serverUrl(id: String, password: String): String? {
if (id.trim() != SERVER_URL_CREDENTIAL_ID) return null
return password.trim().takeIf { it.isNotBlank() }
}
}

private tailrec fun Context.findActivity(): Activity? {
return when (this) {
is Activity -> this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class ServerConfigRepository @Inject constructor(

val saved = secureConfigStore.saveServerUrl(
rawUrl = normalizedServerUrl,
persist = false,
persist = true,
).getOrThrow()
secureConfigStore.clearOfflineSyncState()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import com.ohmz.tday.compose.core.model.UpdateTodoRequest
import com.ohmz.tday.compose.core.network.TdayApiService
import com.ohmz.tday.compose.feature.widget.TodayTasksWidget
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
Expand Down Expand Up @@ -170,36 +172,44 @@ class SyncManager @Inject constructor(
return true
}

private suspend fun fetchRemoteSnapshot(): RemoteSnapshot {
val todos = requireApiBody(
api.getTodos(timeline = true),
"Could not load timeline tasks",
).todos.map(::mapTodoDto)

val completed = requireApiBody(
api.getCompletedTodos(),
"Could not load completed tasks",
).completedTodos.map(::mapCompletedDto)
private suspend fun fetchRemoteSnapshot(): RemoteSnapshot = coroutineScope {
val todos = async {
requireApiBody(
api.getTodos(timeline = true),
"Could not load timeline tasks",
).todos.map(::mapTodoDto)
}

val lists = requireApiBody(
api.getLists(),
"Could not load lists",
).lists.map { mapListDto(it, iconFallback = secureConfigStore.getListIcon(it.id)) }
val completed = async {
requireApiBody(
api.getCompletedTodos(),
"Could not load completed tasks",
).completedTodos.map(::mapCompletedDto)
}

val aiSummaryEnabled = runCatching {
val lists = async {
requireApiBody(
api.getAppSettings(),
"Could not load app settings",
).aiSummaryEnabled
}.getOrElse {
cacheManager.loadOfflineState().aiSummaryEnabled
api.getLists(),
"Could not load lists",
).lists.map { mapListDto(it, iconFallback = secureConfigStore.getListIcon(it.id)) }
}

val aiSummaryEnabled = async {
runCatching {
requireApiBody(
api.getAppSettings(),
"Could not load app settings",
).aiSummaryEnabled
}.getOrElse {
cacheManager.loadOfflineState().aiSummaryEnabled
}
}

return RemoteSnapshot(
todos = todos,
completedItems = completed,
lists = lists,
aiSummaryEnabled = aiSummaryEnabled,
RemoteSnapshot(
todos = todos.await(),
completedItems = completed.await(),
lists = lists.await(),
aiSummaryEnabled = aiSummaryEnabled.await(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ typealias ListsResponse = com.ohmz.tday.shared.model.ListsResponse
typealias CreateListRequest = com.ohmz.tday.shared.model.CreateListRequest
typealias ListDto = com.ohmz.tday.shared.model.ListDto
typealias CreateListResponse = com.ohmz.tday.shared.model.CreateListResponse
typealias ListDetailResponse = com.ohmz.tday.shared.model.ListDetailResponse
typealias UpdateListRequest = com.ohmz.tday.shared.model.UpdateListRequest
typealias DeleteListRequest = com.ohmz.tday.shared.model.DeleteListRequest
typealias DeleteListResponse = com.ohmz.tday.shared.model.DeleteListResponse
typealias CompletedTodosResponse = com.ohmz.tday.shared.model.CompletedTodosResponse
typealias CompletedTodoDto = com.ohmz.tday.shared.model.CompletedTodoDto
typealias UpdateCompletedTodoRequest = com.ohmz.tday.shared.model.UpdateCompletedTodoRequest
Expand Down
Loading
Loading