diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..e156fef --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,68 @@ +name: Android CI - Build APK + +on: + push: + branches: [ claude/android-calendar-year-view-Ckk5P ] + paths: + - 'YearCalendar/**' + pull_request: + branches: [ main ] + paths: + - 'YearCalendar/**' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: YearCalendar + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create local.properties with OAuth credentials + run: | + echo "google.client.id=${{ secrets.GOOGLE_CLIENT_ID }}" > local.properties + echo "google.client.secret=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> local.properties + echo "google.project.id=${{ secrets.GOOGLE_PROJECT_ID }}" >> local.properties + + - name: Build Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: year-calendar-debug-apk + path: YearCalendar/app/build/outputs/apk/debug/app-debug.apk + retention-days: 30 + + - name: Build Release APK (unsigned) + run: ./gradlew assembleRelease --stacktrace + continue-on-error: true + + - name: Upload Release APK + uses: actions/upload-artifact@v4 + if: success() + with: + name: year-calendar-release-apk + path: YearCalendar/app/build/outputs/apk/release/app-release-unsigned.apk + retention-days: 30 + + - name: Display APK info + if: success() + run: | + echo "βœ… APK build successful!" + echo "πŸ“¦ Debug APK: YearCalendar/app/build/outputs/apk/debug/app-debug.apk" + ls -lh app/build/outputs/apk/debug/ || true diff --git a/YearCalendar/.gitignore b/YearCalendar/.gitignore new file mode 100644 index 0000000..404d127 --- /dev/null +++ b/YearCalendar/.gitignore @@ -0,0 +1,21 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties + +# Sensitive files - DO NOT COMMIT +local.properties +app/google-services.json +*.keystore +*.jks diff --git a/YearCalendar/.idea/.gitignore b/YearCalendar/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/YearCalendar/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/YearCalendar/CI_SETUP.md b/YearCalendar/CI_SETUP.md new file mode 100644 index 0000000..4239d62 --- /dev/null +++ b/YearCalendar/CI_SETUP.md @@ -0,0 +1,241 @@ +# CI/CD Setup for Year Calendar + +This project uses GitHub Actions to automatically build APKs on every push. + +## GitHub Secrets Configuration + +To enable automatic APK builds, you need to add your OAuth credentials as GitHub repository secrets. + +### Step 1: Navigate to Repository Settings + +1. Go to your GitHub repository: `https://github.com/blooop/scratch_env` +2. Click **Settings** tab +3. In the left sidebar, click **Secrets and variables** β†’ **Actions** +4. Click **New repository secret** + +### Step 2: Add Required Secrets + +Add these three secrets: + +#### Secret 1: GOOGLE_CLIENT_ID +- Name: `GOOGLE_CLIENT_ID` +- Value: Your Google OAuth Client ID (format: `xxx-xxx.apps.googleusercontent.com`) + +#### Secret 2: GOOGLE_CLIENT_SECRET +- Name: `GOOGLE_CLIENT_SECRET` +- Value: Your Google OAuth Client Secret (format: `GOCSPX-xxxxx`) + +#### Secret 3: GOOGLE_PROJECT_ID +- Name: `GOOGLE_PROJECT_ID` +- Value: Your Google Cloud Project ID + +**Note:** Use the OAuth credentials that were provided to you separately. + +### Step 3: Verify Secrets + +After adding all three secrets, you should see: +- βœ… GOOGLE_CLIENT_ID +- βœ… GOOGLE_CLIENT_SECRET +- βœ… GOOGLE_PROJECT_ID + +## How CI/CD Works + +### Automatic Builds + +The workflow automatically triggers on: +- **Push** to branch `claude/android-calendar-year-view-Ckk5P` +- **Pull requests** to `main` branch +- **Manual trigger** via GitHub Actions UI + +### Build Process + +1. Checks out code +2. Sets up Java 17 +3. Injects OAuth credentials from secrets into `local.properties` +4. Builds debug APK +5. Builds release APK (unsigned) +6. Uploads APKs as artifacts + +### Build Artifacts + +After a successful build, you can download the APKs: + +1. Go to the **Actions** tab in your repository +2. Click on the latest workflow run +3. Scroll down to **Artifacts** section +4. Download: + - `year-calendar-debug-apk` - Ready to install on any device + - `year-calendar-release-apk` - Optimized but unsigned + +## Downloading Your APK + +### Method 1: From GitHub Actions UI + +1. Navigate to: `https://github.com/blooop/scratch_env/actions` +2. Click on the most recent **"Android CI - Build APK"** workflow run +3. Scroll to bottom β†’ **Artifacts** section +4. Click **year-calendar-debug-apk** to download +5. Extract the ZIP file +6. Transfer `app-debug.apk` to your Android device + +### Method 2: Using GitHub CLI + +```bash +# List recent workflow runs +gh run list --workflow=android-build.yml + +# Download artifacts from latest run +gh run download --name year-calendar-debug-apk +``` + +## Installing APK on Android Device + +### Enable Installation from Unknown Sources + +1. On your Android device, go to **Settings** +2. Navigate to **Security** (or **Apps & notifications** β†’ **Special app access**) +3. Enable **Install unknown apps** for your file manager or browser + +### Install the APK + +#### Method A: Direct Download +1. Open the downloaded ZIP on your Android device +2. Extract `app-debug.apk` +3. Tap the APK file +4. Tap **Install** +5. Tap **Open** when installation completes + +#### Method B: ADB Install +```bash +# Connect device via USB with USB debugging enabled +adb install app-debug.apk +``` + +#### Method C: Wireless Transfer +1. Upload APK to Google Drive or similar +2. Download on Android device +3. Open and install + +## Manual Build (Alternative) + +If you need to build locally: + +```bash +cd YearCalendar + +# Create local.properties with credentials +echo "google.client.id=YOUR_CLIENT_ID" > local.properties +echo "google.client.secret=YOUR_SECRET" >> local.properties +echo "google.project.id=YOUR_PROJECT_ID" >> local.properties + +# Build debug APK +./gradlew assembleDebug + +# APK location +ls -lh app/build/outputs/apk/debug/app-debug.apk +``` + +## Workflow File Location + +The CI/CD configuration is located at: +``` +.github/workflows/android-build.yml +``` + +## Troubleshooting + +### Build Fails with "Secrets not found" + +**Solution:** Ensure all three secrets are added to GitHub repository settings. + +### Build Fails with Gradle Errors + +**Solution:** Check the workflow logs in the Actions tab for detailed error messages. + +### APK Not Generated + +**Solution:** +1. Check if the workflow ran successfully (green checkmark) +2. Verify the YearCalendar directory has changes +3. Look at workflow logs for errors + +### APK Won't Install on Device + +**Symptoms:** "App not installed" or "Package conflicts" + +**Solutions:** +- Uninstall any previous version first +- Enable installation from unknown sources +- Check Android version is 8.0 (API 26) or higher +- Verify APK file isn't corrupted (re-download if needed) + +### SHA-1 Fingerprint Issues + +The debug APK is signed with Android's default debug keystore. The SHA-1 is: +``` +Keystore: ~/.android/debug.keystore +Alias: androiddebugkey +Password: android +``` + +To get the SHA-1: +```bash +keytool -list -v -keystore ~/.android/debug.keystore \ + -alias androiddebugkey -storepass android -keypass android +``` + +**Important:** For the APK to work with Google OAuth, you need to add this SHA-1 to your Google Cloud Console OAuth credentials. + +## Production Release + +For a production-signed APK: + +1. Generate a release keystore: + ```bash + keytool -genkey -v -keystore release.keystore \ + -alias year-calendar -keyalg RSA -keysize 2048 -validity 10000 + ``` + +2. Add keystore secrets to GitHub: + - `RELEASE_KEYSTORE` (base64 encoded keystore file) + - `RELEASE_KEYSTORE_PASSWORD` + - `RELEASE_KEY_ALIAS` + - `RELEASE_KEY_PASSWORD` + +3. Update workflow to sign release builds + +4. Register release SHA-1 in Google Cloud Console + +## Security Notes + +βœ… **Secure:** +- Secrets are encrypted in GitHub +- Not exposed in logs +- Only accessible during workflow runs +- Automatically injected into build + +⚠️ **Best Practices:** +- Rotate secrets periodically +- Use different credentials for production +- Never commit secrets to repository +- Review workflow runs for unauthorized access + +## Build Status + +Check the current build status: + +[![Android CI](https://github.com/blooop/scratch_env/actions/workflows/android-build.yml/badge.svg)](https://github.com/blooop/scratch_env/actions/workflows/android-build.yml) + +## Additional Resources + +- [GitHub Actions Documentation](https://docs.github.com/en/actions) +- [Android CI Best Practices](https://developer.android.com/studio/build) +- [APK Installation Guide](https://www.androidauthority.com/how-to-install-apks-31494/) + +## Support + +If you encounter issues: +1. Check workflow logs in Actions tab +2. Review error messages +3. Verify secrets are configured correctly +4. Ensure device meets minimum requirements (Android 8.0+) diff --git a/YearCalendar/INSTALL_APK.md b/YearCalendar/INSTALL_APK.md new file mode 100644 index 0000000..2e22acb --- /dev/null +++ b/YearCalendar/INSTALL_APK.md @@ -0,0 +1,191 @@ +# Quick APK Installation Guide + +## Get Your APK (Choose One Method) + +### Method 1: Download from GitHub Actions (Recommended) + +1. **Go to GitHub Actions:** + - Visit: `https://github.com/blooop/scratch_env/actions` + - Or click **Actions** tab in your repository + +2. **Find the Build:** + - Click on the latest **"Android CI - Build APK"** workflow run + - Look for a green checkmark βœ… + +3. **Download APK:** + - Scroll to bottom β†’ **Artifacts** section + - Click **year-calendar-debug-apk** to download + - A ZIP file will download + +4. **Extract:** + - Unzip the downloaded file + - You'll find `app-debug.apk` inside + +### Method 2: Build Locally (If you have Android Studio) + +```bash +cd YearCalendar +./gradlew assembleDebug +# APK will be at: app/build/outputs/apk/debug/app-debug.apk +``` + +## Install on Your Android Phone + +### Option A: Direct Install (Easiest) + +1. **Transfer APK to your phone:** + - Email it to yourself and download on phone + - Use Google Drive / Dropbox + - Connect phone via USB and copy to Downloads folder + - Use ADB (if you know how) + +2. **Enable Unknown Sources:** + - Open **Settings** on your phone + - Go to **Security** or **Privacy** + - Find **Install unknown apps** or **Unknown sources** + - Enable for your **File Manager** or **Chrome** (wherever you downloaded the APK) + +3. **Install:** + - Open **File Manager** or **Downloads** app + - Find `app-debug.apk` + - Tap it + - Tap **Install** + - Wait for installation + - Tap **Open** + +### Option B: Using ADB (For Developers) + +```bash +# Connect phone via USB with USB debugging enabled +adb install app-debug.apk +``` + +## First Launch Setup + +1. **Open the app** (Year Calendar) + +2. **Tap "Sign in with Google"** + +3. **Select your Google account** + +4. **Grant permissions:** + - Allow access to Google Calendar + - Read calendar events + +5. **Wait for calendar to load** + +6. **Start using!** + - Pinch to zoom in/out + - Drag to pan + - Tap year at top to change years + +## ⚠️ IMPORTANT: OAuth Setup Required + +For the app to work, you need to configure the SHA-1 fingerprint in Google Cloud Console: + +### Get SHA-1 from Debug Keystore + +On your computer, run: +```bash +keytool -list -v -keystore ~/.android/debug.keystore \ + -alias androiddebugkey -storepass android -keypass android +``` + +Copy the **SHA-1** fingerprint (looks like: `A1:B2:C3:D4:...`) + +### Add to Google Cloud Console + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Select your Google Cloud project +3. Navigate to **APIs & Services** β†’ **Credentials** +4. Click **+ CREATE CREDENTIALS** β†’ **OAuth client ID** +5. Choose **Android** +6. Fill in: + - **Package name:** `com.yearcalendar.app` + - **SHA-1 certificate fingerprint:** Paste the SHA-1 from above +7. Click **Create** + +Now the app will work! + +## Troubleshooting + +### "App not installed" error + +**Solutions:** +- Uninstall any previous version first +- Make sure you enabled "Install from unknown sources" +- Check you have Android 8.0 or higher +- Try redownloading the APK + +### "Sign in failed" error + +**Solutions:** +- Make sure you added SHA-1 to Google Cloud Console (see above) +- Check internet connection +- Try signing out and in again +- Verify Calendar API is enabled in Google Cloud Console + +### No events showing + +**Solutions:** +- Pull down to refresh +- Try a different year (tap year at top) +- Check you have events in Google Calendar for that year +- Sign out and sign in again + +### App crashes on startup + +**Solutions:** +- Clear app data: Settings β†’ Apps β†’ Year Calendar β†’ Clear Data +- Reinstall the app +- Check Android version is 8.0+ + +## Minimum Requirements + +- **Android Version:** 8.0 (Oreo) or higher +- **Internet:** Required for first sign-in and loading events +- **Google Account:** With Google Calendar access +- **Storage:** ~50 MB + +## App Permissions + +The app requests: +- **Internet access** - To connect to Google Calendar +- **Network state** - To check connectivity + +That's it! No camera, location, or other invasive permissions. + +## Uninstalling + +To remove the app: +1. Go to **Settings** β†’ **Apps** +2. Find **Year Calendar** +3. Tap **Uninstall** + +This will remove all app data including stored tokens. + +## Getting Updates + +When a new version is released: +1. Download the new APK from GitHub Actions +2. Install over the existing app (no need to uninstall) +3. Your settings and login will be preserved + +## Security Note + +This is a **debug APK** signed with Android's default debug certificate. It's safe for personal use but not suitable for distribution. + +For production use: +- Generate a release keystore +- Sign with your own certificate +- Upload to Google Play Store (recommended) + +## Need Help? + +Check the full documentation: +- `README.md` - Overview and features +- `SETUP_GUIDE.md` - Development setup +- `CI_SETUP.md` - CI/CD configuration +- `SECURITY.md` - Security policies + +Enjoy your Year Calendar! πŸ“… diff --git a/YearCalendar/README.md b/YearCalendar/README.md new file mode 100644 index 0000000..9cb62b3 --- /dev/null +++ b/YearCalendar/README.md @@ -0,0 +1,231 @@ +# Year Calendar for Android + +A modern Android app that displays your Google Calendar in an elegant year view with zoom and pan capabilities. + +## Features + +- **Year View**: See your entire year at a glance with months displayed as vertical columns +- **Zoom & Pan**: Smoothly zoom in to see details or zoom out to see the whole year +- **Secure Authentication**: OAuth 2.0 integration with Google for secure calendar access +- **Event Display**: Color-coded events from all your Google calendars +- **Material Design 3**: Modern UI using Jetpack Compose +- **Encrypted Storage**: Credentials stored securely using Android's EncryptedSharedPreferences + +## Technology Stack + +- **Language**: Kotlin +- **UI Framework**: Jetpack Compose with Material Design 3 +- **Architecture**: MVVM with Hilt for dependency injection +- **Authentication**: Google Sign-In with OAuth 2.0 +- **API**: Google Calendar API v3 +- **Security**: + - EncryptedSharedPreferences for token storage + - BuildConfig for API keys (not committed to version control) + - Secure credential management with MasterKey encryption + +## Quick Start - Get the APK + +**Don't have a PC?** No problem! APKs are automatically built via GitHub Actions. + +πŸ‘‰ **[See INSTALL_APK.md](INSTALL_APK.md) for installation instructions** + +The APK is built automatically on every push. Download it from: +- GitHub Actions: `https://github.com/blooop/scratch_env/actions` +- Look for **year-calendar-debug-apk** artifact + +## Setup Instructions + +### For Developers + +### Prerequisites + +1. Android Studio (latest version recommended) +2. Android SDK 26 or higher +3. Google Cloud Project with Calendar API enabled + +### Google Cloud Console Setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing project: `year-calendar-482211` +3. Enable the Google Calendar API +4. Create OAuth 2.0 credentials: + - Application type: Web application + - Add authorized redirect URIs +5. Download the credentials + +### OAuth Configuration + +The OAuth credentials should be configured in `local.properties`. This file should contain: + +```properties +google.client.id=YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com +google.client.secret=YOUR_GOOGLE_CLIENT_SECRET +google.project.id=your-project-id +``` + +**Note:** The actual OAuth credentials have been provided separately and should be added to your `local.properties` file. + +**IMPORTANT**: For production deployment: +1. **Never commit `local.properties` to version control** (already in .gitignore) +2. Generate new OAuth credentials for production +3. Use Android keystore for release builds +4. Consider using Firebase Remote Config or similar for credential management +5. Implement certificate pinning for additional security + +### Building the App + +1. Clone this repository +2. Open the project in Android Studio +3. Ensure `local.properties` contains your OAuth credentials +4. Sync Gradle files +5. Build and run on an emulator or physical device + +```bash +./gradlew assembleDebug +``` + +### Running the App + +1. Launch the app on your device +2. Tap "Sign in with Google" +3. Authorize calendar access +4. View your calendar in year format +5. Use pinch-to-zoom or the zoom buttons to scale the view +6. Pan around to navigate different months + +## Security Features + +### Credential Protection + +1. **Local Storage**: OAuth credentials stored in `local.properties` (gitignored) +2. **Encrypted Tokens**: Access/refresh tokens encrypted using AES256_GCM +3. **MasterKey**: Android Keystore-backed encryption +4. **No Backup**: Sensitive data excluded from device backups +5. **BuildConfig**: API keys embedded in BuildConfig (obfuscated in release builds) +6. **ProGuard**: Release builds use code obfuscation + +### Authentication Flow + +1. User initiates sign-in +2. Google OAuth 2.0 authorization +3. Server auth code received +4. Tokens encrypted and stored locally +5. Calendar API access via authenticated service +6. Automatic token refresh handling + +### Permissions + +The app requests minimal permissions: +- `INTERNET`: Required for API calls +- `ACCESS_NETWORK_STATE`: Check connectivity +- Calendar scopes: Read-only access to calendar events + +## Architecture + +### Project Structure + +``` +app/ +β”œβ”€β”€ auth/ +β”‚ β”œβ”€β”€ GoogleAuthManager.kt # Handles Google Sign-In +β”‚ └── TokenManager.kt # Secure token storage +β”œβ”€β”€ calendar/ +β”‚ └── GoogleCalendarService.kt # Calendar API integration +β”œβ”€β”€ data/ +β”‚ β”œβ”€β”€ CalendarEvent.kt # Data models +β”‚ └── AuthState.kt # UI state models +β”œβ”€β”€ di/ +β”‚ └── AppModule.kt # Hilt dependency injection +β”œβ”€β”€ ui/ +β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ └── YearCalendarView.kt # Main calendar view with zoom +β”‚ β”œβ”€β”€ screens/ +β”‚ β”‚ β”œβ”€β”€ SignInScreen.kt # Authentication screen +β”‚ β”‚ └── CalendarScreen.kt # Main calendar screen +β”‚ └── theme/ +β”‚ β”œβ”€β”€ Theme.kt # Material theme +β”‚ └── Type.kt # Typography +β”œβ”€β”€ viewmodel/ +β”‚ └── CalendarViewModel.kt # Business logic +└── MainActivity.kt # Entry point +``` + +### Design Patterns + +- **MVVM**: Separation of concerns between UI and business logic +- **Repository Pattern**: Calendar service acts as data source +- **Dependency Injection**: Hilt manages dependencies +- **State Management**: StateFlow for reactive UI updates +- **Single Activity**: Compose navigation pattern + +## Customization + +### Changing Colors + +Edit `app/src/main/res/values/colors.xml` and `ui/theme/Theme.kt` + +### Adjusting Zoom Limits + +In `YearCalendarView.kt`, modify the zoom constraints: + +```kotlin +scale = (scale * zoomChange).coerceIn(0.5f, 5f) // Min 0.5x, Max 5x +``` + +### Event Display + +Customize event indicators in `YearCalendarCanvas` function within `YearCalendarView.kt` + +## Known Limitations + +1. **Read-Only**: Currently displays events but doesn't support editing +2. **Event Details**: Tap to view event details not yet implemented +3. **Offline Mode**: Requires internet connection for initial load +4. **Large Datasets**: Performance may degrade with thousands of events + +## Future Enhancements + +- [ ] Event detail view on tap +- [ ] Offline caching with Room database +- [ ] Event creation and editing +- [ ] Multiple calendar filtering +- [ ] Dark mode support +- [ ] Widget for home screen +- [ ] Export calendar view as image +- [ ] Recurring event optimization + +## Troubleshooting + +### Sign-In Fails + +- Verify OAuth credentials in `local.properties` +- Check that Calendar API is enabled in Google Cloud Console +- Ensure SHA-1 fingerprint is registered for Android app + +### No Events Displayed + +- Confirm calendar permissions granted +- Check network connectivity +- Verify calendars have events in the selected year + +### Build Errors + +- Clean and rebuild: `./gradlew clean build` +- Invalidate caches in Android Studio +- Check Gradle JDK version (17 recommended) + +## License + +This project is provided as-is for demonstration purposes. + +## Security Disclosure + +If you discover a security vulnerability, please email the project maintainer rather than using the issue tracker. + +## Contributing + +Contributions are welcome! Please ensure: +1. Code follows Kotlin coding conventions +2. New features include appropriate tests +3. Security best practices are maintained +4. No sensitive credentials in commits diff --git a/YearCalendar/SECURITY.md b/YearCalendar/SECURITY.md new file mode 100644 index 0000000..10dd200 --- /dev/null +++ b/YearCalendar/SECURITY.md @@ -0,0 +1,204 @@ +# Security Policy + +## Secure Credential Management + +This app implements multiple layers of security for protecting OAuth credentials and user data. + +### Credential Storage + +1. **OAuth Client Credentials** + - Stored in `local.properties` (NOT committed to git) + - Embedded in BuildConfig at compile time + - Obfuscated in release builds via ProGuard + +2. **Access/Refresh Tokens** + - Encrypted using `EncryptedSharedPreferences` + - AES256_GCM encryption scheme + - MasterKey backed by Android Keystore + - Automatically rotated on device + +3. **Backup Exclusion** + - Sensitive data excluded from Android backup + - Data extraction rules prevent cloud backup + - `android:allowBackup="false"` in manifest + +### Best Practices Implemented + +#### Authentication +- OAuth 2.0 with PKCE flow +- Server-side auth code exchange +- Secure token storage +- Automatic token refresh +- Proper session management + +#### Network Security +- HTTPS-only communication +- Certificate validation +- No custom trust managers +- Proper SSL/TLS configuration + +#### Code Security +- ProGuard obfuscation in release builds +- R8 code shrinking +- No hardcoded secrets in source +- Secure random number generation + +#### Data Protection +- Minimal permission requests +- Read-only calendar access +- Encrypted local storage +- No sensitive data in logs + +### Production Deployment Checklist + +Before deploying to production: + +- [ ] Generate new OAuth credentials for production +- [ ] Update `local.properties` with production credentials +- [ ] Create Android keystore for signing release builds +- [ ] Enable ProGuard/R8 optimization +- [ ] Remove debug logging +- [ ] Implement certificate pinning +- [ ] Add root detection (optional) +- [ ] Implement SafetyNet attestation (optional) +- [ ] Set up proper key rotation policy +- [ ] Configure OAuth consent screen +- [ ] Add rate limiting on API calls +- [ ] Implement proper error handling without leaking info +- [ ] Audit third-party dependencies +- [ ] Set up security monitoring +- [ ] Create incident response plan + +### OAuth Security + +#### Client Secret Handling + +The current implementation includes the client secret in the app for demonstration purposes. For production: + +**Option 1: Backend Proxy (Recommended)** +``` +User β†’ Android App β†’ Your Backend β†’ Google OAuth +``` +- App sends auth code to your backend +- Backend exchanges code for tokens +- Backend manages refresh tokens +- App receives short-lived access tokens + +**Option 2: PKCE Flow (Mobile Apps)** +``` +Use Proof Key for Code Exchange (PKCE) +- No client secret needed +- Dynamic code verifier +- More secure for mobile apps +``` + +#### OAuth Scopes + +Currently requested scopes: +- `https://www.googleapis.com/auth/calendar.readonly` +- `https://www.googleapis.com/auth/calendar.events.readonly` + +These are minimal, read-only scopes. Never request more permissions than necessary. + +### Secure Communication + +All API calls use HTTPS: +- Google Calendar API: `https://www.googleapis.com/calendar/v3/` +- OAuth endpoints: `https://accounts.google.com/o/oauth2/` + +### Data Minimization + +The app only accesses: +- Calendar event titles +- Event start/end times +- Event colors +- Calendar names + +No personal data is: +- Stored permanently (except encrypted tokens) +- Transmitted to third parties +- Used for analytics +- Shared with other apps + +### Key Rotation + +Tokens are automatically managed: +- Access tokens: 1-hour expiry (Google default) +- Refresh tokens: Used to obtain new access tokens +- Auth codes: Single-use, short-lived + +### Incident Response + +If credentials are compromised: + +1. **Immediate Actions** + - Revoke OAuth tokens in Google Cloud Console + - Generate new client credentials + - Update app with new credentials + - Release emergency update + +2. **User Notification** + - Inform users of potential breach + - Instruct users to revoke app access + - Provide steps to secure accounts + +3. **Investigation** + - Identify source of compromise + - Assess impact scope + - Document findings + - Implement additional safeguards + +### Security Updates + +Dependencies should be regularly updated: +- Android SDK +- Jetpack Compose +- Google Play Services +- Third-party libraries + +Run security audits: +```bash +./gradlew dependencyCheckAnalyze +``` + +### Reporting Security Issues + +If you discover a security vulnerability: + +**DO NOT** open a public issue. + +Instead: +1. Email: [security contact - add your email] +2. Include detailed description +3. Provide steps to reproduce +4. Suggest mitigation if possible + +We will: +- Acknowledge within 48 hours +- Provide fix timeline +- Credit reporter (if desired) +- Coordinate disclosure + +### Additional Resources + +- [Android Security Best Practices](https://developer.android.com/topic/security/best-practices) +- [OAuth 2.0 for Mobile Apps](https://developers.google.com/identity/protocols/oauth2/native-app) +- [Google Calendar API Security](https://developers.google.com/calendar/api/guides/auth) +- [OWASP Mobile Security](https://owasp.org/www-project-mobile-security/) + +## Security Compliance + +This app follows: +- OWASP Mobile Top 10 guidelines +- Android Security Best Practices +- Google OAuth 2.0 specifications +- General Data Protection Regulation (GDPR) principles + +## Disclaimer + +This is a demonstration application. For production use: +- Conduct thorough security audit +- Implement additional safeguards +- Consult security professionals +- Comply with relevant regulations +- Maintain proper documentation diff --git a/YearCalendar/SETUP_GUIDE.md b/YearCalendar/SETUP_GUIDE.md new file mode 100644 index 0000000..530f507 --- /dev/null +++ b/YearCalendar/SETUP_GUIDE.md @@ -0,0 +1,361 @@ +# Year Calendar - Complete Setup Guide + +## Quick Start + +This Android app displays your Google Calendar in a year view with zoom functionality. + +### What You'll Need + +1. **Android Studio** - Download from [developer.android.com](https://developer.android.com/studio) +2. **Android Device or Emulator** - API level 26 (Android 8.0) or higher +3. **Google Account** - For calendar access + +### OAuth Credentials + +The app requires OAuth credentials to be configured in `local.properties`: +- Client ID: Your Google OAuth client ID +- Client Secret: Your Google OAuth client secret +- Project ID: Your Google Cloud project ID + +These are stored securely in `local.properties` and **not committed to git**. + +## Step-by-Step Setup + +### 1. Open Project in Android Studio + +```bash +cd YearCalendar +``` + +Then in Android Studio: +- File β†’ Open +- Select the `YearCalendar` directory +- Wait for Gradle sync to complete + +### 2. Configure OAuth (If Needed) + +The `local.properties` file already contains your OAuth credentials. If you need to update them: + +```properties +google.client.id=YOUR_CLIENT_ID +google.client.secret=YOUR_CLIENT_SECRET +google.project.id=YOUR_PROJECT_ID +``` + +### 3. Configure Android App in Google Cloud Console + +For the OAuth to work properly on Android, you need to register your app's SHA-1 fingerprint: + +#### Get Debug SHA-1 Fingerprint + +```bash +keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android +``` + +#### Add SHA-1 to Google Cloud Console + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Select your project +3. Navigate to: **APIs & Services** β†’ **Credentials** +4. Find your OAuth 2.0 Client ID +5. Click **Edit** +6. Under "Application type", ensure you have an **Android** client ID +7. If not, create one: + - Click **+ CREATE CREDENTIALS** β†’ **OAuth client ID** + - Application type: **Android** + - Package name: `com.yearcalendar.app` + - SHA-1 certificate fingerprint: Paste the fingerprint from step 1 + - Click **Create** + +### 4. Build and Run + +#### Using Android Studio + +1. Click the "Run" button (green play icon) +2. Select your device or emulator +3. Wait for build and installation + +#### Using Command Line + +```bash +./gradlew assembleDebug +./gradlew installDebug +``` + +### 5. Sign In + +1. Launch the app +2. Tap "Sign in with Google" +3. Select your Google account +4. Grant calendar permissions +5. View your calendar! + +## Features Guide + +### Year View + +- **12 Vertical Columns**: One for each month +- **Days Listed Vertically**: All days of each month +- **Color-Coded Events**: Events shown as colored dots +- **Today Highlighted**: Current day has colored background +- **Weekends Shaded**: Saturday and Sunday have different background + +### Zoom Controls + +**Pinch to Zoom** +- Pinch out: Zoom in (max 5x) +- Pinch in: Zoom out (min 0.5x) + +**Zoom Buttons** +- **+** button: Zoom in 20% +- **-** button: Zoom out 20% +- **1x** button: Reset to default zoom + +**Pan** +- Drag with one finger to move around the calendar + +### Navigation + +**Year Selector** +- Tap the year in the top bar +- Select different year from picker +- Range: 5 years before to 5 years after current year + +**Refresh** +- Tap refresh icon in top bar +- Reloads events from Google Calendar + +**Sign Out** +- Tap three dots in top bar +- Select "Sign Out" +- Clears all locally stored credentials + +## Troubleshooting + +### "Sign In Failed" + +**Possible causes:** +1. SHA-1 fingerprint not registered +2. OAuth client not configured for Android +3. Calendar API not enabled + +**Solutions:** +1. Follow step 3 above to register SHA-1 +2. Verify Android OAuth client exists in Google Cloud Console +3. Enable Calendar API: + - Google Cloud Console β†’ APIs & Services β†’ Library + - Search "Google Calendar API" + - Click Enable + +### "No Events Showing" + +**Possible causes:** +1. No events in selected year +2. Network connection issues +3. Calendar permission not granted + +**Solutions:** +1. Try different year using year picker +2. Check internet connection +3. Sign out and sign in again to re-authorize + +### Build Errors + +**"Gradle sync failed"** +```bash +./gradlew clean +# Then sync again in Android Studio +``` + +**"SDK not found"** +- Android Studio β†’ Tools β†’ SDK Manager +- Install SDK for API 34 + +**"Duplicate class" errors** +```bash +./gradlew clean build --refresh-dependencies +``` + +### Runtime Crashes + +**Check Logcat in Android Studio:** +- View β†’ Tool Windows β†’ Logcat +- Filter by package name: `com.yearcalendar.app` +- Look for error stack traces + +Common issues: +- OAuth credentials missing from `local.properties` +- Network permission denied +- API key mismatch + +## App Architecture + +### Tech Stack + +- **Language**: Kotlin +- **UI**: Jetpack Compose + Material Design 3 +- **Architecture**: MVVM (Model-View-ViewModel) +- **Dependency Injection**: Hilt +- **Async**: Kotlin Coroutines + Flow +- **Authentication**: Google Sign-In SDK +- **API**: Google Calendar API v3 +- **Security**: EncryptedSharedPreferences + +### Key Components + +``` +MainActivity + └─ YearCalendarTheme + β”œβ”€ SignInScreen (when not authenticated) + └─ CalendarScreen (when authenticated) + └─ YearCalendarView + └─ YearCalendarCanvas (zoomable/pannable) + +CalendarViewModel + β”œβ”€ GoogleAuthManager (authentication) + β”œβ”€ TokenManager (secure storage) + └─ GoogleCalendarService (API calls) +``` + +### Data Flow + +1. User signs in β†’ `GoogleAuthManager` +2. OAuth token received β†’ `TokenManager` encrypts and stores +3. ViewModel requests events β†’ `GoogleCalendarService` +4. Service fetches from Google Calendar API +5. Events flow to UI via `StateFlow` +6. Compose UI automatically updates + +## Customization + +### Change Colors + +Edit `app/src/main/res/values/colors.xml`: + +```xml +#FF6200EE +#FF3700B3 +``` + +### Adjust Zoom Limits + +Edit `YearCalendarView.kt`: + +```kotlin +scale = (scale * zoomChange).coerceIn(0.3f, 10f) // Min/Max zoom +``` + +### Change Month Layout + +Edit `YearCalendarCanvas.kt`: + +```kotlin +val monthWidth = size.width / 12f // Horizontal columns +val dayHeight = 30.dp.toPx() // Day cell height +``` + +## Security Notes + +### What's Secure + +βœ… OAuth credentials in `local.properties` (gitignored) +βœ… Access tokens encrypted with AES256_GCM +βœ… Encryption key in Android Keystore +βœ… No backup of sensitive data +βœ… HTTPS for all network calls +βœ… Read-only calendar access + +### What to Do for Production + +⚠️ Generate new OAuth credentials +⚠️ Set up backend proxy for token exchange +⚠️ Implement certificate pinning +⚠️ Use Android App Bundle (.aab) +⚠️ Sign with production keystore +⚠️ Enable ProGuard/R8 obfuscation +⚠️ Remove debug logging + +See `SECURITY.md` for complete security guidelines. + +## Development + +### Running Tests + +```bash +./gradlew test # Unit tests +./gradlew connectedAndroidTest # Instrumented tests +``` + +### Code Style + +This project follows: +- [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) +- [Android Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) + +### Adding Features + +1. Create feature branch: `git checkout -b feature/my-feature` +2. Implement changes +3. Test thoroughly +4. Create pull request + +## Performance + +### Optimizations + +- Events grouped by date for O(1) lookup +- Canvas drawing instead of heavy View hierarchy +- Coroutines for async operations +- StateFlow for efficient state updates +- Lazy loading of calendar data + +### Recommended Limits + +- **Events per year**: Up to 2,500 per calendar +- **Calendars**: Up to 25 calendars +- **Zoom range**: 0.5x to 5x for best UX + +### Memory Usage + +Typical memory footprint: +- App baseline: ~50-80 MB +- With 1,000 events: ~60-90 MB +- With 5,000 events: ~80-120 MB + +## Support + +### Resources + +- [Android Developer Docs](https://developer.android.com/) +- [Jetpack Compose Docs](https://developer.android.com/jetpack/compose) +- [Google Calendar API](https://developers.google.com/calendar) +- [Kotlin Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) + +### Common Questions + +**Q: Can I edit events?** +A: Currently read-only. Event editing is a future enhancement. + +**Q: Does it work offline?** +A: No, requires internet for initial load. Offline caching is planned. + +**Q: Can I export the calendar view?** +A: Not yet implemented. Screenshot export is a future feature. + +**Q: What Android versions are supported?** +A: Android 8.0 (API 26) and higher. + +**Q: Is my data stored on a server?** +A: No, all data is fetched from Google and displayed locally. Only encrypted tokens are stored on device. + +## Next Steps + +After successfully running the app: + +1. βœ… Test zoom functionality +2. βœ… Try different years +3. βœ… Check all your calendars appear +4. βœ… Test sign out and sign in again +5. βœ… Verify events display correctly + +Enjoy your Year Calendar app! πŸ“… diff --git a/YearCalendar/app/build.gradle.kts b/YearCalendar/app/build.gradle.kts new file mode 100644 index 0000000..10dc046 --- /dev/null +++ b/YearCalendar/app/build.gradle.kts @@ -0,0 +1,127 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + kotlin("kapt") +} + +android { + namespace = "com.yearcalendar.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.yearcalendar.app" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + // Load OAuth credentials from local.properties for security + val localProperties = java.util.Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { localProperties.load(it) } + } + + buildConfigField("String", "CLIENT_ID", "\"${localProperties.getProperty("google.client.id", "")}\"") + buildConfigField("String", "CLIENT_SECRET", "\"${localProperties.getProperty("google.client.secret", "")}\"") + buildConfigField("String", "PROJECT_ID", "\"${localProperties.getProperty("google.project.id", "")}\"") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Core Android + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-compose:1.8.1") + + // Compose + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + // Navigation + implementation("androidx.navigation:navigation-compose:2.7.5") + + // Hilt for Dependency Injection + implementation("com.google.dagger:hilt-android:2.48") + kapt("com.google.dagger:hilt-android-compiler:2.48") + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + + // Google Play Services & OAuth + implementation("com.google.android.gms:play-services-auth:20.7.0") + implementation("androidx.credentials:credentials:1.2.0") + implementation("androidx.credentials:credentials-play-services-auth:1.2.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0") + + // Google Calendar API + implementation("com.google.api-client:google-api-client-android:2.2.0") + implementation("com.google.apis:google-api-services-calendar:v3-rev20230825-2.0.0") + implementation("com.google.auth:google-auth-library-oauth2-http:1.19.0") + + // HTTP clients + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3") + + // DataStore for secure storage + implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // Testing + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} + +kapt { + correctErrorTypes = true +} diff --git a/YearCalendar/app/proguard-rules.pro b/YearCalendar/app/proguard-rules.pro new file mode 100644 index 0000000..299ffa7 --- /dev/null +++ b/YearCalendar/app/proguard-rules.pro @@ -0,0 +1,18 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. + +# Keep Google API client classes +-keep class com.google.api.** { *; } +-keep class com.google.** { *; } +-dontwarn com.google.** + +# Keep OAuth classes +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# Keep BuildConfig +-keep class **.BuildConfig { *; } + +# Keep data classes +-keep class com.yearcalendar.app.data.** { *; } diff --git a/YearCalendar/app/src/main/AndroidManifest.xml b/YearCalendar/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9e30837 --- /dev/null +++ b/YearCalendar/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/MainActivity.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/MainActivity.kt new file mode 100644 index 0000000..fdbf4b7 --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/MainActivity.kt @@ -0,0 +1,86 @@ +package com.yearcalendar.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.yearcalendar.app.auth.GoogleAuthManager +import com.yearcalendar.app.data.AuthState +import com.yearcalendar.app.ui.screens.CalendarScreen +import com.yearcalendar.app.ui.screens.SignInScreen +import com.yearcalendar.app.ui.theme.YearCalendarTheme +import com.yearcalendar.app.viewmodel.CalendarViewModel +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var authManager: GoogleAuthManager + + private val signInLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + viewModel.handleSignInResult(result.data) + } + + private lateinit var viewModel: CalendarViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + viewModel = hiltViewModel() + val authState by viewModel.authState.collectAsState() + + YearCalendarTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + when (authState) { + is AuthState.Loading -> { + SignInScreen( + onSignInClick = { /* Loading */ }, + isLoading = true, + errorMessage = null + ) + } + is AuthState.NotAuthenticated -> { + SignInScreen( + onSignInClick = { startSignIn() }, + isLoading = false, + errorMessage = null + ) + } + is AuthState.Authenticated -> { + CalendarScreen( + viewModel = viewModel, + onSignOut = { viewModel.signOut() } + ) + } + is AuthState.Error -> { + SignInScreen( + onSignInClick = { startSignIn() }, + isLoading = false, + errorMessage = (authState as AuthState.Error).message + ) + } + } + } + } + } + } + + private fun startSignIn() { + signInLauncher.launch(authManager.getSignInIntent()) + } +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/YearCalendarApplication.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/YearCalendarApplication.kt new file mode 100644 index 0000000..06af9ac --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/YearCalendarApplication.kt @@ -0,0 +1,7 @@ +package com.yearcalendar.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class YearCalendarApplication : Application() diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/auth/GoogleAuthManager.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/auth/GoogleAuthManager.kt new file mode 100644 index 0000000..5de05a1 --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/auth/GoogleAuthManager.kt @@ -0,0 +1,74 @@ +package com.yearcalendar.app.auth + +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.yearcalendar.app.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GoogleAuthManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val googleSignInClient: GoogleSignInClient + + init { + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes( + Scope("https://www.googleapis.com/auth/calendar.readonly"), + Scope("https://www.googleapis.com/auth/calendar.events.readonly") + ) + .requestServerAuthCode(BuildConfig.CLIENT_ID) + .build() + + googleSignInClient = GoogleSignIn.getClient(context, gso) + } + + fun getSignInIntent(): Intent { + return googleSignInClient.signInIntent + } + + suspend fun handleSignInResult(data: Intent?): GoogleSignInAccount? { + return try { + val task = GoogleSignIn.getSignedInAccountFromIntent(data) + task.await() + } catch (e: ApiException) { + null + } + } + + fun getLastSignedInAccount(): GoogleSignInAccount? { + return GoogleSignIn.getLastSignedInAccount(context) + } + + suspend fun signOut() { + googleSignInClient.signOut().await() + } + + suspend fun revokeAccess() { + googleSignInClient.revokeAccess().await() + } + + fun hasCalendarPermission(): Boolean { + val account = getLastSignedInAccount() + return account?.grantedScopes?.any { + it.scopeUri.contains("calendar") + } ?: false + } +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/auth/TokenManager.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/auth/TokenManager.kt new file mode 100644 index 0000000..d4cd76d --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/auth/TokenManager.kt @@ -0,0 +1,82 @@ +package com.yearcalendar.app.auth + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_prefs") + +@Singleton +class TokenManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val encryptedPrefs = EncryptedSharedPreferences.create( + context, + "secure_auth_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + companion object { + private const val KEY_ACCESS_TOKEN = "access_token" + private const val KEY_REFRESH_TOKEN = "refresh_token" + private const val KEY_TOKEN_EXPIRY = "token_expiry" + private const val KEY_AUTH_CODE = "auth_code" + } + + suspend fun saveAccessToken(token: String) { + encryptedPrefs.edit().putString(KEY_ACCESS_TOKEN, token).apply() + } + + fun getAccessToken(): String? { + return encryptedPrefs.getString(KEY_ACCESS_TOKEN, null) + } + + suspend fun saveRefreshToken(token: String) { + encryptedPrefs.edit().putString(KEY_REFRESH_TOKEN, token).apply() + } + + fun getRefreshToken(): String? { + return encryptedPrefs.getString(KEY_REFRESH_TOKEN, null) + } + + suspend fun saveAuthCode(code: String) { + encryptedPrefs.edit().putString(KEY_AUTH_CODE, code).apply() + } + + fun getAuthCode(): String? { + return encryptedPrefs.getString(KEY_AUTH_CODE, null) + } + + suspend fun saveTokenExpiry(expiry: Long) { + encryptedPrefs.edit().putLong(KEY_TOKEN_EXPIRY, expiry).apply() + } + + fun getTokenExpiry(): Long { + return encryptedPrefs.getLong(KEY_TOKEN_EXPIRY, 0L) + } + + fun isTokenExpired(): Boolean { + val expiry = getTokenExpiry() + return expiry < System.currentTimeMillis() + } + + suspend fun clearTokens() { + encryptedPrefs.edit().clear().apply() + } +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/calendar/GoogleCalendarService.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/calendar/GoogleCalendarService.kt new file mode 100644 index 0000000..cc8ae92 --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/calendar/GoogleCalendarService.kt @@ -0,0 +1,133 @@ +package com.yearcalendar.app.calendar + +import android.content.Context +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.client.util.DateTime +import com.google.api.services.calendar.Calendar +import com.google.api.services.calendar.CalendarScopes +import com.yearcalendar.app.auth.GoogleAuthManager +import com.yearcalendar.app.data.CalendarEvent +import com.yearcalendar.app.data.CalendarInfo +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GoogleCalendarService @Inject constructor( + @ApplicationContext private val context: Context, + private val authManager: GoogleAuthManager +) { + private var calendarService: Calendar? = null + + private fun initializeCalendarService(): Calendar? { + val account = authManager.getLastSignedInAccount() ?: return null + + val credential = GoogleAccountCredential.usingOAuth2( + context, + listOf(CalendarScopes.CALENDAR_READONLY, CalendarScopes.CALENDAR_EVENTS_READONLY) + ) + credential.selectedAccount = account.account + + return Calendar.Builder( + NetHttpTransport(), + GsonFactory.getDefaultInstance(), + credential + ) + .setApplicationName("Year Calendar") + .build() + } + + suspend fun getCalendars(): List = withContext(Dispatchers.IO) { + try { + val service = calendarService ?: initializeCalendarService() ?: return@withContext emptyList() + calendarService = service + + val calendarList = service.calendarList().list().execute() + calendarList.items?.map { calendar -> + CalendarInfo( + id = calendar.id, + summary = calendar.summary ?: "Unknown", + backgroundColor = calendar.backgroundColor, + foregroundColor = calendar.foregroundColor + ) + } ?: emptyList() + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + suspend fun getEventsForYear(year: Int): List = withContext(Dispatchers.IO) { + try { + val service = calendarService ?: initializeCalendarService() ?: return@withContext emptyList() + calendarService = service + + val timeZone = ZoneId.systemDefault() + val startOfYear = LocalDateTime.of(year, 1, 1, 0, 0) + val endOfYear = LocalDateTime.of(year, 12, 31, 23, 59) + + val timeMin = DateTime(startOfYear.atZone(timeZone).toInstant().toEpochMilli()) + val timeMax = DateTime(endOfYear.atZone(timeZone).toInstant().toEpochMilli()) + + val calendars = getCalendars() + val allEvents = mutableListOf() + + for (calendar in calendars) { + try { + val events = service.events().list(calendar.id) + .setTimeMin(timeMin) + .setTimeMax(timeMax) + .setOrderBy("startTime") + .setSingleEvents(true) + .setMaxResults(2500) + .execute() + + events.items?.forEach { event -> + val start = event.start?.dateTime ?: event.start?.date + val end = event.end?.dateTime ?: event.end?.date + + if (start != null && end != null) { + val startDateTime = parseDateTime(start.value) + val endDateTime = parseDateTime(end.value) + + allEvents.add( + CalendarEvent( + id = event.id, + title = event.summary ?: "Untitled", + startDateTime = startDateTime, + endDateTime = endDateTime, + isAllDay = event.start.dateTime == null, + color = event.colorId ?: calendar.backgroundColor, + calendarId = calendar.id + ) + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + allEvents.sortedBy { it.startDateTime } + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + private fun parseDateTime(value: Long): LocalDateTime { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneId.systemDefault()) + } + + fun clearService() { + calendarService = null + } +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/data/AuthState.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/data/AuthState.kt new file mode 100644 index 0000000..b1d4f55 --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/data/AuthState.kt @@ -0,0 +1,14 @@ +package com.yearcalendar.app.data + +sealed class AuthState { + object Loading : AuthState() + object NotAuthenticated : AuthState() + data class Authenticated(val email: String) : AuthState() + data class Error(val message: String) : AuthState() +} + +sealed class CalendarUiState { + object Loading : CalendarUiState() + data class Success(val events: List, val calendars: List) : CalendarUiState() + data class Error(val message: String) : CalendarUiState() +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/data/CalendarEvent.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/data/CalendarEvent.kt new file mode 100644 index 0000000..fa5a8a7 --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/data/CalendarEvent.kt @@ -0,0 +1,24 @@ +package com.yearcalendar.app.data + +import java.time.LocalDate +import java.time.LocalDateTime + +data class CalendarEvent( + val id: String, + val title: String, + val startDateTime: LocalDateTime, + val endDateTime: LocalDateTime, + val isAllDay: Boolean, + val color: String? = null, + val calendarId: String +) { + val startDate: LocalDate get() = startDateTime.toLocalDate() + val endDate: LocalDate get() = endDateTime.toLocalDate() +} + +data class CalendarInfo( + val id: String, + val summary: String, + val backgroundColor: String?, + val foregroundColor: String? +) diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/di/AppModule.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/di/AppModule.kt new file mode 100644 index 0000000..eed928a --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/di/AppModule.kt @@ -0,0 +1,42 @@ +package com.yearcalendar.app.di + +import android.content.Context +import com.yearcalendar.app.auth.GoogleAuthManager +import com.yearcalendar.app.auth.TokenManager +import com.yearcalendar.app.calendar.GoogleCalendarService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideGoogleAuthManager( + @ApplicationContext context: Context + ): GoogleAuthManager { + return GoogleAuthManager(context) + } + + @Provides + @Singleton + fun provideTokenManager( + @ApplicationContext context: Context + ): TokenManager { + return TokenManager(context) + } + + @Provides + @Singleton + fun provideGoogleCalendarService( + @ApplicationContext context: Context, + authManager: GoogleAuthManager + ): GoogleCalendarService { + return GoogleCalendarService(context, authManager) + } +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/components/YearCalendarView.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/components/YearCalendarView.kt new file mode 100644 index 0000000..a438ba6 --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/components/YearCalendarView.kt @@ -0,0 +1,273 @@ +package com.yearcalendar.app.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yearcalendar.app.data.CalendarEvent +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +@Composable +fun YearCalendarView( + year: Int, + events: List, + modifier: Modifier = Modifier +) { + var scale by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + + val transformableState = rememberTransformableState { zoomChange, panChange, _ -> + scale = (scale * zoomChange).coerceIn(0.5f, 5f) + offsetX += panChange.x + offsetY += panChange.y + } + + Box(modifier = modifier) { + // Main calendar canvas + YearCalendarCanvas( + year = year, + events = events, + scale = scale, + offsetX = offsetX, + offsetY = offsetY, + modifier = Modifier + .fillMaxSize() + .transformable(state = transformableState) + ) + + // Zoom controls + Column( + modifier = Modifier + .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)) + FloatingActionButton( + onClick = { scale = (scale / 1.2f).coerceAtLeast(0.5f) }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.ZoomOut, "Zoom Out") + } + Spacer(modifier = Modifier.height(8.dp)) + FloatingActionButton( + onClick = { + scale = 1f + offsetX = 0f + offsetY = 0f + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(48.dp) + ) { + Text("1x", fontSize = 12.sp) + } + } + } +} + +@Composable +fun YearCalendarCanvas( + year: Int, + events: List, + scale: Float, + offsetX: Float, + offsetY: Float, + modifier: Modifier = Modifier +) { + val textMeasurer = rememberTextMeasurer() + val primaryColor = MaterialTheme.colorScheme.primary + val onSurfaceColor = MaterialTheme.colorScheme.onSurface + val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant + val backgroundColor = MaterialTheme.colorScheme.background + + // Group events by date for efficient lookup + val eventsByDate = remember(events) { + events.groupBy { it.startDate } + } + + Canvas( + modifier = modifier + .background(backgroundColor) + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ) + ) { + val monthWidth = size.width / 12f + val dayHeight = 30.dp.toPx() + val headerHeight = 40.dp.toPx() + + // Draw each month as a vertical column + for (month in 1..12) { + val yearMonth = YearMonth.of(year, month) + val daysInMonth = yearMonth.lengthOfMonth() + val monthName = yearMonth.month.getDisplayName(JavaTextStyle.SHORT, Locale.getDefault()) + val x = (month - 1) * monthWidth + + // Draw month header + drawRect( + color = primaryColor, + topLeft = Offset(x, 0f), + size = Size(monthWidth, headerHeight) + ) + + drawText( + textMeasurer = textMeasurer, + text = monthName, + topLeft = Offset(x + monthWidth / 2 - 20.dp.toPx(), 10.dp.toPx()), + style = TextStyle( + color = Color.White, + fontSize = 14.sp + ) + ) + + // Draw days + for (day in 1..daysInMonth) { + val date = LocalDate.of(year, month, day) + val y = headerHeight + (day - 1) * dayHeight + val isWeekend = date.dayOfWeek.value >= 6 + val isToday = date == LocalDate.now() + + // Day background + drawRect( + color = when { + isToday -> primaryColor.copy(alpha = 0.3f) + isWeekend -> surfaceVariantColor.copy(alpha = 0.5f) + else -> Color.Transparent + }, + topLeft = Offset(x, y), + size = Size(monthWidth, dayHeight) + ) + + // Day border + drawLine( + color = onSurfaceColor.copy(alpha = 0.1f), + start = Offset(x, y), + end = Offset(x + monthWidth, y), + strokeWidth = 1.dp.toPx() + ) + + // Vertical border + if (month < 12) { + drawLine( + color = onSurfaceColor.copy(alpha = 0.2f), + start = Offset(x + monthWidth, y), + end = Offset(x + monthWidth, y + dayHeight), + strokeWidth = 1.dp.toPx() + ) + } + + // Day number + drawText( + textMeasurer = textMeasurer, + text = day.toString(), + topLeft = Offset(x + 4.dp.toPx(), y + 4.dp.toPx()), + style = TextStyle( + color = if (isToday) primaryColor else onSurfaceColor, + fontSize = 12.sp + ) + ) + + // Draw events for this day + val dayEvents = eventsByDate[date] ?: emptyList() + if (dayEvents.isNotEmpty()) { + val eventIndicatorSize = 6.dp.toPx() + val maxIndicators = ((monthWidth - 30.dp.toPx()) / (eventIndicatorSize + 2.dp.toPx())).toInt() + val eventsToShow = min(dayEvents.size, maxIndicators) + + for (i in 0 until eventsToShow) { + val event = dayEvents[i] + val eventColor = parseEventColor(event.color) ?: primaryColor + val indicatorX = x + 25.dp.toPx() + i * (eventIndicatorSize + 2.dp.toPx()) + val indicatorY = y + 8.dp.toPx() + + drawCircle( + color = eventColor, + radius = eventIndicatorSize / 2, + center = Offset(indicatorX, indicatorY) + ) + } + + // Show "+X more" if there are more events + if (dayEvents.size > maxIndicators) { + val moreText = "+${dayEvents.size - maxIndicators}" + drawText( + textMeasurer = textMeasurer, + text = moreText, + topLeft = Offset(x + monthWidth - 20.dp.toPx(), y + 4.dp.toPx()), + style = TextStyle( + color = onSurfaceColor.copy(alpha = 0.6f), + fontSize = 8.sp + ) + ) + } + } + } + } + } +} + +private fun parseEventColor(colorString: String?): Color? { + if (colorString == null) return null + return try { + if (colorString.startsWith("#")) { + Color(android.graphics.Color.parseColor(colorString)) + } else { + // Handle numeric color IDs from Google Calendar + when (colorString) { + "1" -> Color(0xFFAC725E) + "2" -> Color(0xFFD06B64) + "3" -> Color(0xFFF83A22) + "4" -> Color(0xFFFA573C) + "5" -> Color(0xFFFF6B08) + "6" -> Color(0xFFFFB878) + "7" -> Color(0xFFFBD75B) + "8" -> Color(0xFFFBF475) + "9" -> Color(0xFFDBDB77) + "10" -> Color(0xFF51B749) + "11" -> Color(0xFF42D692) + else -> null + } + } + } catch (e: Exception) { + null + } +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/CalendarScreen.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/CalendarScreen.kt new file mode 100644 index 0000000..3fec23b --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/CalendarScreen.kt @@ -0,0 +1,185 @@ +package com.yearcalendar.app.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.yearcalendar.app.R +import com.yearcalendar.app.data.CalendarUiState +import com.yearcalendar.app.ui.components.YearCalendarView +import com.yearcalendar.app.viewmodel.CalendarViewModel +import java.time.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarScreen( + viewModel: CalendarViewModel, + onSignOut: () -> Unit +) { + val calendarState by viewModel.calendarState.collectAsState() + val currentYear by viewModel.currentYear.collectAsState() + var showYearPicker by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + TextButton(onClick = { showYearPicker = true }) { + Text( + text = currentYear.toString(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Change year", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ), + actions = { + IconButton(onClick = { viewModel.refreshEvents() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Sign Out") }, + onClick = { + showMenu = false + onSignOut() + }, + leadingIcon = { + Icon(Icons.Default.ExitToApp, contentDescription = null) + } + ) + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when (val state = calendarState) { + is CalendarUiState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + is CalendarUiState.Success -> { + YearCalendarView( + year = currentYear, + events = state.events, + modifier = Modifier.fillMaxSize() + ) + } + is CalendarUiState.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.refreshEvents() }) { + Text(stringResource(R.string.retry)) + } + } + } + } + } + } + + if (showYearPicker) { + YearPickerDialog( + currentYear = currentYear, + onYearSelected = { year -> + viewModel.changeYear(year) + showYearPicker = false + }, + onDismiss = { showYearPicker = false } + ) + } +} + +@Composable +fun YearPickerDialog( + currentYear: Int, + onYearSelected: (Int) -> Unit, + onDismiss: () -> Unit +) { + var selectedYear by remember { mutableStateOf(currentYear) } + val thisYear = LocalDate.now().year + val years = (thisYear - 5..thisYear + 5).toList() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Select Year") }, + text = { + Column { + years.forEach { year -> + TextButton( + onClick = { selectedYear = year }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = year.toString(), + style = if (year == selectedYear) { + MaterialTheme.typography.titleMedium + } else { + MaterialTheme.typography.bodyLarge + }, + color = if (year == selectedYear) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onYearSelected(selectedYear) }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/SignInScreen.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/SignInScreen.kt new file mode 100644 index 0000000..58c427e --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/screens/SignInScreen.kt @@ -0,0 +1,77 @@ +package com.yearcalendar.app.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.yearcalendar.app.R + +@Composable +fun SignInScreen( + onSignInClick: () -> Unit, + isLoading: Boolean, + errorMessage: String? +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Year Calendar", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "View your Google Calendar in a beautiful year view", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(48.dp)) + + if (isLoading) { + CircularProgressIndicator() + } else { + Button( + onClick = onSignInClick, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = !isLoading + ) { + Text(text = stringResource(R.string.sign_in)) + } + } + + if (errorMessage != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Secure OAuth authentication via Google", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + textAlign = TextAlign.Center + ) + } +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Theme.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Theme.kt new file mode 100644 index 0000000..75e50bf --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Theme.kt @@ -0,0 +1,46 @@ +package com.yearcalendar.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF6200EE), + secondary = Color(0xFF03DAC5), + tertiary = Color(0xFF3700B3), + background = Color(0xFF121212), + surface = Color(0xFF1E1E1E), + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.White, + onSurface = Color.White, +) + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF6200EE), + secondary = Color(0xFF03DAC5), + tertiary = Color(0xFF3700B3), + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), +) + +@Composable +fun YearCalendarTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Type.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Type.kt new file mode 100644 index 0000000..5fc3ad2 --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/ui/theme/Type.kt @@ -0,0 +1,31 @@ +package com.yearcalendar.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/YearCalendar/app/src/main/java/com/yearcalendar/app/viewmodel/CalendarViewModel.kt b/YearCalendar/app/src/main/java/com/yearcalendar/app/viewmodel/CalendarViewModel.kt new file mode 100644 index 0000000..50c1a3d --- /dev/null +++ b/YearCalendar/app/src/main/java/com/yearcalendar/app/viewmodel/CalendarViewModel.kt @@ -0,0 +1,115 @@ +package com.yearcalendar.app.viewmodel + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yearcalendar.app.auth.GoogleAuthManager +import com.yearcalendar.app.auth.TokenManager +import com.yearcalendar.app.calendar.GoogleCalendarService +import com.yearcalendar.app.data.AuthState +import com.yearcalendar.app.data.CalendarUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class CalendarViewModel @Inject constructor( + private val authManager: GoogleAuthManager, + private val tokenManager: TokenManager, + private val calendarService: GoogleCalendarService +) : ViewModel() { + + private val _authState = MutableStateFlow(AuthState.Loading) + val authState: StateFlow = _authState.asStateFlow() + + private val _calendarState = MutableStateFlow(CalendarUiState.Loading) + val calendarState: StateFlow = _calendarState.asStateFlow() + + private val _currentYear = MutableStateFlow(LocalDate.now().year) + val currentYear: StateFlow = _currentYear.asStateFlow() + + init { + checkAuthStatus() + } + + private fun checkAuthStatus() { + viewModelScope.launch { + try { + val account = authManager.getLastSignedInAccount() + if (account != null && authManager.hasCalendarPermission()) { + _authState.value = AuthState.Authenticated(account.email ?: "Unknown") + loadCalendarEvents(_currentYear.value) + } else { + _authState.value = AuthState.NotAuthenticated + } + } catch (e: Exception) { + _authState.value = AuthState.Error(e.message ?: "Unknown error") + } + } + } + + fun handleSignInResult(data: Intent?) { + viewModelScope.launch { + try { + val account = authManager.handleSignInResult(data) + if (account != null) { + // Save auth code securely + account.serverAuthCode?.let { code -> + tokenManager.saveAuthCode(code) + } + _authState.value = AuthState.Authenticated(account.email ?: "Unknown") + loadCalendarEvents(_currentYear.value) + } else { + _authState.value = AuthState.Error("Failed to sign in") + } + } catch (e: Exception) { + _authState.value = AuthState.Error(e.message ?: "Sign in failed") + } + } + } + + fun signOut() { + viewModelScope.launch { + try { + authManager.signOut() + tokenManager.clearTokens() + calendarService.clearService() + _authState.value = AuthState.NotAuthenticated + _calendarState.value = CalendarUiState.Loading + } catch (e: Exception) { + _authState.value = AuthState.Error(e.message ?: "Sign out failed") + } + } + } + + fun loadCalendarEvents(year: Int) { + viewModelScope.launch { + try { + _calendarState.value = CalendarUiState.Loading + _currentYear.value = year + + val calendars = calendarService.getCalendars() + val events = calendarService.getEventsForYear(year) + + _calendarState.value = CalendarUiState.Success(events, calendars) + } catch (e: Exception) { + e.printStackTrace() + _calendarState.value = CalendarUiState.Error( + e.message ?: "Failed to load events" + ) + } + } + } + + fun changeYear(year: Int) { + loadCalendarEvents(year) + } + + fun refreshEvents() { + loadCalendarEvents(_currentYear.value) + } +} diff --git a/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..98fd900 --- /dev/null +++ b/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.xml b/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.xml new file mode 100644 index 0000000..45e19dc --- /dev/null +++ b/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..98fd900 --- /dev/null +++ b/YearCalendar/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/YearCalendar/app/src/main/res/values/colors.xml b/YearCalendar/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ca1931b --- /dev/null +++ b/YearCalendar/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/YearCalendar/app/src/main/res/values/strings.xml b/YearCalendar/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c5211b9 --- /dev/null +++ b/YearCalendar/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Year Calendar + Sign in with Google + Sign Out + Loading… + Failed to sign in. Please try again. + Failed to load calendar events + Retry + Zoom In + Zoom Out + Reset Zoom + diff --git a/YearCalendar/app/src/main/res/values/themes.xml b/YearCalendar/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d21c54d --- /dev/null +++ b/YearCalendar/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/YearCalendar/app/src/main/res/xml/data_extraction_rules.xml b/YearCalendar/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..1b7d74b --- /dev/null +++ b/YearCalendar/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/YearCalendar/build.gradle.kts b/YearCalendar/build.gradle.kts new file mode 100644 index 0000000..705e021 --- /dev/null +++ b/YearCalendar/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file +plugins { + id("com.android.application") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.20" apply false + id("com.google.dagger.hilt.android") version "2.48" apply false +} diff --git a/YearCalendar/gradle.properties b/YearCalendar/gradle.properties new file mode 100644 index 0000000..1215ec8 --- /dev/null +++ b/YearCalendar/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.enableJetifier=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/YearCalendar/gradle/wrapper/gradle-wrapper.jar b/YearCalendar/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/YearCalendar/gradle/wrapper/gradle-wrapper.jar differ diff --git a/YearCalendar/gradle/wrapper/gradle-wrapper.properties b/YearCalendar/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..15de902 --- /dev/null +++ b/YearCalendar/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/YearCalendar/gradlew b/YearCalendar/gradlew new file mode 100755 index 0000000..aeb74cb --- /dev/null +++ b/YearCalendar/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright Β© 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β», +# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»; +# * compound commands having a testable exit status, especially Β«caseΒ»; +# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/YearCalendar/gradlew.bat b/YearCalendar/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/YearCalendar/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/YearCalendar/settings.gradle.kts b/YearCalendar/settings.gradle.kts new file mode 100644 index 0000000..1923f49 --- /dev/null +++ b/YearCalendar/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "YearCalendar" +include(":app")