Add Android app with Google Calendar year view and OAuth integration - #8
Add Android app with Google Calendar year view and OAuth integration#8blooop wants to merge 4 commits into
Conversation
Created a complete Android application that displays Google Calendar events in an innovative year view format with zoom and pan capabilities. Features: - Year view with months displayed as vertical columns - Smooth zoom in/out (0.5x to 5x) with pinch gestures - Pan functionality to navigate the calendar - Google OAuth 2.0 secure authentication - Encrypted credential storage using EncryptedSharedPreferences - Material Design 3 UI with Jetpack Compose - Real-time event display with color coding - Year selector to view different years - Pull-to-refresh functionality Technical Implementation: - Language: Kotlin with Jetpack Compose - Architecture: MVVM with Hilt dependency injection - Authentication: Google Sign-In SDK with OAuth 2.0 - API: Google Calendar API v3 - Security: AES256_GCM encryption for tokens, Android Keystore - UI: Custom Canvas-based calendar view with transformable state - Async: Kotlin Coroutines and StateFlow Security Features: - OAuth credentials stored in local.properties (gitignored) - BuildConfig for compile-time credential injection - EncryptedSharedPreferences for token storage - MasterKey backed by Android Keystore - No backup of sensitive data - ProGuard configuration for release builds - HTTPS-only API communication Project Structure: - auth/: Google OAuth authentication and token management - calendar/: Google Calendar API integration - data/: Data models and UI state classes - di/: Hilt dependency injection modules - ui/: Jetpack Compose screens and components - viewmodel/: Business logic and state management Documentation: - README.md: Project overview and features - SETUP_GUIDE.md: Detailed setup instructions - SECURITY.md: Security best practices and policies The app provides a unique way to visualize an entire year of calendar events at once, with the ability to zoom in for details or zoom out to see patterns across the year.
Set up continuous integration to automatically build APKs without requiring a PC or Android Studio. Features: - Automatic APK builds on every push to feature branch - OAuth credentials injected from GitHub Secrets - Debug and release APK variants - APK artifacts available for download (30-day retention) - Workflow triggers on push and pull requests Workflow Details: - Runs on: ubuntu-latest - JDK: 17 (Temurin distribution) - Gradle cache enabled for faster builds - Working directory: YearCalendar - Outputs: app-debug.apk and app-release-unsigned.apk Security: - OAuth credentials stored as GitHub repository secrets - Secrets injected at build time into local.properties - Never exposed in logs or artifacts - Encrypted in GitHub's secret store Documentation Added: - CI_SETUP.md: Complete CI/CD setup instructions - INSTALL_APK.md: Quick installation guide for users - Updated README.md with APK download links Usage: 1. Add secrets to GitHub repository settings: - GOOGLE_CLIENT_ID - GOOGLE_CLIENT_SECRET - GOOGLE_PROJECT_ID 2. Push code to trigger build 3. Download APK from GitHub Actions artifacts 4. Install on Android device This enables APK generation without requiring: - Local Android Studio installation - Android SDK setup - Physical PC access - Manual builds Perfect for users who want the app on their phone but don't have development environment set up.
Reviewer's GuideImplements a new Android YearCalendar app that authenticates with Google via OAuth, fetches Google Calendar events, and renders them in a zoomable, pannable year-view UI, with secure local token storage, Hilt-based MVVM architecture, and CI that builds APKs using GitHub Actions with secrets-injected OAuth credentials. Sequence diagram for Google sign-in and initial calendar loadsequenceDiagram
actor User
participant MainActivity
participant CalendarViewModel
participant GoogleAuthManager
participant TokenManager
participant GoogleCalendarService
participant GoogleCalendarAPI
User->>MainActivity: Launch app
MainActivity->>CalendarViewModel: observe authState
CalendarViewModel->>GoogleAuthManager: getLastSignedInAccount()
GoogleAuthManager-->>CalendarViewModel: GoogleSignInAccount?
alt account and calendar permission present
CalendarViewModel->>CalendarViewModel: set AuthStateAuthenticated
CalendarViewModel->>GoogleCalendarService: getCalendars()
GoogleCalendarService->>GoogleAuthManager: getLastSignedInAccount()
GoogleAuthManager-->>GoogleCalendarService: GoogleSignInAccount
GoogleCalendarService->>GoogleCalendarAPI: calendarList.list()
GoogleCalendarAPI-->>GoogleCalendarService: CalendarList
GoogleCalendarService-->>CalendarViewModel: List CalendarInfo
CalendarViewModel->>GoogleCalendarService: getEventsForYear(currentYear)
GoogleCalendarService->>GoogleCalendarAPI: events.list(timeMin,timeMax)
GoogleCalendarAPI-->>GoogleCalendarService: Events
GoogleCalendarService-->>CalendarViewModel: List CalendarEvent
CalendarViewModel-->>MainActivity: calendarState Success
else no account or missing permission
CalendarViewModel->>CalendarViewModel: set AuthStateNotAuthenticated
MainActivity-->>User: Show SignInScreen
User->>MainActivity: Tap sign in
MainActivity->>GoogleAuthManager: getSignInIntent()
GoogleAuthManager-->>MainActivity: Intent
MainActivity->>User: Launch Google sign-in UI
User-->>MainActivity: Completes sign-in
MainActivity->>CalendarViewModel: handleSignInResult(data)
CalendarViewModel->>GoogleAuthManager: handleSignInResult(data)
GoogleAuthManager-->>CalendarViewModel: GoogleSignInAccount
CalendarViewModel->>TokenManager: saveAuthCode(serverAuthCode)
TokenManager-->>CalendarViewModel: auth code stored
CalendarViewModel->>CalendarViewModel: set AuthStateAuthenticated
CalendarViewModel->>GoogleCalendarService: getCalendars()
GoogleCalendarService->>GoogleCalendarAPI: calendarList.list()
GoogleCalendarAPI-->>GoogleCalendarService: CalendarList
GoogleCalendarService-->>CalendarViewModel: List CalendarInfo
CalendarViewModel->>GoogleCalendarService: getEventsForYear(currentYear)
GoogleCalendarService->>GoogleCalendarAPI: events.list(timeMin,timeMax)
GoogleCalendarAPI-->>GoogleCalendarService: Events
GoogleCalendarService-->>CalendarViewModel: List CalendarEvent
CalendarViewModel-->>MainActivity: calendarState Success
end
MainActivity-->>User: Show CalendarScreen with YearCalendarView
Sequence diagram for changing year and refreshing eventssequenceDiagram
actor User
participant CalendarScreen
participant CalendarViewModel
participant GoogleCalendarService
participant GoogleCalendarAPI
User->>CalendarScreen: Tap year title
CalendarScreen->>CalendarScreen: show YearPickerDialog
User-->>CalendarScreen: Select year and confirm
CalendarScreen->>CalendarViewModel: changeYear(selectedYear)
CalendarViewModel->>CalendarViewModel: loadCalendarEvents(selectedYear)
CalendarViewModel->>GoogleCalendarService: getCalendars()
GoogleCalendarService->>GoogleCalendarAPI: calendarList.list()
GoogleCalendarAPI-->>GoogleCalendarService: CalendarList
GoogleCalendarService-->>CalendarViewModel: List CalendarInfo
CalendarViewModel->>GoogleCalendarService: getEventsForYear(selectedYear)
GoogleCalendarService->>GoogleCalendarAPI: events.list(timeMin,timeMax)
GoogleCalendarAPI-->>GoogleCalendarService: Events
GoogleCalendarService-->>CalendarViewModel: List CalendarEvent
CalendarViewModel-->>CalendarScreen: calendarState Success
CalendarScreen-->>User: Updated YearCalendarView
User->>CalendarScreen: Tap refresh
CalendarScreen->>CalendarViewModel: refreshEvents()
CalendarViewModel->>CalendarViewModel: loadCalendarEvents(currentYear)
CalendarViewModel->>GoogleCalendarService: getCalendars()
GoogleCalendarService->>GoogleCalendarAPI: calendarList.list()
GoogleCalendarAPI-->>GoogleCalendarService: CalendarList
GoogleCalendarService-->>CalendarViewModel: List CalendarInfo
CalendarViewModel->>GoogleCalendarService: getEventsForYear(currentYear)
GoogleCalendarService->>GoogleCalendarAPI: events.list(timeMin,timeMax)
GoogleCalendarAPI-->>GoogleCalendarService: Events
GoogleCalendarService-->>CalendarViewModel: List CalendarEvent
CalendarViewModel-->>CalendarScreen: calendarState Success
CalendarScreen-->>User: Refreshed YearCalendarView
Class diagram for YearCalendar MVVM and servicesclassDiagram
direction LR
class YearCalendarApplication {
}
class MainActivity {
+onCreate(savedInstanceState)
-signInLauncher
-viewModel : CalendarViewModel
-startSignIn()
}
class CalendarViewModel {
-authManager : GoogleAuthManager
-tokenManager : TokenManager
-calendarService : GoogleCalendarService
-_authState : MutableStateFlow~AuthState~
-_calendarState : MutableStateFlow~CalendarUiState~
-_currentYear : MutableStateFlow~Int~
+authState : StateFlow~AuthState~
+calendarState : StateFlow~CalendarUiState~
+currentYear : StateFlow~Int~
+handleSignInResult(data)
+signOut()
+loadCalendarEvents(year)
+changeYear(year)
+refreshEvents()
}
class GoogleAuthManager {
-context : Context
-googleSignInClient : GoogleSignInClient
+getSignInIntent() Intent
+handleSignInResult(data) GoogleSignInAccount?
+getLastSignedInAccount() GoogleSignInAccount?
+signOut()
+revokeAccess()
+hasCalendarPermission() Boolean
}
class TokenManager {
-context : Context
-masterKey : MasterKey
-encryptedPrefs : SharedPreferences
+saveAccessToken(token)
+getAccessToken() String?
+saveRefreshToken(token)
+getRefreshToken() String?
+saveAuthCode(code)
+getAuthCode() String?
+saveTokenExpiry(expiry)
+getTokenExpiry() Long
+isTokenExpired() Boolean
+clearTokens()
}
class GoogleCalendarService {
-context : Context
-authManager : GoogleAuthManager
-calendarService : Calendar?
-initializeCalendarService() Calendar?
+getCalendars() List~CalendarInfo~
+getEventsForYear(year) List~CalendarEvent~
-parseDateTime(value) LocalDateTime
+clearService()
}
class CalendarEvent {
+id : String
+title : String
+startDateTime : LocalDateTime
+endDateTime : LocalDateTime
+isAllDay : Boolean
+color : String?
+calendarId : String
+startDate : LocalDate
+endDate : LocalDate
}
class CalendarInfo {
+id : String
+summary : String
+backgroundColor : String?
+foregroundColor : String?
}
class AuthState {
}
class AuthStateLoading
class AuthStateNotAuthenticated
class AuthStateAuthenticated {
+email : String
}
class AuthStateError {
+message : String
}
class CalendarUiState {
}
class CalendarUiStateLoading
class CalendarUiStateSuccess {
+events : List~CalendarEvent~
+calendars : List~CalendarInfo~
}
class CalendarUiStateError {
+message : String
}
class CalendarScreen {
+CalendarScreen(viewModel,onSignOut)
}
class SignInScreen {
+SignInScreen(onSignInClick,isLoading,errorMessage)
}
class YearCalendarView {
+YearCalendarView(year,events,modifier)
}
class YearCalendarCanvas {
+YearCalendarCanvas(year,events,scale,offsetX,offsetY,modifier)
-parseEventColor(colorString) Color?
}
class YearPickerDialog {
+YearPickerDialog(currentYear,onYearSelected,onDismiss)
}
class AppModule {
+provideGoogleAuthManager(context) GoogleAuthManager
+provideTokenManager(context) TokenManager
+provideGoogleCalendarService(context,authManager) GoogleCalendarService
}
YearCalendarApplication <|-- MainActivity
AppModule ..> GoogleAuthManager
AppModule ..> TokenManager
AppModule ..> GoogleCalendarService
MainActivity --> CalendarViewModel
MainActivity --> SignInScreen
MainActivity --> CalendarScreen
CalendarScreen --> CalendarViewModel
CalendarScreen --> YearCalendarView
CalendarScreen --> YearPickerDialog
CalendarViewModel --> GoogleAuthManager
CalendarViewModel --> TokenManager
CalendarViewModel --> GoogleCalendarService
GoogleCalendarService --> GoogleAuthManager
GoogleCalendarService --> CalendarEvent
GoogleCalendarService --> CalendarInfo
CalendarUiState <|-- CalendarUiStateLoading
CalendarUiState <|-- CalendarUiStateSuccess
CalendarUiState <|-- CalendarUiStateError
AuthState <|-- AuthStateLoading
AuthState <|-- AuthStateNotAuthenticated
AuthState <|-- AuthStateAuthenticated
AuthState <|-- AuthStateError
YearCalendarView --> CalendarEvent
YearCalendarView --> YearCalendarCanvas
YearCalendarCanvas --> CalendarEvent
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 8 issues, and left some high level feedback:
- In MainActivity.startSignIn you construct a new GoogleAuthManager instead of using the Hilt-provided singleton, which bypasses your DI setup; consider injecting GoogleAuthManager (e.g. via hiltViewModel or an @androidentrypoint field) and using it consistently for sign-in.
- CLIENT_SECRET is loaded from local.properties into BuildConfig but never used in the app, so you can simplify the Gradle config and secrets handling by removing that field (and related secret) to reduce unnecessary exposure and configuration overhead.
- In the GitHub Actions workflow, the release APK build step has
continue-on-error: truebut the subsequent artifact upload is gated only onif: success(), which will still be true even when assembleRelease fails; consider conditioning the upload on the specific step outcome to avoid artifact upload failures when the release build is skipped or broken.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In MainActivity.startSignIn you construct a new GoogleAuthManager instead of using the Hilt-provided singleton, which bypasses your DI setup; consider injecting GoogleAuthManager (e.g. via hiltViewModel or an @AndroidEntryPoint field) and using it consistently for sign-in.
- CLIENT_SECRET is loaded from local.properties into BuildConfig but never used in the app, so you can simplify the Gradle config and secrets handling by removing that field (and related secret) to reduce unnecessary exposure and configuration overhead.
- In the GitHub Actions workflow, the release APK build step has `continue-on-error: true` but the subsequent artifact upload is gated only on `if: success()`, which will still be true even when assembleRelease fails; consider conditioning the upload on the specific step outcome to avoid artifact upload failures when the release build is skipped or broken.
## Individual Comments
### Comment 1
<location> `YearCalendar/app/src/main/java/com/yearcalendar/app/ui/components/YearCalendarView.kt:73-78` </location>
<code_context>
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ ) {
+ FloatingActionButton(
+ onClick = { scale = (scale * 1.2f).coerceAtMost(5f) },
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ modifier = Modifier.size(48.dp)
+ ) {
+ Icon(Icons.Default.ZoomIn, "Zoom In")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
</code_context>
<issue_to_address>
**suggestion:** Use string resources for zoom button labels and content descriptions for localization and consistency.
The zoom FABs currently hardcode their labels and contentDescriptions (e.g., "Zoom In"). Since `zoom_in`, `zoom_out`, and `reset_zoom` already exist in `strings.xml`, please use `stringResource` here to centralize UI text and support localization.
</issue_to_address>
### Comment 2
<location> `YearCalendar/app/src/main/java/com/yearcalendar/app/ui/components/YearCalendarView.kt:152-154` </location>
<code_context>
+ size = Size(monthWidth, headerHeight)
+ )
+
+ drawText(
+ textMeasurer = textMeasurer,
+ text = monthName,
+ topLeft = Offset(x + monthWidth / 2 - 20.dp.toPx(), 10.dp.toPx()),
+ style = TextStyle(
</code_context>
<issue_to_address>
**suggestion:** Month header text centering uses a hardcoded offset that may misalign for different fonts/locales.
The centering currently subtracts a fixed `20.dp.toPx()` from `x + monthWidth / 2`, assuming a constant text width. This will look off for different month lengths, fonts, or locales. Since you already have `rememberTextMeasurer`, please measure `monthName` and derive `topLeft` from the measured width so the text is truly centered within `monthWidth`.
Suggested implementation:
```
val monthHeaderTextStyle = TextStyle(
color = Color.White,
fontSize = 14.sp
)
val monthHeaderTextLayout = textMeasurer.measure(
text = AnnotatedString(monthName),
style = monthHeaderTextStyle
)
val monthHeaderTopLeft = Offset(
x = x + monthWidth / 2f - monthHeaderTextLayout.size.width / 2f,
y = 10.dp.toPx()
)
drawText(
textLayoutResult = monthHeaderTextLayout,
topLeft = monthHeaderTopLeft
)
```
You will also need to ensure that the following import is present at the top of the file (or add it if missing):
```kotlin
import androidx.compose.ui.text.AnnotatedString
```
If there is already a shared `TextStyle` for month headers elsewhere in this file or project, you may want to reuse that instead of defining `monthHeaderTextStyle` inline to stay consistent with existing styling conventions.
</issue_to_address>
### Comment 3
<location> `YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/CalendarScreen.kt:69-70` </location>
<code_context>
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Sign Out") },
+ onClick = {
+ showMenu = false
</code_context>
<issue_to_address>
**suggestion:** Avoid hardcoded UI strings in the screen and reuse string resources instead.
For example, the content description for "Change year", the "More" label, and "Sign Out" are hardcoded even though `sign_out` and other labels already exist in `strings.xml`. Use `stringResource(...)` for these to leverage localization and keep text definitions centralized.
Suggested implementation:
```
import androidx.compose.material3.DropdownMenu
import androidx.compose.ui.res.stringResource
```
```
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more),
tint = MaterialTheme.colorScheme.onPrimary
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.sign_out)) },
```
There is also a "Change year" content description mentioned in your review but not visible in the provided snippet. In the same file, replace any hardcoded `"Change year"` with `stringResource(R.string.change_year)` (or the appropriate existing string resource name). Ensure that the `more` and `change_year` string entries exist in `res/values/strings.xml`; if they don't, add them or reuse suitable existing keys.
</issue_to_address>
### Comment 4
<location> `YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/CalendarScreen.kt:126` </location>
<code_context>
+ }
+
+ if (showYearPicker) {
+ YearPickerDialog(
+ currentYear = currentYear,
+ onYearSelected = { year ->
</code_context>
<issue_to_address>
**suggestion:** Year picker dialog text is hardcoded rather than using string resources.
Within `YearPickerDialog`, labels like "Select Year", "OK", and "Cancel" are hardcoded. Please move these into `strings.xml` and reference them via `stringResource` to keep them localizable and consistent with the rest of the app.
Suggested implementation:
```
@Composable
fun YearPickerDialog(
```
```
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.year_picker_title)) },
text = {
// ...
},
confirmButton = {
TextButton(onClick = { onYearSelected(selectedYear) }) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
```
Because only part of the file is visible, you’ll also need to:
1. Add the corresponding string resources in `res/values/strings.xml` (or the appropriate module resource file), for example:
- `<string name="year_picker_title">Select Year</string>`
- `<string name="ok">OK</string>`
- `<string name="cancel">Cancel</string>`
2. If your project uses different naming conventions (e.g., `common_ok`, `common_cancel`), adjust the `R.string.*` references to match.
3. If the body of `YearPickerDialog` differs (e.g., different parameter names or a different `AlertDialog` structure), apply the same replacement pattern to whatever `Text("Select Year")`, `Text("OK")`, and `Text("Cancel")` usages exist there.
</issue_to_address>
### Comment 5
<location> `YearCalendar/app/src/main/java/com/yearcalendar/app/auth/GoogleAuthManager.kt:68-71` </location>
<code_context>
+ googleSignInClient.revokeAccess().await()
+ }
+
+ fun hasCalendarPermission(): Boolean {
+ val account = getLastSignedInAccount()
+ return account?.grantedScopes?.any {
+ it.scopeUri.contains("calendar")
+ } ?: false
+ }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Scope check using `contains("calendar")` is brittle; prefer comparing against the exact calendar scope URIs.
This logic will treat any scope containing "calendar" as valid, which may match unintended future scopes and doesn’t distinguish between the specific calendar scopes you need. Please compare against the exact expected URIs (e.g., "https://www.googleapis.com/auth/calendar.readonly") or an explicit allowlist of scope strings instead.
Suggested implementation:
```
suspend fun revokeAccess() {
googleSignInClient.revokeAccess().await()
}
fun hasCalendarPermission(): Boolean {
val account = getLastSignedInAccount()
return account?.grantedScopes?.any {
it.scopeUri.contains("calendar")
} ?: false
}
fun getSignInIntent(): Intent {
```
```
fun handleSignInResult(data: Intent?): GoogleSignInAccount? {
```
</issue_to_address>
### Comment 6
<location> `YearCalendar/app/src/main/java/com/yearcalendar/app/MainActivity.kt:24-26` </location>
<code_context>
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ private val signInLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ viewModel.handleSignInResult(result.data)
+ }
</code_context>
<issue_to_address>
**issue (bug_risk):** The ActivityResult callback closes over `viewModel` before it is initialized, risking a crash if invoked early.
Since `signInLauncher` is registered as a property, its callback can execute before `viewModel` is set inside `onCreate`/`setContent` (e.g., after process death or unusual recreation order), leading to an NPE. To avoid this, either obtain/initialize the `ViewModel` before registering the launcher, or route the result through a method that first checks `::viewModel.isInitialized` (or otherwise guards access) before calling `handleSignInResult`.
</issue_to_address>
### Comment 7
<location> `YearCalendar/app/src/main/java/com/yearcalendar/app/MainActivity.kt:78-83` </location>
<code_context>
+ }
+ }
+
+ private fun startSignIn() {
+ val authManager = (application as YearCalendarApplication).let {
+ // Get auth manager through Hilt
+ com.yearcalendar.app.auth.GoogleAuthManager(this)
+ }
+ signInLauncher.launch(authManager.getSignInIntent())
+ }
+}
</code_context>
<issue_to_address>
**issue:** Creating a new GoogleAuthManager in `startSignIn` bypasses Hilt and can desynchronize auth configuration.
Here `GoogleAuthManager(this)` creates a new instance instead of using the Hilt-provided singleton used elsewhere (e.g., in `CalendarViewModel`). That can cause configuration drift between instances and undermines centralized DI. Inject `GoogleAuthManager` into `MainActivity` via Hilt and use that injected instance for `getSignInIntent()`.
</issue_to_address>
### Comment 8
<location> `YearCalendar/SECURITY.md:171` </location>
<code_context>
+**DO NOT** open a public issue.
+
+Instead:
+1. Email: [security contact - add your email]
+2. Include detailed description
+3. Provide steps to reproduce
</code_context>
<issue_to_address>
**🚨 issue (security):** Replace the placeholder security contact email before publishing
"[security contact - add your email]" is still a placeholder. Before publishing, replace this with the actual security contact address or clearly specify the responsible disclosure contact method.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| FloatingActionButton( | ||
| onClick = { scale = (scale * 1.2f).coerceAtMost(5f) }, | ||
| containerColor = MaterialTheme.colorScheme.primaryContainer, | ||
| modifier = Modifier.size(48.dp) | ||
| ) { | ||
| Icon(Icons.Default.ZoomIn, "Zoom In") |
There was a problem hiding this comment.
suggestion: Use string resources for zoom button labels and content descriptions for localization and consistency.
The zoom FABs currently hardcode their labels and contentDescriptions (e.g., "Zoom In"). Since zoom_in, zoom_out, and reset_zoom already exist in strings.xml, please use stringResource here to centralize UI text and support localization.
| drawText( | ||
| textMeasurer = textMeasurer, | ||
| text = monthName, |
There was a problem hiding this comment.
suggestion: Month header text centering uses a hardcoded offset that may misalign for different fonts/locales.
The centering currently subtracts a fixed 20.dp.toPx() from x + monthWidth / 2, assuming a constant text width. This will look off for different month lengths, fonts, or locales. Since you already have rememberTextMeasurer, please measure monthName and derive topLeft from the measured width so the text is truly centered within monthWidth.
Suggested implementation:
val monthHeaderTextStyle = TextStyle(
color = Color.White,
fontSize = 14.sp
)
val monthHeaderTextLayout = textMeasurer.measure(
text = AnnotatedString(monthName),
style = monthHeaderTextStyle
)
val monthHeaderTopLeft = Offset(
x = x + monthWidth / 2f - monthHeaderTextLayout.size.width / 2f,
y = 10.dp.toPx()
)
drawText(
textLayoutResult = monthHeaderTextLayout,
topLeft = monthHeaderTopLeft
)
You will also need to ensure that the following import is present at the top of the file (or add it if missing):
import androidx.compose.ui.text.AnnotatedStringIf there is already a shared TextStyle for month headers elsewhere in this file or project, you may want to reuse that instead of defining monthHeaderTextStyle inline to stay consistent with existing styling conventions.
| DropdownMenuItem( | ||
| text = { Text("Sign Out") }, |
There was a problem hiding this comment.
suggestion: Avoid hardcoded UI strings in the screen and reuse string resources instead.
For example, the content description for "Change year", the "More" label, and "Sign Out" are hardcoded even though sign_out and other labels already exist in strings.xml. Use stringResource(...) for these to leverage localization and keep text definitions centralized.
Suggested implementation:
import androidx.compose.material3.DropdownMenu
import androidx.compose.ui.res.stringResource
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more),
tint = MaterialTheme.colorScheme.onPrimary
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.sign_out)) },
There is also a "Change year" content description mentioned in your review but not visible in the provided snippet. In the same file, replace any hardcoded "Change year" with stringResource(R.string.change_year) (or the appropriate existing string resource name). Ensure that the more and change_year string entries exist in res/values/strings.xml; if they don't, add them or reuse suitable existing keys.
| } | ||
|
|
||
| if (showYearPicker) { | ||
| YearPickerDialog( |
There was a problem hiding this comment.
suggestion: Year picker dialog text is hardcoded rather than using string resources.
Within YearPickerDialog, labels like "Select Year", "OK", and "Cancel" are hardcoded. Please move these into strings.xml and reference them via stringResource to keep them localizable and consistent with the rest of the app.
Suggested implementation:
@Composable
fun YearPickerDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.year_picker_title)) },
text = {
// ...
},
confirmButton = {
TextButton(onClick = { onYearSelected(selectedYear) }) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
Because only part of the file is visible, you’ll also need to:
- Add the corresponding string resources in
res/values/strings.xml(or the appropriate module resource file), for example:<string name="year_picker_title">Select Year</string><string name="ok">OK</string><string name="cancel">Cancel</string>
- If your project uses different naming conventions (e.g.,
common_ok,common_cancel), adjust theR.string.*references to match. - If the body of
YearPickerDialogdiffers (e.g., different parameter names or a differentAlertDialogstructure), apply the same replacement pattern to whateverText("Select Year"),Text("OK"), andText("Cancel")usages exist there.
| fun hasCalendarPermission(): Boolean { | ||
| val account = getLastSignedInAccount() | ||
| return account?.grantedScopes?.any { | ||
| it.scopeUri.contains("calendar") |
There was a problem hiding this comment.
suggestion (bug_risk): Scope check using contains("calendar") is brittle; prefer comparing against the exact calendar scope URIs.
This logic will treat any scope containing "calendar" as valid, which may match unintended future scopes and doesn’t distinguish between the specific calendar scopes you need. Please compare against the exact expected URIs (e.g., "https://www.googleapis.com/auth/calendar.readonly") or an explicit allowlist of scope strings instead.
Suggested implementation:
suspend fun revokeAccess() {
googleSignInClient.revokeAccess().await()
}
fun hasCalendarPermission(): Boolean {
val account = getLastSignedInAccount()
return account?.grantedScopes?.any {
it.scopeUri.contains("calendar")
} ?: false
}
fun getSignInIntent(): Intent {
fun handleSignInResult(data: Intent?): GoogleSignInAccount? {
| private val signInLauncher = registerForActivityResult( | ||
| ActivityResultContracts.StartActivityForResult() | ||
| ) { result -> |
There was a problem hiding this comment.
issue (bug_risk): The ActivityResult callback closes over viewModel before it is initialized, risking a crash if invoked early.
Since signInLauncher is registered as a property, its callback can execute before viewModel is set inside onCreate/setContent (e.g., after process death or unusual recreation order), leading to an NPE. To avoid this, either obtain/initialize the ViewModel before registering the launcher, or route the result through a method that first checks ::viewModel.isInitialized (or otherwise guards access) before calling handleSignInResult.
| **DO NOT** open a public issue. | ||
|
|
||
| Instead: | ||
| 1. Email: [security contact - add your email] |
There was a problem hiding this comment.
🚨 issue (security): Replace the placeholder security contact email before publishing
"[security contact - add your email]" is still a placeholder. Before publishing, replace this with the actual security contact address or clearly specify the responsible disclosure contact method.
Add missing files needed for GitHub Actions CI to build APK: - gradle-wrapper.jar: Essential for Gradle wrapper to work - gradlew: Updated to complete POSIX shell script - gradlew.bat: Windows wrapper script for completeness - ic_launcher_foreground.xml: Launcher icon foreground These files are required for the Android build process to work properly in GitHub Actions CI environment. Fixes: - Missing gradle wrapper JAR causing build failures - Incomplete gradlew script - Missing launcher icon resources The APK build should now succeed in GitHub Actions.
…anager Fixed two critical build issues: 1. build.gradle.kts: Changed from org.jetbrains.kotlin.konan.properties.Properties to java.util.Properties - the konan version is for Kotlin/Native, not Android 2. MainActivity.kt: Properly inject GoogleAuthManager via Hilt using @Inject instead of manually creating instance - this was breaking dependency injection These fixes should resolve CI build failures and allow APK generation.
Created a complete Android application that displays Google Calendar
events in an innovative year view format with zoom and pan capabilities.
Features:
Technical Implementation:
Security Features:
Project Structure:
Documentation:
The app provides a unique way to visualize an entire year of calendar
events at once, with the ability to zoom in for details or zoom out
to see patterns across the year.
Summary by Sourcery
Add a new Android Year Calendar app module that visualizes Google Calendar events in an interactive year view with secure Google OAuth integration and CI support.
New Features:
Enhancements:
Build:
CI:
Documentation: