Skip to content

Add Android app with Google Calendar year view and OAuth integration - #8

Open
blooop wants to merge 4 commits into
mainfrom
claude/android-calendar-year-view-Ckk5P
Open

Add Android app with Google Calendar year view and OAuth integration#8
blooop wants to merge 4 commits into
mainfrom
claude/android-calendar-year-view-Ckk5P

Conversation

@blooop

@blooop blooop commented Dec 25, 2025

Copy link
Copy Markdown
Owner

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.

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:

  • Introduce a year-at-a-glance calendar UI with zoom, pan, and year selection capabilities using Jetpack Compose.
  • Integrate Google OAuth-based sign-in and Google Calendar API access to display color-coded events from linked calendars.
  • Provide user flows for signing in, viewing yearly events, refreshing data, and signing out with basic error handling.

Enhancements:

  • Structure the Android project with MVVM architecture, Hilt-based dependency injection, and theming for Material Design 3.
  • Implement encrypted token and credential management using EncryptedSharedPreferences, Android Keystore, and data backup exclusion rules.

Build:

  • Configure Gradle for the new Android application module, including Compose, Hilt, Google Play Services, and Google Calendar API dependencies.

CI:

  • Add a GitHub Actions workflow to build and publish debug and unsigned release APK artifacts using repository secrets for OAuth credentials.

Documentation:

  • Add comprehensive documentation for setup, APK installation, CI configuration, and security practices for the Year Calendar app.

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.
@sourcery-ai

sourcery-ai Bot commented Dec 25, 2025

Copy link
Copy Markdown

Reviewer's Guide

Implements 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 load

sequenceDiagram
    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
Loading

Sequence diagram for changing year and refreshing events

sequenceDiagram
    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
Loading

Class diagram for YearCalendar MVVM and services

classDiagram
    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
Loading

File-Level Changes

Change Details Files
Add zoomable, pannable year-view calendar UI in Jetpack Compose that visualizes events for a full year.
  • Introduce YearCalendarView composable that manages scale and pan state and exposes FAB zoom controls and reset
  • Implement YearCalendarCanvas custom Canvas drawing with month columns, day cells, weekend/today highlighting, and event color indicators grouped by date
  • Provide basic Material3 theme and typography setup for consistent styling across the calendar UI
YearCalendar/app/src/main/java/com/yearcalendar/app/ui/components/YearCalendarView.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Theme.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Type.kt
Implement authenticated calendar screen flow with year selection, event loading states, and error handling using MVVM and Hilt.
  • Create CalendarScreen composable with top app bar (year selector, refresh, overflow menu), content switching between loading, success (calendar view), and error states
  • Add YearPickerDialog to select years within a configurable window relative to the current year and trigger reloading of events
  • Define AuthState and CalendarUiState sealed classes to model authentication and calendar loading state
  • Introduce CalendarViewModel to orchestrate auth status checks, sign-in handling, year changes, and event loading via GoogleCalendarService
YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/CalendarScreen.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/data/AuthState.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/viewmodel/CalendarViewModel.kt
Integrate Google Sign-In OAuth and secure token management, wired into an MVVM/Hilt app entry point.
  • Implement GoogleAuthManager using GoogleSignInOptions with calendar read-only scopes and server auth code request, exposing helpers for sign-in intents, result handling, and permission checks
  • Create TokenManager that stores auth codes and tokens in EncryptedSharedPreferences protected by a MasterKey with AES256_GCM
  • Wire MainActivity as a Hilt entry point that chooses between SignInScreen and CalendarScreen based on AuthState, launching the Google sign-in intent and routing results to CalendarViewModel
  • Add SignInScreen composable to handle initial sign-in UX, loading state, and presenting any error messages
YearCalendar/app/src/main/java/com/yearcalendar/app/auth/GoogleAuthManager.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/auth/TokenManager.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/MainActivity.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/SignInScreen.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/YearCalendarApplication.kt
Implement Google Calendar API integration and data models for fetching and representing yearly events.
  • Add CalendarEvent and CalendarInfo data classes with derived date properties for easier aggregation and rendering
  • Implement GoogleCalendarService that initializes a Google Calendar API client with OAuth credentials and exposes methods to fetch calendar list and events for a given year, mapping API responses into CalendarEvent instances
  • Provide DI wiring for GoogleAuthManager, TokenManager, and GoogleCalendarService via an AppModule in the SingletonComponent
YearCalendar/app/src/main/java/com/yearcalendar/app/data/CalendarEvent.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/calendar/GoogleCalendarService.kt
YearCalendar/app/src/main/java/com/yearcalendar/app/di/AppModule.kt
Configure Android project, security-related settings, and CI pipeline to build APKs with injected OAuth credentials.
  • Set up Gradle configuration for the app module, including Compose, Hilt, Google Play Services, Google Calendar API client libraries, and BuildConfig fields populated from local.properties for OAuth credentials
  • Define AndroidManifest with minimal network permissions, backup disabled, custom theme, and main activity, plus data_extraction_rules to exclude sensitive storage from cloud backups
  • Add ProGuard rules to keep necessary Google and app data classes and enable shrinking/obfuscation in release builds
  • Configure GitHub Actions workflow to build debug and (unsigned) release APKs on pushes/PRs, injecting OAuth credentials via repository secrets into local.properties and uploading build artifacts
  • Initialize Gradle wrapper, project-level build scripts, settings, and resource basics (colors, strings, launcher icons, theme) for a self-contained YearCalendar app project
YearCalendar/app/build.gradle.kts
YearCalendar/build.gradle.kts
YearCalendar/settings.gradle.kts
YearCalendar/app/src/main/AndroidManifest.xml
YearCalendar/app/src/main/res/xml/data_extraction_rules.xml
YearCalendar/app/proguard-rules.pro
YearCalendar/.gitignore
YearCalendar/.idea/.gitignore
YearCalendar/gradle/wrapper/gradle-wrapper.properties
YearCalendar/gradle.properties
.github/workflows/android-build.yml
YearCalendar/app/src/main/res/values/strings.xml
YearCalendar/app/src/main/res/values/colors.xml
YearCalendar/app/src/main/res/values/themes.xml
YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Add developer and operational documentation for setup, CI/CD, installation, and security posture of the app.
  • Document project overview, features, tech stack, architecture, and security features in README.md
  • Provide SETUP_GUIDE.md with detailed instructions for local Android Studio setup, OAuth configuration, feature usage, troubleshooting, architecture explanation, and customization
  • Add CI_SETUP.md that explains how to configure GitHub secrets, how the workflow works, and how to retrieve APK artifacts
  • Introduce INSTALL_APK.md as an end-user focused guide to obtaining and installing APKs, including SHA-1 registration steps
  • Add SECURITY.md outlining credential management, implemented security measures, production hardening checklist, and vulnerability reporting process
YearCalendar/README.md
YearCalendar/SETUP_GUIDE.md
YearCalendar/CI_SETUP.md
YearCalendar/INSTALL_APK.md
YearCalendar/SECURITY.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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: 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +73 to +78
FloatingActionButton(
onClick = { scale = (scale * 1.2f).coerceAtMost(5f) },
containerColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.ZoomIn, "Zoom In")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +152 to +154
drawText(
textMeasurer = textMeasurer,
text = monthName,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.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.

Comment on lines +69 to +70
DropdownMenuItem(
text = { Text("Sign Out") },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +68 to +71
fun hasCalendarPermission(): Boolean {
val account = getLastSignedInAccount()
return account?.grantedScopes?.any {
it.scopeUri.contains("calendar")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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? {

Comment on lines +24 to +26
private val signInLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread YearCalendar/app/src/main/java/com/yearcalendar/app/MainActivity.kt
Comment thread YearCalendar/SECURITY.md
**DO NOT** open a public issue.

Instead:
1. Email: [security contact - add your email]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants