Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:58:23 +08:00
commit 9faa5d88f3
22 changed files with 9600 additions and 0 deletions

View File

@@ -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
<manifest>
<!-- Essential permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Battery optimization -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
```
### 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>(AsleepState.STATE_IDLE)
val trackingState: StateFlow<AsleepState> = _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.

View File

@@ -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>(AsleepState.STATE_IDLE)
val asleepState: StateFlow<AsleepState> get() = _asleepState
// User and session data
private var _asleepUserId = MutableLiveData<String?>(null)
val asleepUserId: LiveData<String?> get() = _asleepUserId
private var _sessionId = MutableStateFlow<String?>(null)
val sessionId: StateFlow<String?> 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<Boolean> = _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 -> ""
}
}
```

View File

@@ -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>(AsleepState.STATE_IDLE)
val trackingState: StateFlow<AsleepState> = _trackingState.asStateFlow()
private val _userId = MutableLiveData<String?>()
val userId: LiveData<String?> = _userId
private val _sessionId = MutableStateFlow<String?>(null)
val sessionId: StateFlow<String?> = _sessionId.asStateFlow()
private val _sequence = MutableLiveData<Int>()
val sequence: LiveData<Int> = _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<Boolean> = _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>(AsleepState.STATE_IDLE)
val trackingState: StateFlow<AsleepState> = _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")
}
}
)
}
```

View File

@@ -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
```

View File

@@ -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<AsleepState>()
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<TrackingFragment>(
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<TrackingFragment>()
// 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"
}
```

View File

@@ -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
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<ProgressBar
android:id="@+id/progressIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/btnTrack" />
<TextView
android:id="@+id/tvSequence"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sequence: 0"
android:textSize="18sp"
app:layout_constraintTop_toBottomOf="@id/progressIndicator"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/btnTrack" />
<Button
android:id="@+id/btnTrack"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Start Tracking"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
```
## Jetpack Compose Implementation
### Complete Tracking Screen
```kotlin
@Composable
fun SleepTrackingScreen(
viewModel: SleepTrackingViewModel = hiltViewModel()
) {
val trackingState by viewModel.trackingState.collectAsState()
val sequence by viewModel.sequence.observeAsState(0)
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (trackingState) {
AsleepState.STATE_TRACKING_STARTED -> {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Tracking in progress")
Text("Sequence: $sequence")
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = { viewModel.stopTracking() }) {
Text("Stop Tracking")
}
}
AsleepState.STATE_INITIALIZED -> {
Button(onClick = { viewModel.startTracking() }) {
Text("Start Sleep Tracking")
}
}
is AsleepState.STATE_ERROR -> {
val error = (trackingState as AsleepState.STATE_ERROR).errorCode
ErrorDisplay(error = error)
}
else -> {
CircularProgressIndicator()
Text("Initializing...")
}
}
}
}
@Composable
fun ErrorDisplay(error: AsleepError) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Error",
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = getUserFriendlyMessage(error),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
}
```
### Advanced Compose UI with Sleep Stages
```kotlin
@Composable
fun DetailedTrackingScreen(
viewModel: SleepTrackingViewModel = hiltViewModel()
) {
val trackingState by viewModel.trackingState.collectAsState()
val sequence by viewModel.sequence.observeAsState(0)
val sessionId by viewModel.sessionId.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Sleep Tracking") }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
when (trackingState) {
AsleepState.STATE_TRACKING_STARTED -> {
TrackingActiveContent(
sequence = sequence,
sessionId = sessionId,
onStop = { viewModel.stopTracking() }
)
}
AsleepState.STATE_INITIALIZED -> {
TrackingIdleContent(
onStart = { viewModel.startTracking() }
)
}
else -> {
LoadingContent()
}
}
}
}
}
@Composable
fun TrackingActiveContent(
sequence: Int,
sessionId: String?,
onStop: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
// Status card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Tracking Active",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Session: ${sessionId?.take(8)}...",
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Progress indicator
Box(
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(120.dp),
strokeWidth = 8.dp
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "$sequence",
style = MaterialTheme.typography.headlineLarge
)
Text(
text = "sequences",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Duration estimate
val minutes = sequence / 2
Text(
text = "Approximately $minutes minutes",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.weight(1f))
// Stop button
Button(
onClick = onStop,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Stop Tracking")
}
}
}
@Composable
fun TrackingIdleContent(onStart: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.NightsStay,
contentDescription = "Sleep",
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Ready to Track Sleep",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Make sure you're in a quiet environment",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth(0.8f)
) {
Text("Start Tracking")
}
}
}
@Composable
fun LoadingContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Initializing SDK...")
}
}
}
```
## Material Design 3 Theming
```kotlin
// Theme setup for Compose
@Composable
fun SleepTrackingTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) {
darkColorScheme(
primary = Color(0xFF6200EE),
secondary = Color(0xFF03DAC6),
error = Color(0xFFCF6679)
)
} else {
lightColorScheme(
primary = Color(0xFF6200EE),
secondary = Color(0xFF03DAC6),
error = Color(0xFFB00020)
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
```
## Custom Views (XML)
### CircularProgressView
```kotlin
class CircularProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0
private val paint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 20f
color = Color.BLUE
isAntiAlias = true
}
fun setProgress(value: Int) {
progress = value
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerX = width / 2f
val centerY = height / 2f
val radius = (min(width, height) / 2f) - 20f
// Draw circle
canvas.drawCircle(centerX, centerY, radius, paint)
// Draw progress arc
val rectF = RectF(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius
)
paint.style = Paint.Style.FILL
canvas.drawArc(rectF, -90f, (progress * 360f / 100f), true, paint)
}
}
```
## Permission UI Patterns
### Permission Request Dialog (Compose)
```kotlin
@Composable
fun PermissionRequestDialog(
permissionName: String,
rationale: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Permission Required") },
text = { Text(rationale) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Grant Permission")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
```
### Using with Accompanist Permissions
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionScreen(
onPermissionsGranted: () -> Unit
) {
val micPermissionState = rememberPermissionState(
Manifest.permission.RECORD_AUDIO
)
when {
micPermissionState.status.isGranted -> {
onPermissionsGranted()
}
micPermissionState.status.shouldShowRationale -> {
PermissionRequestDialog(
permissionName = "Microphone",
rationale = "Microphone access is required to record sleep sounds",
onConfirm = { micPermissionState.launchPermissionRequest() },
onDismiss = { /* Handle dismissal */ }
)
}
else -> {
Button(onClick = { micPermissionState.launchPermissionRequest() }) {
Text("Grant Microphone Permission")
}
}
}
}
```
## Navigation Integration
### Jetpack Navigation with Compose
```kotlin
@Composable
fun SleepTrackingNavHost(
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = "tracking"
) {
composable("tracking") {
SleepTrackingScreen(
onSessionComplete = { sessionId ->
navController.navigate("results/$sessionId")
}
)
}
composable(
"results/{sessionId}",
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) { backStackEntry ->
ResultsScreen(
sessionId = backStackEntry.arguments?.getString("sessionId")
)
}
}
}
```
## Best Practices
1. **ViewBinding**: Always nullify binding in `onDestroyView()` for fragments
2. **Compose**: Use `collectAsState()` for StateFlow and `observeAsState()` for LiveData
3. **State Management**: Handle all possible states in UI, including loading and error states
4. **Accessibility**: Add content descriptions for all interactive elements
5. **Dark Mode**: Support both light and dark themes
6. **Orientation**: Handle configuration changes with ViewModels
7. **Touch Targets**: Ensure buttons are at least 48dp in size