commit 9faa5d88f34bda86a3f6e1ae2360b88c15a6c012 Author: Zhongwei Li Date: Sat Nov 29 17:58:23 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..6698a9c --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "sleeptrack-skills", + "description": "Collection of Asleep API integration skills covering foundation concepts, Android SDK, iOS SDK, and backend REST API integration", + "version": "0.0.0-2025.11.28", + "author": { + "name": "asleep-ai", + "email": "marcus.kyung@asleep.ai" + }, + "skills": [ + "./skills/sleeptrack-foundation", + "./skills/sleeptrack-android", + "./skills/sleeptrack-ios", + "./skills/sleeptrack-be" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7c0021 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# sleeptrack-skills + +Collection of Asleep API integration skills covering foundation concepts, Android SDK, iOS SDK, and backend REST API integration diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..92e1619 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,116 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:asleep-ai/sleeptrack-skills:sleeptrack-skills", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "319b8a0dbcb2b9f90d09d4ed7587672a9b4f324d", + "treeHash": "2cafb5edf79f84585942c2730e943fe7d45a61220e64713093af6a41ad350eef", + "generatedAt": "2025-11-28T10:13:57.298964Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "sleeptrack-skills", + "description": "Collection of Asleep API integration skills covering foundation concepts, Android SDK, iOS SDK, and backend REST API integration" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "a9acce6d7c234a78493c7b30f76ed06db87c56e8acb902a494ae05454e704b4b" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "e9ff3a3d67e31ded00d4657fcd98dbade606b6f0b626aed73e7933617aca0189" + }, + { + "path": "skills/sleeptrack-ios/SKILL.md", + "sha256": "d9780fa15b6b8ef6892d1a6c9dd37715b438fedad0bb652e8db5b66effea87be" + }, + { + "path": "skills/sleeptrack-ios/references/advanced_patterns.md", + "sha256": "ffc04cdf679179249fc6f13a764d70ed0b74a211fb152be0bf00327e5d1e0ffd" + }, + { + "path": "skills/sleeptrack-ios/references/complete_viewmodel_implementation.md", + "sha256": "f8f7488401db0cd5640b4f9844444e5614a0b2a45b81ae06b91e475434fe9263" + }, + { + "path": "skills/sleeptrack-ios/references/ios_specific_features.md", + "sha256": "cde115705877116916d096468e232a006352de341dd3efabadf34762a0ac139d" + }, + { + "path": "skills/sleeptrack-foundation/SKILL.md", + "sha256": "f7333481f2c7aed21ece7912f4139e14ce09848d5c441608003eb763fda80ab1" + }, + { + "path": "skills/sleeptrack-foundation/references/asleep_api_reference.md", + "sha256": "2be82ea3e6225357fecda0ee8c57488e38d8ad10e097e6bc598d624cd276be03" + }, + { + "path": "skills/sleeptrack-be/SKILL.md", + "sha256": "c3cb15558e0de616d192838d843d4a2a6a23e4b06e1c7e75ac053b6998588fae" + }, + { + "path": "skills/sleeptrack-be/references/rest_api_reference.md", + "sha256": "9bcd9dcbfe436e80fca056b1b270d4ad855a75f8db466a11020224221995ea80" + }, + { + "path": "skills/sleeptrack-be/references/python_client_implementation.md", + "sha256": "b7c64fb4db38ed04ab58c2a6a50c823a161744430aebddbc17ab9fde2e588ac9" + }, + { + "path": "skills/sleeptrack-be/references/nodejs_client_implementation.md", + "sha256": "38a9b5430948d844492e0013bf519c05ba7bcbdf655e601748c49628d8f6b416" + }, + { + "path": "skills/sleeptrack-be/references/production_patterns.md", + "sha256": "3aabc5a73da0dd832186d63e0ff2aa870934026930e44e308555df3383231125" + }, + { + "path": "skills/sleeptrack-be/references/webhook_implementation_guide.md", + "sha256": "961c83efd075d03d347cc9629dc46e5851c472e36ca2c506ab3b8b22e5ba40f7" + }, + { + "path": "skills/sleeptrack-be/references/webhook_reference.md", + "sha256": "7009b91f9703d22313db12432101c99a68c5cb652c8dcbae725572f1193cf707" + }, + { + "path": "skills/sleeptrack-android/SKILL.md", + "sha256": "0214d65f84442f0b0a27e79f02d4bc48400e6cbb1f8548d48bcadd4b67fd7540" + }, + { + "path": "skills/sleeptrack-android/references/android_architecture_patterns.md", + "sha256": "02b0a34d760cd26a53d8139aed851a0e733e74ee075e8f74a9a4611284425356" + }, + { + "path": "skills/sleeptrack-android/references/ui_implementation_guide.md", + "sha256": "67c9f290019eb0a3a222a7f227cb6f3c51602fc57a454c8a7de6884ab29eb514" + }, + { + "path": "skills/sleeptrack-android/references/testing_guide.md", + "sha256": "b046cdaeec378e576a70066b069021c8be54fb8e02060bf06c8fc26997107f94" + }, + { + "path": "skills/sleeptrack-android/references/complete_viewmodel_implementation.md", + "sha256": "b23e07af992481d33dc43253bcea480e5a06a13b41b1b65954d2be47c805ab6a" + }, + { + "path": "skills/sleeptrack-android/references/gradle_setup.md", + "sha256": "2ee36f3d8a3bbd458448bba0014987a32f8b00612e4189a507b8c9dfdf7f691e" + } + ], + "dirSha256": "2cafb5edf79f84585942c2730e943fe7d45a61220e64713093af6a41ad350eef" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/sleeptrack-android/SKILL.md b/skills/sleeptrack-android/SKILL.md new file mode 100644 index 0000000..bd0a439 --- /dev/null +++ b/skills/sleeptrack-android/SKILL.md @@ -0,0 +1,551 @@ +--- +name: sleeptrack-android +description: This skill helps Android developers integrate the Asleep SDK for sleep tracking functionality. Use this skill when developers ask about Android implementation, MVVM architecture patterns, permission handling, Jetpack Compose UI, Kotlin coroutines integration, or Android-specific sleep tracking features. This skill provides working code examples from the official sample app. +--- + +# Sleeptrack Android + +## Overview + +This skill provides comprehensive guidance for integrating the Asleep sleep tracking SDK into Android applications. It covers Android-specific implementation details including MVVM architecture, permission handling, state management, UI patterns, and lifecycle management. + +Use this skill when developers need to: +- Set up Asleep SDK in Android projects +- Implement sleep tracking with proper Android architecture +- Handle Android-specific permissions (microphone, notifications, battery optimization) +- Manage tracking lifecycle with state machines +- Build UI with ViewBinding or Jetpack Compose +- Handle errors and edge cases in Android environment +- Implement foreground services for background tracking + +**Prerequisites**: Review the `sleeptrack-foundation` skill first for core concepts including session lifecycle, error codes, and API fundamentals. + +## Quick Start + +### 1. Add Dependencies + +Add to your app-level `build.gradle`: + +```gradle +dependencies { + // Core dependencies + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.lifecycle:lifecycle-service:2.7.0' + + // Required for Asleep SDK + implementation 'com.squareup.okhttp3:okhttp:4.11.0' + implementation 'com.google.code.gson:gson:2.10' + implementation 'ai.asleep:asleepsdk:3.1.4' + + // Optional: Hilt for DI + implementation "com.google.dagger:hilt-android:2.48" + kapt "com.google.dagger:hilt-compiler:2.48" +} +``` + +**Minimum Requirements**: +- minSdk: 24 (Android 7.0) +- targetSdk: 34 +- Java: 17 +- Kotlin: 1.9.24+ + +See `references/gradle_setup.md` for complete Gradle configuration. + +### 2. Configure Permissions + +Add to `AndroidManifest.xml`: + +```xml + + + + + + + + + + + + + +``` + +### 3. Initialize SDK + +```kotlin +Asleep.initAsleepConfig( + context = applicationContext, + apiKey = "your_api_key_here", + userId = "unique_user_id", + baseUrl = "https://api.asleep.ai", + callbackUrl = null, // Optional webhook URL + service = "YourAppName", + asleepConfigListener = object : Asleep.AsleepConfigListener { + override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) { + // SDK initialized successfully + } + + override fun onFail(errorCode: Int, detail: String) { + Log.e("Asleep", "Init failed: $errorCode - $detail") + } + } +) +``` + +### 4. Start Sleep Tracking + +```kotlin +Asleep.beginSleepTracking( + asleepConfig = asleepConfig, + asleepTrackingListener = object : Asleep.AsleepTrackingListener { + override fun onStart(sessionId: String) { + // Tracking started successfully + } + + override fun onPerform(sequence: Int) { + // Called every ~30 seconds (1 sequence) + } + + override fun onFinish(sessionId: String?) { + // Tracking completed + } + + override fun onFail(errorCode: Int, detail: String) { + // Handle tracking errors + } + }, + notificationTitle = "Sleep Tracking Active", + notificationText = "Tap to return to app", + notificationIcon = R.drawable.ic_notification, + notificationClass = MainActivity::class.java +) +``` + +### 5. Stop Tracking + +```kotlin +Asleep.endSleepTracking() +``` + +## Android Architecture Pattern (MVVM + Hilt) + +The recommended architecture follows Android best practices with MVVM pattern, Hilt dependency injection, and proper state management. + +### State Management + +Define tracking states using sealed classes: + +```kotlin +sealed class AsleepState { + data object STATE_IDLE: AsleepState() + data object STATE_INITIALIZING : AsleepState() + data object STATE_INITIALIZED : AsleepState() + data object STATE_TRACKING_STARTING : AsleepState() + data object STATE_TRACKING_STARTED : AsleepState() + data object STATE_TRACKING_STOPPING : AsleepState() + data class STATE_ERROR(val errorCode: AsleepError) : AsleepState() +} + +data class AsleepError(val code: Int, val message: String) +``` + +### Basic ViewModel Pattern + +```kotlin +@HiltViewModel +class SleepTrackingViewModel @Inject constructor( + @ApplicationContext private val app: Application +) : ViewModel() { + + private val _trackingState = MutableStateFlow(AsleepState.STATE_IDLE) + val trackingState: StateFlow = _trackingState + + private var config: AsleepConfig? = null + + fun initializeSDK(userId: String) { + Asleep.initAsleepConfig( + context = app, + apiKey = BuildConfig.ASLEEP_API_KEY, + userId = userId, + baseUrl = "https://api.asleep.ai", + callbackUrl = null, + service = "MyApp", + asleepConfigListener = object : Asleep.AsleepConfigListener { + override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) { + config = asleepConfig + _trackingState.value = AsleepState.STATE_INITIALIZED + } + override fun onFail(errorCode: Int, detail: String) { + _trackingState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail)) + } + } + ) + } + + fun startTracking() { + config?.let { + Asleep.beginSleepTracking( + asleepConfig = it, + asleepTrackingListener = trackingListener, + notificationTitle = "Sleep Tracking", + notificationText = "Active", + notificationIcon = R.drawable.ic_notification, + notificationClass = MainActivity::class.java + ) + } + } + + fun stopTracking() { + Asleep.endSleepTracking() + } +} +``` + +**For complete production-ready ViewModel with real-time data, error handling, and lifecycle management**, see `references/complete_viewmodel_implementation.md`. + +### Basic Activity Pattern + +```kotlin +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private val viewModel: SleepTrackingViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Setup button + binding.btnTrack.setOnClickListener { + when (viewModel.trackingState.value) { + AsleepState.STATE_INITIALIZED -> viewModel.startTracking() + AsleepState.STATE_TRACKING_STARTED -> viewModel.stopTracking() + else -> {} + } + } + + // Observe state + lifecycleScope.launch { + viewModel.trackingState.collect { state -> + updateUI(state) + } + } + } +} +``` + +**For complete Activity implementation with permission handling**, see `references/complete_viewmodel_implementation.md`. + +## Permission Handling + +Android requires multiple runtime permissions for sleep tracking: + +### Required Permissions + +1. **RECORD_AUDIO**: Microphone access for sleep sound recording +2. **POST_NOTIFICATIONS**: Android 13+ notification permission +3. **Battery Optimization**: Prevent app from being killed during tracking + +### Permission Request Flow + +```kotlin +// Request permissions sequentially +when { + !hasMicrophonePermission() -> requestMicrophone() + !hasNotificationPermission() -> requestNotification() + !isBatteryOptimizationIgnored() -> requestBatteryOptimization() + else -> allPermissionsGranted() +} +``` + +### Best Practices + +1. **Request in sequence**: Request one permission at a time for better UX +2. **Show rationale**: Explain why each permission is needed before requesting +3. **Handle denial**: Provide fallback or guide users to settings +4. **Check on resume**: Re-check permissions when app resumes + +```kotlin +override fun onResume() { + super.onResume() + if (!hasRequiredPermissions() && Asleep.isSleepTrackingAlive(applicationContext)) { + handlePermissionLoss() + } +} +``` + +**For complete PermissionManager implementation**, see `references/complete_viewmodel_implementation.md`. + +## Error Handling + +Distinguish between critical errors and warnings: + +### Error Classification + +```kotlin +fun isWarning(errorCode: Int): Boolean { + return errorCode in setOf( + AsleepErrorCode.ERR_AUDIO_SILENCED, + AsleepErrorCode.ERR_UPLOAD_FAILED + ) +} + +fun handleError(error: AsleepError) { + if (isWarning(error.code)) { + // Show warning, continue tracking + Toast.makeText(context, getUserFriendlyMessage(error), Toast.LENGTH_SHORT).show() + } else { + // Critical error - stop tracking + stopTracking() + showErrorDialog(getUserFriendlyMessage(error)) + } +} +``` + +## UI Patterns + +### ViewBinding Example + +```kotlin +class TrackingFragment : Fragment() { + private var _binding: FragmentTrackingBinding? = null + private val binding get() = _binding!! + private val viewModel: SleepTrackingViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.trackingState.collect { state -> + when (state) { + AsleepState.STATE_TRACKING_STARTED -> { + binding.btnTrack.text = "Stop Tracking" + } + AsleepState.STATE_INITIALIZED -> { + binding.btnTrack.text = "Start Tracking" + } + else -> {} + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} +``` + +### Jetpack Compose Example + +```kotlin +@Composable +fun SleepTrackingScreen( + viewModel: SleepTrackingViewModel = hiltViewModel() +) { + val trackingState by viewModel.trackingState.collectAsState() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + when (trackingState) { + AsleepState.STATE_TRACKING_STARTED -> { + Button(onClick = { viewModel.stopTracking() }) { + Text("Stop Tracking") + } + } + AsleepState.STATE_INITIALIZED -> { + Button(onClick = { viewModel.startTracking() }) { + Text("Start Sleep Tracking") + } + } + is AsleepState.STATE_ERROR -> { + ErrorDisplay(error = (trackingState as AsleepState.STATE_ERROR).errorCode) + } + else -> { + CircularProgressIndicator() + } + } + } +} +``` + +**For complete UI implementations with Material3 components**, see `references/ui_implementation_guide.md`. + +## Lifecycle Management + +### Handle App Lifecycle Events + +```kotlin +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + override fun onStart() { + super.onStart() + // Reconnect to existing tracking session + if (Asleep.isSleepTrackingAlive(applicationContext)) { + viewModel.reconnectToTracking() + } + } + + override fun onStop() { + super.onStop() + // Tracking continues in background via foreground service + // No action needed + } + + override fun onDestroy() { + super.onDestroy() + // Do NOT call endSleepTracking() here + // Service continues in background + } +} +``` + +### Foreground Service + +The Asleep SDK automatically manages a foreground service during tracking. The notification keeps the service alive: + +```kotlin +Asleep.beginSleepTracking( + asleepConfig = config, + asleepTrackingListener = listener, + notificationTitle = "Sleep Tracking Active", + notificationText = "Tracking your sleep patterns", + notificationIcon = R.drawable.ic_sleep, + notificationClass = MainActivity::class.java // Tapping notification opens this +) +``` + +The service will: +- Keep the app alive during sleep tracking +- Show persistent notification +- Maintain microphone access +- Continue even if user swipes away the app + +## Real-Time Sleep Data + +Access preliminary sleep data during tracking: + +```kotlin +// In ViewModel +override fun onPerform(sequence: Int) { + // Check after sequence 10, then every 10 sequences + if (sequence > 10 && sequence % 10 == 0) { + getCurrentSleepData() + } +} + +private fun getCurrentSleepData() { + Asleep.getCurrentSleepData( + asleepSleepDataListener = object : Asleep.AsleepSleepDataListener { + override fun onSleepDataReceived(session: Session) { + val currentSleepStage = session.sleepStages?.lastOrNull() + val currentSnoringStage = session.snoringStages?.lastOrNull() + + Log.d("Sleep", "Current stage: $currentSleepStage") + } + + override fun onFail(errorCode: Int, detail: String) { + Log.e("Sleep", "Failed to get current data: $errorCode") + } + } + ) +} +``` + +**Note**: Real-time data is preliminary and may differ from final report after processing. + +## Testing + +### Unit Testing +```kotlin +class SleepTrackingViewModelTest { + @get:Rule val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `startTracking should fail if not initialized`() = runTest { + viewModel.startTracking() + assertNotEquals(AsleepState.STATE_TRACKING_STARTED, viewModel.trackingState.value) + } +} +``` + +### Integration Testing +```kotlin +@Test +fun trackingFlow_complete() { + onView(withId(R.id.btn_start_stop)).perform(click()) + onView(withId(R.id.tracking_indicator)).check(matches(isDisplayed())) +} +``` + +**For complete testing guide with Compose UI tests and test utilities**, see `references/testing_guide.md`. + +## Common Issues & Solutions + +### Tracking stops unexpectedly +**Causes**: Battery optimization, notification dismissed, permission revoked, microphone conflict + +**Solution**: Check battery optimization and permissions on resume: +```kotlin +override fun onResume() { + super.onResume() + if (!hasRequiredPermissions() && Asleep.isSleepTrackingAlive(applicationContext)) { + handlePermissionLoss() + } +} +``` + +### No real-time data available +**Cause**: Checking before sequence 10 + +**Solution**: Only call `getCurrentSleepData()` after sequence 10 + +### ERR_UPLOAD_FORBIDDEN error +**Cause**: Same user_id tracking on multiple devices + +**Solution**: Use unique user IDs per device or check for active sessions before starting + +## Resources + +This skill includes detailed reference documentation: + +- `references/complete_viewmodel_implementation.md`: Complete ViewModel, Activity, and PermissionManager implementations +- `references/ui_implementation_guide.md`: Complete ViewBinding and Jetpack Compose UI examples +- `references/testing_guide.md`: Comprehensive unit, integration, and UI testing guides +- `references/android_architecture_patterns.md`: Complete architecture examples from the official sample app +- `references/gradle_setup.md`: Comprehensive Gradle configuration including dependencies and ProGuard rules + +### Official Documentation + +- **Android Getting Started**: https://docs-en.asleep.ai/docs/android-get-started.md +- **AsleepConfig Reference**: https://docs-en.asleep.ai/docs/android-asleep-config.md +- **SleepTrackingManager**: https://docs-en.asleep.ai/docs/android-sleep-tracking-manager.md +- **Error Codes**: https://docs-en.asleep.ai/docs/android-error-codes.md + +### Android Resources + +- **Android Permissions**: https://developer.android.com/guide/topics/permissions/overview +- **Foreground Services**: https://developer.android.com/develop/background-work/services/foreground-services +- **StateFlow Guide**: https://developer.android.com/kotlin/flow/stateflow-and-sharedflow +- **Hilt Documentation**: https://developer.android.com/training/dependency-injection/hilt-android + +## Next Steps + +After implementing Android sleep tracking: + +1. **Test thoroughly**: Test on different Android versions (especially 13+ for notifications) +2. **Handle edge cases**: Low battery, airplane mode, app updates during tracking +3. **Fetch reports**: Use REST API or backend integration to retrieve sleep reports +4. **Build UI**: Create compelling visualizations of sleep data +5. **Analytics**: Track user engagement and sleep patterns + +For backend report fetching and webhook integration, use the `sleeptrack-be` skill. diff --git a/skills/sleeptrack-android/references/android_architecture_patterns.md b/skills/sleeptrack-android/references/android_architecture_patterns.md new file mode 100644 index 0000000..675ea5d --- /dev/null +++ b/skills/sleeptrack-android/references/android_architecture_patterns.md @@ -0,0 +1,364 @@ +# Android Architecture Patterns for Asleep SDK + +This document contains key architecture patterns from the Asleep Android sample app. + +## 1. Application Setup with Hilt + +```kotlin +@HiltAndroidApp +class SampleApplication : Application() { + companion object { + private lateinit var instance: SampleApplication + val ACTION_AUTO_TRACKING: String by lazy { + instance.packageName + ".ACTION_AUTO_TRACKING" + } + } + + override fun onCreate() { + super.onCreate() + instance = this + } +} +``` + +## 2. State Management with Sealed Classes + +```kotlin +sealed class AsleepState { + data object STATE_IDLE: AsleepState() + data object STATE_INITIALIZING : AsleepState() + data object STATE_INITIALIZED : AsleepState() + data object STATE_TRACKING_STARTING : AsleepState() + data object STATE_TRACKING_STARTED : AsleepState() + data object STATE_TRACKING_STOPPING : AsleepState() + data class STATE_ERROR(val errorCode: AsleepError) : AsleepState() +} +``` + +## 3. ViewModel with Asleep Integration + +```kotlin +@HiltViewModel +class AsleepViewModel @Inject constructor( + @ApplicationContext private val applicationContext: Application +) : ViewModel() { + + // State management + private var _asleepState = MutableStateFlow(AsleepState.STATE_IDLE) + val asleepState: StateFlow get() = _asleepState + + // User and session data + private var _asleepUserId = MutableLiveData(null) + val asleepUserId: LiveData get() = _asleepUserId + + private var _sessionId = MutableStateFlow(null) + val sessionId: StateFlow get() = _sessionId + + // Initialize SDK + fun initAsleepConfig() { + if (_asleepState.value != AsleepState.STATE_IDLE) return + + _asleepState.value = AsleepState.STATE_INITIALIZING + val storedUserId = PreferenceHelper.getAsleepUserId(applicationContext) + + Asleep.initAsleepConfig( + context = applicationContext, + apiKey = Constants.ASLEEP_API_KEY, + userId = storedUserId, + baseUrl = Constants.BASE_URL, + callbackUrl = Constants.CALLBACK_URL, + service = Constants.SERVICE_NAME, + asleepConfigListener = object : Asleep.AsleepConfigListener { + override fun onFail(errorCode: Int, detail: String) { + _asleepErrorCode.value = AsleepError(errorCode, detail) + _asleepState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail)) + } + + override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) { + _asleepConfig.value = asleepConfig + _asleepUserId.value = userId + userId?.let { PreferenceHelper.putAsleepUserId(applicationContext, it) } + _asleepState.value = AsleepState.STATE_INITIALIZED + } + } + ) + } + + // Tracking lifecycle + private val asleepTrackingListener = object: Asleep.AsleepTrackingListener { + override fun onStart(sessionId: String) { + _asleepState.value = AsleepState.STATE_TRACKING_STARTED + } + + override fun onPerform(sequence: Int) { + _sequence.postValue(sequence) + if (sequence > 10 && (sequence % 10 == 1 || sequence - (_analyzedSeq ?: 0) > 10)) { + getCurrentSleepData(sequence) + } + } + + override fun onFinish(sessionId: String?) { + _sessionId.value = sessionId + if (asleepState.value is AsleepState.STATE_ERROR) { + // Exit due to Error + } else { + // Successful Finish + _asleepState.value = AsleepState.STATE_IDLE + } + } + + override fun onFail(errorCode: Int, detail: String) { + handleErrorOrWarning(AsleepError(errorCode, detail)) + } + } + + fun beginSleepTracking() { + if (_asleepState.value == AsleepState.STATE_INITIALIZED) { + _asleepState.value = AsleepState.STATE_TRACKING_STARTING + _asleepConfig.value?.let { + Asleep.beginSleepTracking( + asleepConfig = it, + asleepTrackingListener = asleepTrackingListener, + notificationTitle = applicationContext.getString(R.string.app_name), + notificationText = "", + notificationIcon = R.mipmap.ic_app, + notificationClass = MainActivity::class.java + ) + } + PreferenceHelper.saveStartTrackingTime(applicationContext, System.currentTimeMillis()) + } + } + + fun endSleepTracking() { + if (Asleep.isSleepTrackingAlive(applicationContext)) { + _asleepState.value = AsleepState.STATE_TRACKING_STOPPING + Asleep.endSleepTracking() + } + } + + fun connectSleepTracking() { + Asleep.connectSleepTracking(asleepTrackingListener) + _asleepUserId.value = PreferenceHelper.getAsleepUserId(applicationContext) + _asleepState.value = AsleepState.STATE_TRACKING_STARTED + } + + // Error handling + fun handleErrorOrWarning(asleepError: AsleepError) { + val code = asleepError.code + val message = asleepError.message + if (isWarning(code)) { + // Log warning, continue tracking + _warningMessage.postValue("$existingMessage\n${getCurrentTime()} $code - $message") + } else { + // Critical error, stop tracking + _asleepErrorCode.postValue(asleepError) + _asleepState.value = AsleepState.STATE_ERROR(asleepError) + } + } +} +``` + +## 4. Activity State Handling + +```kotlin +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var permissionManager: PermissionManager + private val asleepViewModel: AsleepViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + permissionManager = PermissionManager(this) + setPermissionObserver() + permissionManager.checkAllPermissions() + + // State management with coroutines + lifecycleScope.launch { + asleepViewModel.asleepState.collect { state -> + when (state) { + AsleepState.STATE_IDLE -> { + checkRunningService() + } + AsleepState.STATE_INITIALIZING -> { + binding.btnControlTracking.text = "No user id" + binding.btnControlTracking.isEnabled = false + } + AsleepState.STATE_INITIALIZED -> { + binding.btnControlTracking.apply { + isEnabled = true + text = getString(R.string.button_text_start_tracking) + setOnClickListener { + if (permissionManager.allPermissionsGranted.value == true) { + asleepViewModel.beginSleepTracking() + } else { + permissionManager.checkAndRequestPermissions() + } + } + } + } + AsleepState.STATE_TRACKING_STARTED -> { + binding.btnControlTracking.apply { + isEnabled = true + text = getString(R.string.button_text_stop_tracking) + setOnClickListener { + if (asleepViewModel.isEnoughTrackingTime()) { + asleepViewModel.endSleepTracking() + } else { + showInsufficientTimeDialog() + } + } + } + } + is AsleepState.STATE_ERROR -> { + binding.btnControlTracking.isEnabled = false + showErrorDialog(supportFragmentManager) + } + } + } + } + } + + private fun checkRunningService() { + val isRunningService = Asleep.isSleepTrackingAlive(applicationContext) + if (isRunningService) { + asleepViewModel.connectSleepTracking() + } else { + asleepViewModel.initAsleepConfig() + } + } +} +``` + +## 5. Permission Management + +```kotlin +class PermissionManager(private val activity: AppCompatActivity) { + private val context = activity.applicationContext + + private val batteryOptimizationLauncher = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + checkAndRequestPermissions() + } + + private val micPermissionLauncher = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { + checkAndRequestPermissions() + } + + private val notificationPermissionLauncher = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { + checkAndRequestPermissions() + } + + private val _allPermissionsGranted = MutableLiveData(false) + val allPermissionsGranted: LiveData = _allPermissionsGranted + + fun checkAndRequestPermissions() { + when { + !isBatteryOptimizationIgnored() -> { + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.packageName}") + } + batteryOptimizationLauncher.launch(intent) + } + + context.checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) + != android.content.pm.PackageManager.PERMISSION_GRANTED -> { + if (shouldShowRequestPermissionRationale(activity, + android.Manifest.permission.RECORD_AUDIO)) { + showPermissionDialog() + } else { + micPermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO) + } + } + + hasNotificationPermission().not() -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + shouldShowRequestPermissionRationale(activity, + android.Manifest.permission.POST_NOTIFICATIONS)) { + showPermissionDialog() + } else { + notificationPermissionLauncher.launch( + android.Manifest.permission.POST_NOTIFICATIONS) + } + } + + else -> { + checkAllPermissions() + } + } + } + + fun checkAllPermissions() { + _batteryOptimized.value = isBatteryOptimizationIgnored() + _micPermission.value = context.checkSelfPermission( + android.Manifest.permission.RECORD_AUDIO + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + _notificationPermission.value = hasNotificationPermission() + + _allPermissionsGranted.value = + (_batteryOptimized.value == true) && + (_micPermission.value == true) && + (_notificationPermission.value == true) + } + + private fun isBatteryOptimizationIgnored(): Boolean { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val packageName = context.packageName + return powerManager.isIgnoringBatteryOptimizations(packageName) + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) + == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } + } +} +``` + +## 6. Error Handling Utilities + +```kotlin +data class AsleepError(val code: Int, val message: String) + +internal fun isWarning(errorCode: Int): Boolean { + return errorCode in setOf( + AsleepErrorCode.ERR_AUDIO_SILENCED, + AsleepErrorCode.ERR_AUDIO_UNSILENCED, + AsleepErrorCode.ERR_UPLOAD_FAILED, + ) +} + +internal fun getDebugMessage(errorCode: AsleepError): String { + if (isNetworkError(errorCode.message)) { + return "Please check your network connection." + } + if (isMethodFormatInvalid(errorCode.message)) { + return "Please check the method format, including argument values and types." + } + + return when (errorCode.code) { + AsleepErrorCode.ERR_MIC_PERMISSION -> + "The app does not have microphone access permission." + AsleepErrorCode.ERR_AUDIO -> + "Another app is using the microphone, or there is an issue with the microphone settings." + AsleepErrorCode.ERR_INVALID_URL -> + "Please check the URL format." + AsleepErrorCode.ERR_COMMON_EXPIRED -> + "The API rate limit has been exceeded, or the plan has expired." + AsleepErrorCode.ERR_UPLOAD_FORBIDDEN -> + "initAsleepConfig() was performed elsewhere with the same ID during the tracking." + AsleepErrorCode.ERR_UPLOAD_NOT_FOUND, AsleepErrorCode.ERR_CLOSE_NOT_FOUND -> + "The session has already ended." + else -> "" + } +} +``` diff --git a/skills/sleeptrack-android/references/complete_viewmodel_implementation.md b/skills/sleeptrack-android/references/complete_viewmodel_implementation.md new file mode 100644 index 0000000..cc33a34 --- /dev/null +++ b/skills/sleeptrack-android/references/complete_viewmodel_implementation.md @@ -0,0 +1,540 @@ +# Complete ViewModel and Activity Implementation + +This reference provides complete, production-ready implementations for Android MVVM architecture with the Asleep SDK. + +## Complete ViewModel with State Management + +```kotlin +@HiltViewModel +class SleepTrackingViewModel @Inject constructor( + @ApplicationContext private val applicationContext: Application +) : ViewModel() { + + // State management + private val _trackingState = MutableStateFlow(AsleepState.STATE_IDLE) + val trackingState: StateFlow = _trackingState.asStateFlow() + + private val _userId = MutableLiveData() + val userId: LiveData = _userId + + private val _sessionId = MutableStateFlow(null) + val sessionId: StateFlow = _sessionId.asStateFlow() + + private val _sequence = MutableLiveData() + val sequence: LiveData = _sequence + + private var asleepConfig: AsleepConfig? = null + + // Tracking listener + private val trackingListener = object : Asleep.AsleepTrackingListener { + override fun onStart(sessionId: String) { + _sessionId.value = sessionId + _trackingState.value = AsleepState.STATE_TRACKING_STARTED + } + + override fun onPerform(sequence: Int) { + _sequence.postValue(sequence) + + // Check real-time data every 10 sequences after sequence 10 + if (sequence > 10 && sequence % 10 == 0) { + getCurrentSleepData() + } + } + + override fun onFinish(sessionId: String?) { + _sessionId.value = sessionId + _trackingState.value = AsleepState.STATE_IDLE + } + + override fun onFail(errorCode: Int, detail: String) { + handleError(AsleepError(errorCode, detail)) + } + } + + fun initializeSDK(userId: String) { + if (_trackingState.value != AsleepState.STATE_IDLE) return + + _trackingState.value = AsleepState.STATE_INITIALIZING + + Asleep.initAsleepConfig( + context = applicationContext, + apiKey = BuildConfig.ASLEEP_API_KEY, + userId = userId, + baseUrl = "https://api.asleep.ai", + callbackUrl = null, + service = applicationContext.getString(R.string.app_name), + asleepConfigListener = object : Asleep.AsleepConfigListener { + override fun onSuccess(userId: String?, config: AsleepConfig?) { + asleepConfig = config + _userId.value = userId + _trackingState.value = AsleepState.STATE_INITIALIZED + } + + override fun onFail(errorCode: Int, detail: String) { + _trackingState.value = AsleepState.STATE_ERROR( + AsleepError(errorCode, detail) + ) + } + } + ) + } + + fun startTracking() { + val config = asleepConfig ?: return + if (_trackingState.value != AsleepState.STATE_INITIALIZED) return + + _trackingState.value = AsleepState.STATE_TRACKING_STARTING + + Asleep.beginSleepTracking( + asleepConfig = config, + asleepTrackingListener = trackingListener, + notificationTitle = "Sleep Tracking", + notificationText = "Recording your sleep", + notificationIcon = R.drawable.ic_notification, + notificationClass = MainActivity::class.java + ) + } + + fun stopTracking() { + if (Asleep.isSleepTrackingAlive(applicationContext)) { + _trackingState.value = AsleepState.STATE_TRACKING_STOPPING + Asleep.endSleepTracking() + } + } + + fun reconnectToTracking() { + if (Asleep.isSleepTrackingAlive(applicationContext)) { + Asleep.connectSleepTracking(trackingListener) + _trackingState.value = AsleepState.STATE_TRACKING_STARTED + } + } + + private fun getCurrentSleepData() { + Asleep.getCurrentSleepData( + asleepSleepDataListener = object : Asleep.AsleepSleepDataListener { + override fun onSleepDataReceived(session: Session) { + // Process real-time sleep data + val currentStage = session.sleepStages?.lastOrNull() + Log.d("Sleep", "Current stage: $currentStage") + } + + override fun onFail(errorCode: Int, detail: String) { + Log.e("Sleep", "Failed to get data: $errorCode") + } + } + ) + } + + private fun handleError(error: AsleepError) { + if (isWarning(error.code)) { + // Log warning but continue tracking + Log.w("Sleep", "Warning: ${error.code} - ${error.message}") + } else { + // Critical error - stop tracking + _trackingState.value = AsleepState.STATE_ERROR(error) + } + } +} +``` + +## Complete Activity Implementation + +```kotlin +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private val viewModel: SleepTrackingViewModel by viewModels() + private lateinit var permissionManager: PermissionManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + permissionManager = PermissionManager(this) + + setupUI() + observeState() + checkExistingSession() + } + + private fun setupUI() { + binding.btnStartStop.setOnClickListener { + when (viewModel.trackingState.value) { + AsleepState.STATE_INITIALIZED -> { + if (permissionManager.allPermissionsGranted.value == true) { + viewModel.startTracking() + } else { + permissionManager.requestPermissions() + } + } + AsleepState.STATE_TRACKING_STARTED -> { + viewModel.stopTracking() + } + else -> { + // Button disabled + } + } + } + } + + private fun observeState() { + lifecycleScope.launch { + viewModel.trackingState.collect { state -> + updateUI(state) + } + } + + viewModel.sequence.observe(this) { sequence -> + binding.tvSequence.text = "Sequence: $sequence" + } + } + + private fun updateUI(state: AsleepState) { + when (state) { + AsleepState.STATE_IDLE -> { + binding.btnStartStop.isEnabled = false + binding.btnStartStop.text = "Initializing..." + } + AsleepState.STATE_INITIALIZED -> { + binding.btnStartStop.isEnabled = true + binding.btnStartStop.text = "Start Tracking" + } + AsleepState.STATE_TRACKING_STARTED -> { + binding.btnStartStop.isEnabled = true + binding.btnStartStop.text = "Stop Tracking" + binding.trackingIndicator.visibility = View.VISIBLE + } + AsleepState.STATE_TRACKING_STOPPING -> { + binding.btnStartStop.isEnabled = false + binding.btnStartStop.text = "Stopping..." + } + is AsleepState.STATE_ERROR -> { + showErrorDialog(state.errorCode) + } + else -> {} + } + } + + private fun checkExistingSession() { + if (Asleep.isSleepTrackingAlive(applicationContext)) { + viewModel.reconnectToTracking() + } else { + viewModel.initializeSDK(getUserId()) + } + } + + private fun getUserId(): String { + // Load from SharedPreferences or generate + val prefs = getSharedPreferences("asleep", MODE_PRIVATE) + return prefs.getString("user_id", null) ?: run { + val newId = UUID.randomUUID().toString() + prefs.edit().putString("user_id", newId).apply() + newId + } + } +} +``` + +## Complete PermissionManager + +```kotlin +class PermissionManager(private val activity: AppCompatActivity) { + private val context = activity.applicationContext + + // Permission launchers + private val batteryOptimizationLauncher = + activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + checkAndRequestNext() + } + + private val micPermissionLauncher = + activity.registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) checkAndRequestNext() + else showPermissionDeniedDialog("Microphone") + } + + private val notificationPermissionLauncher = + activity.registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) checkAndRequestNext() + else showPermissionDeniedDialog("Notifications") + } + + private val _allPermissionsGranted = MutableLiveData(false) + val allPermissionsGranted: LiveData = _allPermissionsGranted + + fun requestPermissions() { + checkAndRequestNext() + } + + private fun checkAndRequestNext() { + when { + !isBatteryOptimizationIgnored() -> requestBatteryOptimization() + !hasMicrophonePermission() -> requestMicrophone() + !hasNotificationPermission() -> requestNotification() + else -> { + _allPermissionsGranted.value = true + } + } + } + + private fun requestBatteryOptimization() { + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.packageName}") + } + batteryOptimizationLauncher.launch(intent) + } + + private fun requestMicrophone() { + if (ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.RECORD_AUDIO + )) { + showPermissionRationale( + "Microphone access is required to record sleep sounds" + ) + } else { + micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + + private fun requestNotification() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch( + Manifest.permission.POST_NOTIFICATIONS + ) + } else { + checkAndRequestNext() + } + } + + private fun isBatteryOptimizationIgnored(): Boolean { + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return pm.isIgnoringBatteryOptimizations(context.packageName) + } + + private fun hasMicrophonePermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } + } + + private fun showPermissionRationale(message: String) { + AlertDialog.Builder(activity) + .setTitle("Permission Required") + .setMessage(message) + .setPositiveButton("OK") { _, _ -> + checkAndRequestNext() + } + .show() + } + + private fun showPermissionDeniedDialog(permissionName: String) { + AlertDialog.Builder(activity) + .setTitle("Permission Denied") + .setMessage("$permissionName permission is required for sleep tracking. Please grant it in app settings.") + .setPositiveButton("Settings") { _, _ -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${context.packageName}") + } + activity.startActivity(intent) + } + .setNegativeButton("Cancel", null) + .show() + } +} +``` + +## Minimal Production Templates + +### Minimal ViewModel + +```kotlin +@HiltViewModel +class SleepTrackingViewModel @Inject constructor( + @ApplicationContext private val app: Application +) : ViewModel() { + + private val _trackingState = MutableStateFlow(AsleepState.STATE_IDLE) + val trackingState: StateFlow = _trackingState + + private var config: AsleepConfig? = null + + private val listener = object : Asleep.AsleepTrackingListener { + override fun onStart(sessionId: String) { + _trackingState.value = AsleepState.STATE_TRACKING_STARTED + } + override fun onPerform(sequence: Int) {} + override fun onFinish(sessionId: String?) { + _trackingState.value = AsleepState.STATE_IDLE + } + override fun onFail(errorCode: Int, detail: String) { + _trackingState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail)) + } + } + + fun initializeSDK(userId: String) { + Asleep.initAsleepConfig( + context = app, + apiKey = BuildConfig.ASLEEP_API_KEY, + userId = userId, + baseUrl = "https://api.asleep.ai", + callbackUrl = null, + service = "MyApp", + asleepConfigListener = object : Asleep.AsleepConfigListener { + override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) { + config = asleepConfig + _trackingState.value = AsleepState.STATE_INITIALIZED + } + override fun onFail(errorCode: Int, detail: String) { + _trackingState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail)) + } + } + ) + } + + fun startTracking() { + config?.let { + Asleep.beginSleepTracking( + asleepConfig = it, + asleepTrackingListener = listener, + notificationTitle = "Sleep Tracking", + notificationText = "Active", + notificationIcon = R.drawable.ic_notification, + notificationClass = SleepActivity::class.java + ) + } + } + + fun stopTracking() { + Asleep.endSleepTracking() + } +} +``` + +### Minimal Activity + +```kotlin +@AndroidEntryPoint +class SleepActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySleepBinding + private val viewModel: SleepTrackingViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySleepBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Initialize SDK + viewModel.initializeSDK(loadUserId()) + + // Setup button + binding.btnTrack.setOnClickListener { + when (viewModel.trackingState.value) { + AsleepState.STATE_INITIALIZED -> viewModel.startTracking() + AsleepState.STATE_TRACKING_STARTED -> viewModel.stopTracking() + else -> {} + } + } + + // Observe state + lifecycleScope.launch { + viewModel.trackingState.collect { state -> + binding.btnTrack.text = when (state) { + AsleepState.STATE_INITIALIZED -> "Start" + AsleepState.STATE_TRACKING_STARTED -> "Stop" + else -> "Loading..." + } + } + } + } + + private fun loadUserId(): String { + val prefs = getSharedPreferences("app", MODE_PRIVATE) + return prefs.getString("user_id", null) ?: UUID.randomUUID().toString().also { + prefs.edit().putString("user_id", it).apply() + } + } +} +``` + +## State Management Pattern + +Define states using sealed classes: + +```kotlin +sealed class AsleepState { + data object STATE_IDLE: AsleepState() + data object STATE_INITIALIZING : AsleepState() + data object STATE_INITIALIZED : AsleepState() + data object STATE_TRACKING_STARTING : AsleepState() + data object STATE_TRACKING_STARTED : AsleepState() + data object STATE_TRACKING_STOPPING : AsleepState() + data class STATE_ERROR(val errorCode: AsleepError) : AsleepState() +} + +data class AsleepError(val code: Int, val message: String) +``` + +## Real-Time Data Access + +```kotlin +// In ViewModel +private var lastCheckedSequence = 0 + +private val trackingListener = object : Asleep.AsleepTrackingListener { + override fun onPerform(sequence: Int) { + _sequence.postValue(sequence) + + // Check after sequence 10, then every 10 sequences + if (sequence > 10 && sequence - lastCheckedSequence >= 10) { + getCurrentSleepData(sequence) + } + } + // ... other callbacks +} + +private fun getCurrentSleepData(sequence: Int) { + Asleep.getCurrentSleepData( + asleepSleepDataListener = object : Asleep.AsleepSleepDataListener { + override fun onSleepDataReceived(session: Session) { + lastCheckedSequence = sequence + + // Extract current data + val currentSleepStage = session.sleepStages?.lastOrNull() + val currentSnoringStage = session.snoringStages?.lastOrNull() + + _currentSleepStage.postValue(currentSleepStage) + + Log.d("Sleep", "Current stage: $currentSleepStage") + Log.d("Sleep", "Snoring: $currentSnoringStage") + } + + override fun onFail(errorCode: Int, detail: String) { + Log.e("Sleep", "Failed to get current data: $errorCode") + } + } + ) +} +``` diff --git a/skills/sleeptrack-android/references/gradle_setup.md b/skills/sleeptrack-android/references/gradle_setup.md new file mode 100644 index 0000000..acfd3ec --- /dev/null +++ b/skills/sleeptrack-android/references/gradle_setup.md @@ -0,0 +1,146 @@ +# Gradle Setup for Asleep SDK + +## Project-level build.gradle + +```gradle +buildscript { + dependencies { + classpath 'com.google.dagger:hilt-android-gradle-plugin:2.48' + } +} + +plugins { + id 'com.android.application' version '8.2.2' apply false + id 'com.android.library' version '8.2.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.24' apply false + id 'com.google.dagger.hilt.android' version '2.48' apply false +} +``` + +## App-level build.gradle + +```gradle +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + namespace 'com.example.yourapp' + compileSdk 34 + + defaultConfig { + applicationId "com.example.yourapp" + minSdk 24 // Minimum SDK 24 required for Asleep SDK + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + // Store API key securely in local.properties + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + buildConfigField "String", "ASLEEP_API_KEY", properties['asleep_api_key'] + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + viewBinding true + buildConfig = true + } +} + +dependencies { + // Core Android dependencies + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + // Hilt for dependency injection + implementation "com.google.dagger:hilt-android:2.48" + kapt "com.google.dagger:hilt-compiler:2.48" + + // Activity and Fragment KTX + implementation 'androidx.activity:activity-ktx:1.8.2' + implementation 'androidx.fragment:fragment-ktx:1.6.2' + implementation 'androidx.lifecycle:lifecycle-service:2.7.0' + + // Required for Asleep SDK + implementation 'com.squareup.okhttp3:okhttp:4.11.0' + debugImplementation 'com.squareup.okhttp3:logging-interceptor:4.10.0' + implementation 'com.google.code.gson:gson:2.10' + + // Asleep SDK (check for latest version) + implementation 'ai.asleep:asleepsdk:3.1.4' + + // Testing + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} +``` + +## local.properties + +```properties +# Store your Asleep API key securely (never commit this file to version control) +asleep_api_key="your_api_key_here" +``` + +## Key Requirements + +1. **Minimum SDK**: 24 (Android 7.0) +2. **Target SDK**: 34 recommended +3. **Java Version**: 17 +4. **Kotlin Version**: 1.9.24 or higher +5. **Gradle Plugin**: 8.2.2 or higher + +## Dependency Notes + +- **OkHttp**: Required for network operations +- **Gson**: Required for JSON parsing +- **Hilt**: Recommended for dependency injection (not strictly required) +- **ViewBinding**: Recommended for type-safe view access + +## ProGuard Rules + +If using ProGuard/R8, add these rules to `proguard-rules.pro`: + +```proguard +# Asleep SDK +-keep class ai.asleep.asleepsdk.** { *; } +-dontwarn ai.asleep.asleepsdk.** + +# Gson +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** +-keep class * implements com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase +``` diff --git a/skills/sleeptrack-android/references/testing_guide.md b/skills/sleeptrack-android/references/testing_guide.md new file mode 100644 index 0000000..cae8798 --- /dev/null +++ b/skills/sleeptrack-android/references/testing_guide.md @@ -0,0 +1,523 @@ +# Testing Guide for Android Sleep Tracking + +This guide provides comprehensive testing patterns for Android sleep tracking applications. + +## Unit Testing + +### ViewModel Testing Setup + +```kotlin +@ExperimentalCoroutinesApi +class SleepTrackingViewModelTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var viewModel: SleepTrackingViewModel + private lateinit var mockContext: Application + + @Before + fun setup() { + mockContext = mock(Application::class.java) + viewModel = SleepTrackingViewModel(mockContext) + } + + @Test + fun `initializeSDK should transition to INITIALIZED on success`() = runTest { + // Given + val userId = "test_user_123" + + // When + viewModel.initializeSDK(userId) + + // Simulate success callback + // (requires mocking Asleep SDK) + + // Then + assertEquals(AsleepState.STATE_INITIALIZED, viewModel.trackingState.value) + assertEquals(userId, viewModel.userId.value) + } + + @Test + fun `startTracking should fail if not initialized`() = runTest { + // When + viewModel.startTracking() + + // Then + assertNotEquals(AsleepState.STATE_TRACKING_STARTED, viewModel.trackingState.value) + } + + @Test + fun `stopTracking should transition state correctly`() = runTest { + // Given + viewModel.initializeSDK("test_user") + // Simulate initialization success + viewModel.startTracking() + // Simulate tracking started + + // When + viewModel.stopTracking() + + // Then + assertEquals(AsleepState.STATE_TRACKING_STOPPING, viewModel.trackingState.value) + } + + @Test + fun `error handling should classify warnings correctly`() = runTest { + // Given + val warningError = AsleepError( + AsleepErrorCode.ERR_AUDIO_SILENCED, + "Microphone temporarily unavailable" + ) + + // When + val isWarning = isWarning(warningError.code) + + // Then + assertTrue(isWarning) + } +} +``` + +### MainDispatcherRule for Coroutines + +```kotlin +@ExperimentalCoroutinesApi +class MainDispatcherRule( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} +``` + +### Testing StateFlow + +```kotlin +@Test +fun `trackingState should emit correct states during tracking lifecycle`() = runTest { + // Given + val states = mutableListOf() + val job = launch { + viewModel.trackingState.collect { state -> + states.add(state) + } + } + + // When + viewModel.initializeSDK("test_user") + advanceUntilIdle() + + // Then + assertTrue(states.contains(AsleepState.STATE_INITIALIZING)) + assertTrue(states.contains(AsleepState.STATE_INITIALIZED)) + + job.cancel() +} +``` + +## Integration Testing + +### Activity Testing + +```kotlin +@RunWith(AndroidJUnit4::class) +class SleepTrackingIntegrationTest { + + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + @Test + fun trackingFlow_complete() { + // Check permissions granted + onView(withId(R.id.btn_start_stop)) + .check(matches(isEnabled())) + + // Start tracking + onView(withId(R.id.btn_start_stop)) + .perform(click()) + + // Verify tracking started + onView(withId(R.id.tracking_indicator)) + .check(matches(isDisplayed())) + + // Wait for sequences + Thread.sleep(60000) // 1 minute + + // Stop tracking + onView(withId(R.id.btn_start_stop)) + .perform(click()) + + // Verify stopped + onView(withId(R.id.tracking_indicator)) + .check(matches(not(isDisplayed()))) + } + + @Test + fun errorDisplay_showsCorrectMessage() { + // Simulate error state + // Verify error message displayed + onView(withId(R.id.error_text)) + .check(matches(isDisplayed())) + .check(matches(withText(containsString("Microphone permission")))) + } +} +``` + +### Fragment Testing + +```kotlin +@RunWith(AndroidJUnit4::class) +class TrackingFragmentTest { + + @Test + fun fragmentLaunch_displaysCorrectUI() { + // Launch fragment in container + launchFragmentInContainer( + themeResId = R.style.Theme_SleepTracking + ) + + // Verify initial UI state + onView(withId(R.id.btnTrack)) + .check(matches(isDisplayed())) + .check(matches(withText("Start Tracking"))) + } + + @Test + fun clickStartButton_requestsPermissions() { + launchFragmentInContainer() + + // Click start button + onView(withId(R.id.btnTrack)) + .perform(click()) + + // Verify permission dialog or state change + // This depends on permission state + } +} +``` + +## Compose UI Testing + +### Basic Compose Testing + +```kotlin +@RunWith(AndroidJUnit4::class) +class SleepTrackingScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun initialState_showsStartButton() { + // Given + val viewModel = mockViewModel(AsleepState.STATE_INITIALIZED) + + // When + composeTestRule.setContent { + SleepTrackingScreen(viewModel = viewModel) + } + + // Then + composeTestRule.onNodeWithText("Start Sleep Tracking") + .assertIsDisplayed() + } + + @Test + fun trackingActive_showsStopButton() { + // Given + val viewModel = mockViewModel(AsleepState.STATE_TRACKING_STARTED) + + // When + composeTestRule.setContent { + SleepTrackingScreen(viewModel = viewModel) + } + + // Then + composeTestRule.onNodeWithText("Stop Tracking") + .assertIsDisplayed() + composeTestRule.onNodeWithText("Tracking in progress") + .assertIsDisplayed() + } + + @Test + fun errorState_displaysErrorMessage() { + // Given + val error = AsleepError(AsleepErrorCode.ERR_MIC_PERMISSION, "Permission denied") + val viewModel = mockViewModel(AsleepState.STATE_ERROR(error)) + + // When + composeTestRule.setContent { + SleepTrackingScreen(viewModel = viewModel) + } + + // Then + composeTestRule.onNodeWithText("Microphone permission is required") + .assertIsDisplayed() + } + + private fun mockViewModel(state: AsleepState): SleepTrackingViewModel { + return mock(SleepTrackingViewModel::class.java).apply { + whenever(trackingState).thenReturn(MutableStateFlow(state)) + whenever(sequence).thenReturn(MutableLiveData(0)) + } + } +} +``` + +### Testing User Interactions + +```kotlin +@Test +fun clickStartButton_startsTracking() { + // Given + val viewModel = SleepTrackingViewModel(mockContext) + composeTestRule.setContent { + SleepTrackingScreen(viewModel = viewModel) + } + + // When + composeTestRule.onNodeWithText("Start Sleep Tracking") + .performClick() + + // Then + verify(viewModel).startTracking() +} + +@Test +fun clickStopButton_stopsTracking() { + // Given + val viewModel = mockViewModel(AsleepState.STATE_TRACKING_STARTED) + composeTestRule.setContent { + SleepTrackingScreen(viewModel = viewModel) + } + + // When + composeTestRule.onNodeWithText("Stop Tracking") + .performClick() + + // Then + verify(viewModel).stopTracking() +} +``` + +## Permission Testing + +### Testing Permission Manager + +```kotlin +@RunWith(AndroidJUnit4::class) +class PermissionManagerTest { + + private lateinit var activity: AppCompatActivity + private lateinit var permissionManager: PermissionManager + + @Before + fun setup() { + val scenario = ActivityScenario.launch(TestActivity::class.java) + scenario.onActivity { act -> + activity = act + permissionManager = PermissionManager(activity) + } + } + + @Test + fun requestPermissions_checksAllPermissions() { + // When + permissionManager.requestPermissions() + + // Then + // Verify permission checks called + verify(permissionManager).isBatteryOptimizationIgnored() + verify(permissionManager).hasMicrophonePermission() + } + + @Test + fun allPermissionsGranted_updatesLiveData() { + // Given + // Mock all permissions as granted + + // When + permissionManager.checkAndRequestNext() + + // Then + assertTrue(permissionManager.allPermissionsGranted.value == true) + } +} +``` + +## Instrumentation Testing + +### Testing Foreground Service + +```kotlin +@RunWith(AndroidJUnit4::class) +class ForegroundServiceTest { + + @get:Rule + val serviceRule = ServiceTestRule() + + @Test + fun trackingService_startsForeground() { + // Given + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // When + val serviceIntent = Intent(context, SleepTrackingService::class.java) + serviceRule.startService(serviceIntent) + + // Then + // Verify service is in foreground + assertTrue(isServiceForeground(context, SleepTrackingService::class.java)) + } + + private fun isServiceForeground(context: Context, serviceClass: Class<*>): Boolean { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + return manager.getRunningServices(Integer.MAX_VALUE) + .any { it.service.className == serviceClass.name && it.foreground } + } +} +``` + +## Mock SDK Testing + +### Creating SDK Mocks + +```kotlin +class MockAsleepSDK { + companion object { + fun mockInitSuccess(userId: String, listener: Asleep.AsleepConfigListener) { + val config = mock(AsleepConfig::class.java) + listener.onSuccess(userId, config) + } + + fun mockInitFail(errorCode: Int, listener: Asleep.AsleepConfigListener) { + listener.onFail(errorCode, "Test error") + } + + fun mockTrackingStart(sessionId: String, listener: Asleep.AsleepTrackingListener) { + listener.onStart(sessionId) + } + + fun mockTrackingPerform(sequence: Int, listener: Asleep.AsleepTrackingListener) { + listener.onPerform(sequence) + } + } +} +``` + +### Using Mocks in Tests + +```kotlin +@Test +fun initializeSDK_successFlow() = runTest { + // Given + val userId = "test_user" + + // Mock Asleep SDK + MockAsleepSDK.mockInitSuccess(userId, any()) + + // When + viewModel.initializeSDK(userId) + advanceUntilIdle() + + // Then + assertEquals(AsleepState.STATE_INITIALIZED, viewModel.trackingState.value) +} +``` + +## Test Dependencies + +Add to your `build.gradle`: + +```gradle +dependencies { + // Unit testing + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.3.1' + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.0.0' + testImplementation 'androidx.arch.core:core-testing:2.2.0' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' + + // Android instrumentation testing + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' + + // Fragment testing + debugImplementation 'androidx.fragment:fragment-testing:1.6.1' + + // Compose testing + androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.5.4' + debugImplementation 'androidx.compose.ui:ui-test-manifest:1.5.4' +} +``` + +## Testing Best Practices + +1. **Unit Tests**: Test ViewModels, business logic, and state management +2. **Integration Tests**: Test UI interactions and component integration +3. **Use Test Rules**: Leverage JUnit rules for setup/teardown +4. **Mock External Dependencies**: Mock Asleep SDK calls for predictable tests +5. **Test Coroutines**: Use `runTest` and `TestDispatcher` for coroutine testing +6. **Test Permissions**: Verify permission flows but avoid actual permission dialogs +7. **Compose Tests**: Use semantic properties and avoid hardcoded strings +8. **CI/CD**: Run tests in continuous integration pipeline + +## Debugging Tests + +### Enable Debug Logging + +```kotlin +@Before +fun setup() { + // Enable debug logging for tests + Log.setDebug(true) +} +``` + +### Capture Screenshots on Failure + +```kotlin +@Rule +fun testRule = TestRule { base, description -> + object : Statement() { + override fun evaluate() { + try { + base.evaluate() + } catch (t: Throwable) { + // Capture screenshot + Screenshot.capture() + throw t + } + } + } +} +``` + +## Test Coverage + +Aim for: +- **Unit Tests**: 80%+ coverage of ViewModels and business logic +- **Integration Tests**: Key user flows and state transitions +- **UI Tests**: Critical user interactions + +Use JaCoCo for coverage reporting: + +```gradle +apply plugin: 'jacoco' + +jacoco { + toolVersion = "0.8.10" +} +``` diff --git a/skills/sleeptrack-android/references/ui_implementation_guide.md b/skills/sleeptrack-android/references/ui_implementation_guide.md new file mode 100644 index 0000000..336a082 --- /dev/null +++ b/skills/sleeptrack-android/references/ui_implementation_guide.md @@ -0,0 +1,524 @@ +# UI Implementation Guide + +This guide provides complete UI implementations for Android sleep tracking with both ViewBinding and Jetpack Compose. + +## ViewBinding Implementation + +### Complete Fragment with ViewBinding + +```kotlin +class TrackingFragment : Fragment() { + private var _binding: FragmentTrackingBinding? = null + private val binding get() = _binding!! + private val viewModel: SleepTrackingViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentTrackingBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.btnTrack.setOnClickListener { + viewModel.startTracking() + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.trackingState.collect { state -> + when (state) { + AsleepState.STATE_TRACKING_STARTED -> { + binding.progressIndicator.visibility = View.VISIBLE + binding.btnTrack.text = "Stop Tracking" + } + AsleepState.STATE_INITIALIZED -> { + binding.progressIndicator.visibility = View.GONE + binding.btnTrack.text = "Start Tracking" + } + else -> {} + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} +``` + +### Layout XML (fragment_tracking.xml) + +```xml + + + + + + + +