A memory-refresh document covering what has been built, how it works, and where to pick up. Last updated: July 2026 (codebase state as of the last commits in January 2026).
Tasky is a task/agenda management Android app built as a learning project against the Tasky API (Philipp Lackner's backend). It is written in Kotlin with Jetpack Compose, structured as a multi-module Gradle project, and uses Hilt for dependency injection and Retrofit/OkHttp for networking.
| Area | Status |
|---|---|
| Registration & Login (UI + API + validation feedback) | ✅ Done |
| Encrypted session/token persistence | ✅ Done |
| Splash screen with startup auth check | ✅ Done |
| Type-safe Compose navigation (nested auth/agenda graphs) | ✅ Done |
| Unit tests (repository + ViewModels) | ✅ Done |
| CI (GitHub Actions: lint + tests) and CodeQL | ✅ Done |
| Agenda feature | 🚧 Placeholder screen only ("Coming Soon") |
| Token refresh | ❌ Not implemented (refresh token is stored but unused) |
| Logout UI | ❌ Repository/API exist, nothing calls them from UI |
The last substantial work (PRs #6–#8) was: the splash screen + startup auth check,
centralizing build config in the root build.gradle.kts, creating the (still empty)
features:agenda module, and setting up CI/CodeQL.
Tasky
├── app # Entry point: MainActivity, MainViewModel, NavHost, theme
├── features
│ ├── auth # Complete feature: data + domain + presentation (login/register)
│ └── agenda # ⚠️ Shell only — has a build.gradle.kts but NO source code yet
└── core
├── domain/util # Pure utilities: Result, Error, UiText
└── data # Networking (OkHttp/Retrofit DI), token storage, crypto
Dependency direction: app → features:auth → core:data → core:domain:util.
Type-safe project accessors are enabled (projects.core.data syntax) in settings.gradle.kts.
MainActivity.kt— splash screen handling, rendersTaskyNavHostonce auth check finishes.MainViewModel.kt— holdsMainState(isCheckingAuth, isLoggedIn); asksTokenManagerif the stored token is valid.ui/navigation/Routes.kt+TaskyNavHost.kt— type-safe navigation graphs.ui/theme/— standard Material 3 theme scaffolding.
Classic three-layer feature module:
data/remote/—AuthApi(Retrofit interface:auth/login,auth/register,auth/logout), DTOs,AuthResponse.data/repository/DefaultAuthRepository.kt— implements the domain interface; maps exceptions/HTTP codes to typed errors; saves the session on login.domain/—AuthRepositoryinterface,AuthErrorsealed error types.presentation/login/,presentation/register/— Screen + ViewModel + Action + Event per screen.presentation/agenda/AgendaScreen.kt—⚠️ the placeholder agenda screen currently lives here, not infeatures:agenda(see §8).presentation/util/—ObserveAsEvents(lifecycle-aware one-shot event collector),toUiText(error → string resource mapping).di/AuthModule.kt— providesAuthApiandAuthRepository.
Three small but load-bearing files used everywhere:
Result.kt— genericResult<D, E : Error>withmap,onSuccess,onError,asEmptyDataResult, andtypealias EmptyResult<E> = Result<Unit, E>.Error.kt— marker interface all error enums implement.UiText.kt—DynamicString/StringResourcewrapper so ViewModels never touch raw strings orContext.
di/CoreDataModule.kt— the network stack:Json,CryptoManager,TokenManager, both interceptors,OkHttpClient,Retrofit.remote/ApiKeyInterceptor.kt— addsx-api-keyheader fromBuildConfig.API_KEYto every request.remote/AuthTokenInterceptor.kt— addsAuthorization: Bearer <accessToken>if a session exists.token/TokenManager.kt— interface +SessionDatamodel (access/refresh tokens, user id/name, expiry timestamp).token/TokenStorage.kt—DataStoreTokenStorage, the implementation (see §5).security/CryptoManager.kt— AES/GCM encryption backed by the Android Keystore.local/EncryptedTokenData.kt— the@SerializableDTO persisted to disk.
- Kotlin 2.0.21, AGP 8.13.0, KSP;
compileSdk 36,minSdk 33, JVM target 11 - Jetpack Compose (BOM 2024.09.00) + Material 3, Navigation Compose 2.8.5 (type-safe routes via
@Serializable) - Hilt 2.57.2 (+
hilt-navigation-compose) - Retrofit 2.9 with the kotlinx-serialization converter, OkHttp 4.12 (+ logging interceptor)
- DataStore Preferences for persistence, Android Keystore (via custom
CryptoManager) for encryption - Timber for logging
- Tests: JUnit 4, MockK, Turbine (Flow testing),
kotlinx-coroutines-test
MainActivityinstalls the AndroidX splash screen and keeps it visible whileMainViewModel.state.isCheckingAuthis true.MainViewModel.initcallstokenManager.isTokenValid()— this reads the stored session and checks the access-token expiry timestamp againstSystem.currentTimeMillis()with a 5-minute safety buffer.- When the check completes, the splash dismisses and
TaskyNavHostrenders withisAuthenticateddeciding the start destination:- valid session →
AgendaGraphRoutes.AgendaGraph - otherwise →
AuthGraphRoutes.AuthGraph(starts at Login)
- valid session →
Type-safe navigation with @Serializable route objects grouped in two nested graphs
(AuthGraphRoutes, AgendaGraphRoutes). On login success the whole auth graph is popped:
navController.navigate(AgendaGraphRoutes.AgendaGraph) {
popUpTo(AuthGraphRoutes.AuthGraph) { inclusive = true }
}so the user can't press "back" into the login screen.
Each screen follows the same shape — worth internalizing because the agenda feature should copy it:
- State: a
data class(LoginUiState,RegisterUiState) exposed asvar state by mutableStateOf(...)with aprivate set. - Actions: a sealed interface (
LoginAction,RegisterAction) — the UI calls a singleviewModel.onAction(action)entry point. Typing into a field also clears the current error. - One-shot events (navigation, snackbars): a
Channel<Event>exposed asevents = eventChannel.receiveAsFlow(), collected in the UI with the customObserveAsEventscomposable, which usesrepeatOnLifecycle(STARTED)+Dispatchers.Main.immediateso events aren't dropped or replayed on rotation.
The flow of an error from the network to the screen:
DefaultAuthRepository.safeApiCallcatches exceptions and maps them to typed errors:HttpException400/401/409/5xx →AuthError.Auth.*/AuthError.Network.SERVER_ERROR,SocketTimeoutException→TIMEOUT,IOException→NO_INTERNET, anything else →UNKNOWN(CancellationExceptionis correctly rethrown).- The repository returns
Result<T, AuthError>(type aliases:LoginResult,RegisterResult,LogoutResult). - The ViewModel chains
.onSuccess { } .onError { }and converts errors withAuthError.toUiText()→ aUiText.StringResourcepointing atstrings.xml. - The composable resolves it via the
@Composable UiText.asString()extension.
No raw exception messages or hardcoded strings ever reach the UI.
One OkHttpClient with three interceptors, in order:
ApiKeyInterceptor—x-api-key: BuildConfig.API_KEYon every call.AuthTokenInterceptor—Authorization: Bearer <token>when a session exists (usesrunBlockingto bridge into the suspendTokenManager— standard for OkHttp interceptors).HttpLoggingInterceptoratLevel.BODY.
Retrofit is built against BuildConfig.BASE_URL with the kotlinx-serialization converter
(Json { ignoreUnknownKeys = true }).
On successful login, DefaultAuthRepository saves a SessionData (access + refresh token,
userId, username, expiry timestamp). DataStoreTokenStorage.saveSession then:
- Serializes it to JSON (
EncryptedTokenData). - Encrypts the bytes with
CryptoManager— AES/GCM/NoPadding with a key named"secret"generated in and never leaving the Android Keystore. The output is[4-byte IV length][IV][ciphertext]so decryption can recover the IV. - Base64-encodes and stores it under one key (
session_data) in DataStore (session_prefs).
getSession() reverses this and returns null on any decryption failure (fail-safe: user just
logs in again). This single-encrypted-blob design was a deliberate refactor (PR #5) away from
storing fields individually.
- The API key is resolved in the root
build.gradle.kts:System.getenv("API_KEY") ?: local.properties "apiKey"— so locally add tolocal.properties:(Note: the value is injected verbatim intoapiKey="your-tasky-api-key"
buildConfigField, so it needs the quotes.) - The base URL (
https://tasky.pl-coding.com/) is hardcoded in the same file and both land inBuildConfigofcore:data,features:auth, andfeatures:agenda— debug build type only (see §8). - Build/test:
./gradlew assembleDebug,./gradlew test,./gradlew lint. JDK 17 is what CI uses.
All meaningful tests live in features/auth/src/test/:
DefaultAuthRepositoryTest.kt— repository logic against a mockedAuthApi(MockK), incl. error-mapping cases.LoginViewModelTest.kt/RegisterViewModelTest.kt— state transitions and one-shot events, using Turbine for Flow assertions andMainCoroutineRuleto swap the main dispatcher (the register test drives a hand-writtenFakeAuthRepositoryinstead of MockK).
There's also an older app/src/test/.../MainViewModelTest.kt (plus a second copy of
MainCoroutineRule there). PR #4 ("Add repository tests and refactor ViewModel tests with
Turbine") established the Turbine pattern — follow it for new tests.
.github/workflows/ci.yml— "Kotlin CI": on push/PR tomain, JDK 17 (temurin, Gradle cache), runs./gradlew lintthen./gradlew test. Requires theAPI_KEYrepository secret (exported asORG_GRADLE_PROJECT_API_KEY)..github/workflows/codeql.yml— CodeQL scanning (set up in PR #8).
| PR | Branch | What it did |
|---|---|---|
| #1–2 | featureAuth-Register |
Register screen; introduced the generic Result error handling |
| #3 | featureAuth-Refactor |
Standardized errors with UiText + string resources |
| #4 | featureAuth-Navigation |
Login ↔ Register navigation, type-safe nested graphs, single-shot events, DataStore token storage, Turbine tests, centralized network/token management in core:data |
| #5 | featureTokenPersistance |
Repository pattern for tokens; consolidated session into one encrypted object |
| #6 | splashScreen |
Splash screen + startup auth check; MainState; centralized build config; created features:agenda module |
| #8 | DemisChan-CodeQL |
CodeQL + Kotlin CI workflow (JDK 17, lint, API key env var) |
Honest notes from reading the code — none are blockers, but several are worth fixing early:
- 🔴 Passwords are logged in plaintext.
RegisterViewModel.register()logsstate.passwordvia Timber (RegisterViewModel.kt:61). Even in debug builds this is a bad habit and CodeQL may eventually flag it. Also, the OkHttp logging interceptor runs atLevel.BODYunconditionally, so login/register request bodies (passwords) appear in Logcat. Fix: remove the password log line; gate the logging interceptor onBuildConfig.DEBUGand/or redact auth endpoints. AgendaScreenlives in the wrong module. The placeholder is atfeatures/auth/.../presentation/agenda/AgendaScreen.kt, whilefeatures:agendacontains only abuild.gradle.ktsand no source at all. First step when resuming: move the screen intofeatures:agendaand add the module toapp's dependencies.- No token refresh.
SessionData.refreshTokenis stored but never used. When the access token expires (checked with a 5-min buffer at startup only), the user is forced to log in again — and a token that expires mid-session will just cause 401s, since nothing handles refresh at the OkHttp layer. Natural fix: an OkHttpAuthenticatorthat calls the API's/accessTokenrefresh endpoint and updates the stored session. - Logout is unreachable.
AuthRepository.logout()andAuthApi.logout()exist and clear the session, but no screen calls them. The agenda screen will need a logout menu item. API_KEY/BASE_URLonly exist in thedebugbuild type (incore:data,features:auth,features:agenda). Areleasebuild will fail to compile becauseBuildConfig.API_KEYwon't be generated. Move thebuildConfigFields todefaultConfig.- Naming drift in
core:data/token. The interface isTokenManagerbut the implementation file isTokenStorage.ktcontainingDataStoreTokenStorage, and the DI provider is calledprovidesSessionManager. It really manages a session, not just a token — consider renaming toSessionStorage/SessionManagerconsistently. - Duplication / dead code.
MainCoroutineRuleexists twice (app + auth test source sets);TokenManager.isAuthenticated()duplicates the decrypt-and-check-expiry logic ofisTokenValid()and is never called anywhere;LoginAction.SignUpClickedandRegisterAction.LoginClickedare empty no-op branches (navigation is passed as callbacks into the screens instead — pick one mechanism). - Small nits:
passwordVisibleis declaredvarinside otherwise-immutable state data classes (make itval;copy()is already used); unused version-catalog entries (androidx-credentials,androidx-security-crypto— you wrote your ownCryptoManagerinstead, which is fine); Compose BOM 2024.09.00 is old relative to compileSdk 36 and worth bumping; the agenda module's build file has heavy dependencies (Retrofit, Hilt) it doesn't need yet.
Things that are genuinely good and worth keeping as patterns: the Result/UiText
pipeline, safeApiCall's exception mapping (incl. rethrowing CancellationException), the
Channel-based one-shot events with ObserveAsEvents, the single-encrypted-blob session
storage, and the per-screen Action/State/Event structure.
- Quick fixes first (small PRs, get back into the rhythm): stop logging passwords / gate the
logging interceptor (§8.1), move
buildConfigFields todefaultConfig(§8.5), moveAgendaScreenintofeatures:agenda(§8.2). - Build the Agenda feature in
features:agenda, copying the auth module's structure:data/remote(AgendaApi: agenda items, tasks, events, reminders) →domain(models + repository interface) →presentation(AgendaScreen with State/Action/Event ViewModel). - Token refresh via an OkHttp
Authenticator+ the refresh endpoint, updatingDataStoreTokenStorage. - Logout UI on the agenda screen (calls
AuthRepository.logout(), navigates back to the auth graph). - Then: offline-first caching with Room, WorkManager sync — the usual Tasky roadmap.