Files
gh-asleep-ai-sleeptrack-ski…/skills/sleeptrack-android/references/android_architecture_patterns.md
2025-11-29 17:58:23 +08:00

13 KiB

Android Architecture Patterns for Asleep SDK

This document contains key architecture patterns from the Asleep Android sample app.

1. Application Setup with Hilt

@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

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

@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

@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

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

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 -> ""
    }
}