diff --git a/app/src/debug/kotlin/io/homeassistant/companion/android/developer/DemoExoPlayerActivity.kt b/app/src/debug/kotlin/io/homeassistant/companion/android/developer/DemoExoPlayerActivity.kt index 6d23302b494..c0864488c38 100644 --- a/app/src/debug/kotlin/io/homeassistant/companion/android/developer/DemoExoPlayerActivity.kt +++ b/app/src/debug/kotlin/io/homeassistant/companion/android/developer/DemoExoPlayerActivity.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -32,6 +33,7 @@ import androidx.media3.common.Player import androidx.media3.datasource.DataSource import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.util.SdkVersion +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.common.util.initializePlayer import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme import io.homeassistant.companion.android.util.compose.media.player.HAMediaPlayer @@ -47,19 +49,26 @@ import kotlinx.coroutines.launch @AndroidEntryPoint class DemoExoPlayerActivity : AppCompatActivity() { @Inject - lateinit var dataSourceFactory: DataSource.Factory + lateinit var dataSourceFactoryProvider: SuspendProvider override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { + val dataSourceFactory by produceState(initialValue = null, dataSourceFactoryProvider) { + value = dataSourceFactoryProvider() + } HomeAssistantAppTheme { Box(modifier = Modifier.fillMaxSize()) { - HAMediaPlayer( - "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", - dataSourceFactory = dataSourceFactory, - modifier = Modifier.size(width = 428.dp, height = 192.dp).align(Alignment.Center), - ) + dataSourceFactory?.let { + HAMediaPlayer( + "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8", + dataSourceFactory = it, + modifier = Modifier + .size(width = 428.dp, height = 192.dp) + .align(Alignment.Center), + ) + } } } } diff --git a/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepository.kt b/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepository.kt index 30058292d33..bfc74b705d6 100644 --- a/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepository.kt +++ b/app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepository.kt @@ -10,6 +10,7 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities. import io.homeassistant.companion.android.common.data.integration.impl.entities.Template import io.homeassistant.companion.android.common.data.servers.tryOnUrls import io.homeassistant.companion.android.common.util.FailFast +import io.homeassistant.companion.android.common.util.di.SuspendProvider import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonPrimitive @@ -62,8 +63,8 @@ data class WearServer( * credentials without requiring a persisted server in the app's database. */ class SettingsWearRepository @Inject constructor( - private val authenticationService: AuthenticationService, - private val integrationService: IntegrationService, + private val authenticationServiceProvider: SuspendProvider, + private val integrationServiceProvider: SuspendProvider, ) { /** @@ -77,7 +78,7 @@ class SettingsWearRepository @Inject constructor( */ suspend fun registerRefreshToken(server: WearServer, refreshToken: String): WearServer { return tryOnUrls(server.getBaseUrls(), "refresh_token") { - val response = authenticationService.refreshToken( + val response = authenticationServiceProvider().refreshToken( it.newBuilder().addPathSegments(SEGMENT_AUTH_TOKEN).build(), AuthenticationService.GRANT_TYPE_REFRESH, refreshToken, @@ -108,7 +109,7 @@ class SettingsWearRepository @Inject constructor( wearServer.getWebhookUrls(), "render_template", ) { url -> - integrationService.getTemplate( + integrationServiceProvider().getTemplate( url, RenderTemplateIntegrationRequest( mapOf("template" to Template(template, emptyMap())), @@ -136,7 +137,7 @@ class SettingsWearRepository @Inject constructor( return try { tryOnUrls(wearServer.getBaseUrls(), "get_entities") { url -> - integrationService.getStates( + integrationServiceProvider().getStates( url.newBuilder().addPathSegments("api/states").build(), "Bearer ${wearServer.accessToken}", ) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt b/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt index 9c33feca3bb..25f62ce4f7a 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -15,7 +15,6 @@ import android.webkit.WebView import androidx.core.content.ContextCompat import androidx.webkit.WebViewCompat import coil3.ImageLoader -import coil3.PlatformContext import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import dagger.hilt.android.HiltAndroidApp @@ -27,6 +26,7 @@ import io.homeassistant.companion.android.common.sensors.LastUpdateManager import io.homeassistant.companion.android.common.util.HAStrictMode import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.common.util.configureComposeDiagnosticStackTrace +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.common.util.isAutomotive import io.homeassistant.companion.android.database.sensor.SensorDao import io.homeassistant.companion.android.database.settings.SensorUpdateFrequencySetting @@ -55,9 +55,7 @@ import okhttp3.OkHttpClient import timber.log.Timber @HiltAndroidApp -open class HomeAssistantApplication : - Application(), - SingletonImageLoader.Factory { +open class HomeAssistantApplication : Application() { private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job()) @@ -69,7 +67,7 @@ open class HomeAssistantApplication : lateinit var keyChainRepository: KeyChainRepository @Inject - lateinit var okHttpClient: OkHttpClient + lateinit var okHttpClientProvider: SuspendProvider @Inject lateinit var languagesManager: LanguagesManager @@ -108,6 +106,19 @@ open class HomeAssistantApplication : prefsRepository.isCrashReporting(), ) initCrashSaving(applicationContext) + val okHttpClient = okHttpClientProvider() + + SingletonImageLoader.setSafe { + ImageLoader.Builder(this@HomeAssistantApplication) + .components { + add( + OkHttpNetworkFetcherFactory( + callFactory = okHttpClient, + ), + ) + } + .build() + } configureWebViewDebugging(enabled = BuildConfig.DEBUG || prefsRepository.isWebViewDebugEnabled()) @@ -370,16 +381,6 @@ open class HomeAssistantApplication : } } - override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) - .components { - add( - OkHttpNetworkFetcherFactory( - callFactory = okHttpClient, - ), - ) - } - .build() - /** * Enables WebView contents debugging and logs the current WebView package. * diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/exoplayer/FrontendExoPlayerManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/exoplayer/FrontendExoPlayerManager.kt index 95f6174a19f..19139e974d3 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/exoplayer/FrontendExoPlayerManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/exoplayer/FrontendExoPlayerManager.kt @@ -11,6 +11,7 @@ import androidx.media3.common.VideoSize import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.ExoPlayer import dagger.hilt.android.qualifiers.ApplicationContext +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.common.util.initializePlayer import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent import java.io.Closeable @@ -35,9 +36,12 @@ class FrontendExoPlayerManager @VisibleForTesting constructor( ) : Closeable { @Inject - constructor(@ApplicationContext context: Context, dataSourceFactory: DataSource.Factory) : this( + constructor( + @ApplicationContext context: Context, + dataSourceFactoryProvider: SuspendProvider, + ) : this( { configure -> - initializePlayer(context, dataSourceFactory).apply(configure) + initializePlayer(context, dataSourceFactoryProvider()).apply(configure) }, ) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt index a2d336958f1..95aa7dfb400 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt @@ -69,6 +69,7 @@ import io.homeassistant.companion.android.common.notifications.prepareText import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.common.util.cancelGroupIfNeeded import io.homeassistant.companion.android.common.util.createSystemAppSettingsIntent +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.common.util.getActiveNotification import io.homeassistant.companion.android.common.util.isAutomotive import io.homeassistant.companion.android.common.util.kotlinJsonMapper @@ -118,7 +119,7 @@ import timber.log.Timber class MessagingManager @Inject constructor( @ApplicationContext val context: Context, - private val okHttpClient: OkHttpClient, + private val okHttpClientProvider: SuspendProvider, private val serverManager: ServerManager, private val prefsRepository: PrefsRepository, private val notificationDao: NotificationDao, @@ -1393,9 +1394,9 @@ class MessagingManager @Inject constructor( } }.build() - val response = okHttpClient.newCall(request).execute() - image = BitmapFactory.decodeStream(response.body?.byteStream()) - response.close() + okHttpClientProvider().newCall(request).execute().use { + image = BitmapFactory.decodeStream(it.body.byteStream()) + } } catch (e: Exception) { Timber.e(e, "Couldn't download image for notification") } @@ -1429,11 +1430,10 @@ class MessagingManager @Inject constructor( } }.build() - val response = okHttpClient.newCall(request).execute() - val bytes = response.body?.bytes() ?: return@withContext null - file.writeBytes(bytes) - - response.close() + okHttpClientProvider().newCall(request).execute().use { + val bytes = it.body.bytes() + file.writeBytes(bytes) + } } catch (e: Exception) { Timber.e(e, "Couldn't download image for notification") } @@ -1506,15 +1506,15 @@ class MessagingManager @Inject constructor( ) } }.build() - val response = okHttpClient.newCall(request).execute() - if (!videoFile.exists()) { videoFile.parentFile?.mkdirs() videoFile.createNewFile() } - response.body.source().use { source -> - videoFile.sink().use { sink -> - source.readAll(sink) + okHttpClientProvider().newCall(request).execute().use { + it.body.source().use { source -> + videoFile.sink().use { sink -> + source.readAll(sink) + } } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt index 1177e01643a..6daaf6a6975 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt @@ -6,7 +6,6 @@ import android.os.strictmode.DiskWriteViolation import android.os.strictmode.IncorrectContextUseViolation import android.os.strictmode.Violation import androidx.annotation.RequiresApi -import io.homeassistant.companion.android.common.data.HomeAssistantApis import io.homeassistant.companion.android.common.util.IgnoreViolationRule val vmPolicyIgnoredViolationRules = listOf( @@ -29,7 +28,6 @@ val threadPolicyIgnoredViolationRules = listOf( IgnoreMiuiTurboSchedMonitorDiskRead, IgnoreChromiumKeyStoreDiskWrite, IgnoreAppCompatPersistLocalesDiskReadWrite, - IgnoreConfigureOkHttpClientDiskRead, ) /** @@ -329,21 +327,3 @@ private data object IgnoreChromiumKeyStoreDiskWrite : IgnoreViolationRule { } } } - -/** - * Ignores a [DiskReadViolation] raised while [HomeAssistantApis] configures its OkHttpClient: - * building the TLSHelper reads CA keystores from disk on some devices. - * - * Solved by https://github.com/home-assistant/android/pull/7042 - */ -private data object IgnoreConfigureOkHttpClientDiskRead : IgnoreViolationRule { - @RequiresApi(Build.VERSION_CODES.P) - override fun shouldIgnore(violation: Violation): Boolean { - if (violation !is DiskReadViolation) return false - - return violation.stackTrace.any { - it.className == HomeAssistantApis::class.java.name && - it.methodName == "configureOkHttpClient" - } - } -} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index 32acf2abf69..308af635b50 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -100,6 +100,7 @@ import io.homeassistant.companion.android.common.util.FailFast import io.homeassistant.companion.android.common.util.GestureAction import io.homeassistant.companion.android.common.util.GestureDirection import io.homeassistant.companion.android.common.util.SdkVersion +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.common.util.getBooleanOrElse import io.homeassistant.companion.android.common.util.getBooleanOrNull import io.homeassistant.companion.android.common.util.getDoubleOrElse @@ -283,7 +284,7 @@ class WebViewActivity : lateinit var checkLocationDisabled: CheckLocationDisabledUseCase @Inject - lateinit var dataSourceFactory: DataSource.Factory + lateinit var dataSourceFactoryProvider: SuspendProvider private lateinit var webView: WebView private var loadedUrl: Uri? = null @@ -1424,7 +1425,7 @@ class WebViewActivity : val uri = payload?.getStringOrNull("url")?.toUri() ?: return val isMuted = payload.getBooleanOrElse("muted", false) lifecycleScope.launch { - exoPlayer.value = initializePlayer(this@WebViewActivity, dataSourceFactory).apply { + exoPlayer.value = initializePlayer(this@WebViewActivity, dataSourceFactoryProvider()).apply { setMediaItem(MediaItem.fromUri(uri)) playWhenReady = true addListener( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidget.kt index 3d14271ff05..0881ec2802e 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidget.kt @@ -38,7 +38,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import okhttp3.OkHttpClient import timber.log.Timber @AndroidEntryPoint @@ -57,9 +56,6 @@ class CameraWidget : AppWidgetProvider() { @Inject lateinit var cameraWidgetDao: CameraWidgetDao - @Inject - lateinit var okHttpClient: OkHttpClient - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { // There may be multiple widgets active, so update all of them appWidgetIds.forEach { appWidgetId -> diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/BaseOnboardingNavigationTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/BaseOnboardingNavigationTest.kt index 824b797af4f..6736f47fb47 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/BaseOnboardingNavigationTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/BaseOnboardingNavigationTest.kt @@ -1,6 +1,8 @@ package io.homeassistant.companion.android.onboarding +import android.content.Context import android.content.pm.PackageManager +import android.provider.Settings import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.activity.result.ActivityResultRegistry import androidx.activity.result.ActivityResultRegistryOwner @@ -13,6 +15,7 @@ import androidx.navigation.NavController import androidx.navigation.compose.ComposeNavigator import androidx.navigation.compose.NavHost import androidx.navigation.testing.TestNavHostController +import androidx.test.core.app.ApplicationProvider import dagger.hilt.android.testing.HiltAndroidRule import io.homeassistant.companion.android.HiltComponentActivity import io.homeassistant.companion.android.util.FakePermissionResultRegistry @@ -26,6 +29,10 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule +// Robolectric leaves Settings.Secure.ANDROID_ID null, but the integration graph injects it as a +// non-null @NamedDeviceId, so we seed a value to avoid a null-from-@Provides crash during DI. +private const val FAKE_ANDROID_ID = "robolectric-android-id" + /** * Base class for onboarding navigation tests providing shared setup, mocks, and utilities. * @@ -46,6 +53,11 @@ internal abstract class BaseOnboardingNavigationTest { @Before fun baseSetup() { + Settings.Secure.putString( + ApplicationProvider.getApplicationContext().contentResolver, + Settings.Secure.ANDROID_ID, + FAKE_ANDROID_ID, + ) mockkStatic(NavController::navigateToUri) coEvery { any().navigateToUri(any(), any()) } just Runs } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt index 49235791657..55a0aa76376 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt @@ -1,6 +1,8 @@ package io.homeassistant.companion.android.onboarding +import android.content.Context import android.net.Uri +import android.provider.Settings import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.activity.result.ActivityResultRegistry import androidx.activity.result.ActivityResultRegistryOwner @@ -26,6 +28,7 @@ import androidx.navigation.compose.ComposeNavigator import androidx.navigation.compose.NavHost import androidx.navigation.testing.TestNavHostController import androidx.navigation.toRoute +import androidx.test.core.app.ApplicationProvider import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -86,6 +89,10 @@ import org.robolectric.annotation.Config private const val WEAR_NAME = "super_ha_wear" private const val VALID_PASSWORD = "1234" +// Robolectric leaves Settings.Secure.ANDROID_ID null, but the integration graph injects it as a +// non-null @NamedDeviceId, so we seed a value to avoid a null-from-@Provides crash during DI. +private const val FAKE_ANDROID_ID = "robolectric-android-id" + @RunWith(RobolectricTestRunner::class) @Config(application = HiltTestApplication::class) @UninstallModules(ServerDiscoveryModule::class) @@ -152,6 +159,11 @@ internal class WearOnboardingNavigationTest { @Before fun setup() { + Settings.Secure.putString( + ApplicationProvider.getApplicationContext().contentResolver, + Settings.Secure.ANDROID_ID, + FAKE_ANDROID_ID, + ) mockkStatic(NavController::navigateToUri) coEvery { any().navigateToUri(any(), any()) } just Runs } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepositoryTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepositoryTest.kt index 534b2f63faa..f7304cffa0e 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepositoryTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/settings/wear/SettingsWearRepositoryTest.kt @@ -30,7 +30,7 @@ class SettingsWearRepositoryTest { private val authenticationService: AuthenticationService = mockk() private val integrationService: IntegrationService = mockk() - private val repository = SettingsWearRepository(authenticationService, integrationService) + private val repository = SettingsWearRepository({ authenticationService }, { integrationService }) private fun createWearServer( externalUrl: String = "https://ha.local:8123", diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt index 27d4e74ce51..9cdc075a76b 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt @@ -11,6 +11,10 @@ import io.homeassistant.companion.android.di.OkHttpConfigurator import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -40,7 +44,38 @@ class HomeAssistantApis @Inject constructor( private const val READ_TIMEOUT = 30L } - private fun configureOkHttpClient(builder: OkHttpClient.Builder): OkHttpClient.Builder { + private val okHttpMutex = Mutex() + private val retrofitMutex = Mutex() + + @Volatile + private var okHttpClient: OkHttpClient? = null + + @Volatile + private var retrofit: Retrofit? = null + + suspend fun getOkHttpClient(): OkHttpClient { + return okHttpClient ?: okHttpMutex.withLock { + okHttpClient ?: configureOkHttpClient().build().also { okHttpClient = it } + } + } + + suspend fun getRetrofit(): Retrofit { + return retrofit ?: retrofitMutex.withLock { + retrofit ?: Retrofit + .Builder() + .addConverterFactory( + kotlinJsonMapper.asConverterFactory( + "application/json; charset=UTF-8".toMediaType(), + ), + ) + .client(getOkHttpClient()) + .baseUrl(LOCAL_HOST) + .build().also { retrofit = it } + } + } + + private suspend fun configureOkHttpClient(): OkHttpClient.Builder = withContext(Dispatchers.Default) { + val builder = OkHttpClient.Builder() if (BuildConfig.DEBUG) { builder.addInterceptor( HttpLoggingInterceptor().apply { @@ -79,21 +114,6 @@ class HomeAssistantApis @Inject constructor( it(builder) } - return builder - } - - val okHttpClient by lazy { configureOkHttpClient(OkHttpClient.Builder()).build() } - - val retrofit: Retrofit by lazy { - Retrofit - .Builder() - .addConverterFactory( - kotlinJsonMapper.asConverterFactory( - "application/json; charset=UTF-8".toMediaType(), - ), - ) - .client(okHttpClient) - .baseUrl(LOCAL_HOST) - .build() + return@withContext builder } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt index 3786d6976a8..a70da7d7ab4 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt @@ -21,6 +21,8 @@ import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509ExtendedKeyManager import javax.net.ssl.X509ExtendedTrustManager import javax.net.ssl.X509TrustManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import timber.log.Timber @@ -45,11 +47,13 @@ class TLSHelper @Inject constructor( * them, the handshake also falls back to a trust manager holding only the user-installed CAs * whenever the default rejects a certificate (see [withUserInstalledCaFallback]). This relies on * the app opting into user CAs via `` in `network_security_config.xml`. + * + * Runs on [Dispatchers.IO] because it may read keystores from disk. */ - fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) { + suspend fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) = withContext(Dispatchers.IO) { val platformTrustManager = defaultX509TrustManager() ?: run { FailFast.fail { "No default X509 trust manager available" } - return + return@withContext } val handshakeTrustManager = withUserInstalledCaFallback(platformTrustManager) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt index 4238c418603..ed89376d936 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt @@ -1,12 +1,17 @@ package io.homeassistant.companion.android.common.data.authentication -import dagger.assisted.AssistedFactory +import io.homeassistant.companion.android.common.data.LocalStorage import io.homeassistant.companion.android.common.data.authentication.impl.AuthenticationRepositoryImpl +import io.homeassistant.companion.android.common.data.authentication.impl.AuthenticationService +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.di.SuspendProvider +import io.homeassistant.companion.android.di.qualifiers.NamedInstallId +import io.homeassistant.companion.android.di.qualifiers.NamedSessionStorage +import javax.inject.Inject +import javax.inject.Provider interface AuthenticationRepository { - suspend fun registerRefreshToken(refreshToken: String) - suspend fun retrieveExternalAuthentication(forceRefresh: Boolean): String suspend fun retrieveAccessToken(): String @@ -26,7 +31,20 @@ interface AuthenticationRepository { suspend fun isLockEnabled(): Boolean } -@AssistedFactory -internal interface AuthenticationRepositoryFactory { - fun create(serverId: Int): AuthenticationRepositoryImpl +internal class AuthenticationRepositoryFactory @Inject constructor( + private val authenticationServiceProvider: SuspendProvider, + // Use a Provider to avoid a dependency circle since serverManager needs the factory + private val serverManagerProvider: Provider, + @NamedSessionStorage private val localStorage: LocalStorage, + @NamedInstallId private val installIdProvider: SuspendProvider, +) { + suspend fun create(serverId: Int): AuthenticationRepositoryImpl { + return AuthenticationRepositoryImpl( + authenticationService = authenticationServiceProvider(), + serverManager = serverManagerProvider.get(), + serverId = serverId, + localStorage = localStorage, + installId = installIdProvider(), + ) + } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepository.kt index c6a366a4cb5..d916d44b1f8 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepository.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepository.kt @@ -15,8 +15,8 @@ import timber.log.Timber * codes. */ class ServerRegistrationRepository @Inject constructor( - private val authenticationService: AuthenticationService, - @param:NamedInstallId private val installId: SuspendProvider, + private val authenticationServiceProvider: SuspendProvider, + @param:NamedInstallId private val installIdProvider: SuspendProvider, ) { /** @@ -35,7 +35,7 @@ class ServerRegistrationRepository @Inject constructor( allowInsecureConnection: Boolean?, ): TemporaryServer? { return url.toHttpUrlOrNull()?.let { httpUrl -> - authenticationService.getToken( + authenticationServiceProvider().getToken( httpUrl.newBuilder().addPathSegments(SEGMENT_AUTH_TOKEN).build(), AuthenticationService.GRANT_TYPE_CODE, authorizationCode, @@ -53,7 +53,7 @@ class ServerRegistrationRepository @Inject constructor( refreshToken = it.refreshToken, tokenExpiration = System.currentTimeMillis() / 1000 + it.expiresIn, tokenType = it.tokenType, - installId = installId(), + installId = installIdProvider(), ), ) } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt index 5b617a96e8f..5b999c967c3 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt @@ -1,7 +1,5 @@ package io.homeassistant.companion.android.common.data.authentication.impl -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject import io.homeassistant.companion.android.common.data.LocalStorage import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository import io.homeassistant.companion.android.common.data.authentication.AuthorizationException @@ -10,22 +8,19 @@ import io.homeassistant.companion.android.common.data.authentication.impl.Authen import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.data.servers.firstUrlOrNull import io.homeassistant.companion.android.common.util.MapAnySerializer -import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.server.ServerSessionInfo -import io.homeassistant.companion.android.di.qualifiers.NamedInstallId -import io.homeassistant.companion.android.di.qualifiers.NamedSessionStorage import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import timber.log.Timber -class AuthenticationRepositoryImpl @AssistedInject constructor( +class AuthenticationRepositoryImpl internal constructor( private val authenticationService: AuthenticationService, private val serverManager: ServerManager, - @Assisted private val serverId: Int, - @NamedSessionStorage private val localStorage: LocalStorage, - @NamedInstallId private val installId: SuspendProvider, + private val serverId: Int, + private val localStorage: LocalStorage, + private val installId: String, ) : AuthenticationRepository { companion object { @@ -39,15 +34,6 @@ class AuthenticationRepositoryImpl @AssistedInject constructor( private suspend fun connectionStateProvider() = serverManager.connectionStateProvider(serverId) - override suspend fun registerRefreshToken(refreshToken: String) { - val url = connectionStateProvider().urlFlow().firstUrlOrNull()?.toHttpUrlOrNull() - if (url == null) { - Timber.e("Unable to register session with refresh token. No available URL") - return - } - refreshSessionWithToken(url, refreshToken) - } - override suspend fun retrieveExternalAuthentication(forceRefresh: Boolean): String { ensureValidSession(forceRefresh) val server = server() @@ -103,7 +89,7 @@ class AuthenticationRepositoryImpl @AssistedInject constructor( override suspend fun getSessionState(): SessionState { val server = server() return if (server.session.isComplete() && - server.session.installId == installId() && + server.session.installId == installId && server.connection.hasAtLeastOneUrl ) { SessionState.CONNECTED @@ -120,7 +106,7 @@ class AuthenticationRepositoryImpl @AssistedInject constructor( private suspend fun ensureValidSession(forceRefresh: Boolean = false) { val server = server() val url = connectionStateProvider().urlFlow().firstUrlOrNull()?.toHttpUrlOrNull() - if (!server.session.isComplete() || server.session.installId != installId() || url == null) { + if (!server.session.isComplete() || server.session.installId != installId || url == null) { Timber.e("Unable to ensure valid session.") throw AuthorizationException() } @@ -147,7 +133,7 @@ class AuthenticationRepositoryImpl @AssistedInject constructor( refreshToken = refreshToken, tokenExpiration = System.currentTimeMillis() / 1000 + refreshedToken.expiresIn, tokenType = refreshedToken.tokenType, - installId = installId(), + installId = installId, ), ), ) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/connectivity/DefaultConnectivityChecker.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/connectivity/DefaultConnectivityChecker.kt index 936b71d975c..825689d9265 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/connectivity/DefaultConnectivityChecker.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/connectivity/DefaultConnectivityChecker.kt @@ -1,6 +1,7 @@ package io.homeassistant.companion.android.common.data.connectivity import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.common.util.kotlinJsonMapper import io.homeassistant.companion.android.util.UrlUtil import io.homeassistant.companion.android.util.sensitive @@ -11,6 +12,8 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.Serializable @@ -29,12 +32,40 @@ private data class ManifestResponse(val name: String? = null) { private val CONNECTIVITY_TIMEOUT = 5.seconds +/** + * Lazily builds and caches a single [OkHttpClient]. + * + * Creation is guarded by a [Mutex] so concurrent callers share one instance instead of each building + * their own. + */ +private class OkHttpClientProvider(private val defaultOkHttpClientProvider: SuspendProvider) { + @Volatile + private var okHttpClient: OkHttpClient? = null + private val okHttpClientMutex = Mutex() + + suspend operator fun invoke(): OkHttpClient { + return okHttpClient ?: okHttpClientMutex.withLock { + okHttpClient ?: configureOkHttpClientForChecker(defaultOkHttpClientProvider()).also { okHttpClient = it } + } + } + + /** + * Preconfigures the provided [OkHttpClient] with timeouts for connectivity testing. + */ + private fun configureOkHttpClientForChecker(client: OkHttpClient): OkHttpClient = client.newBuilder() + .connectTimeout(CONNECTIVITY_TIMEOUT) + .readTimeout(CONNECTIVITY_TIMEOUT) + .build() +} + /** * Default implementation of [ConnectivityChecker] that performs real network operations. */ -internal class DefaultConnectivityChecker @Inject constructor(defaultOkHttpClient: OkHttpClient) : ConnectivityChecker { +internal class DefaultConnectivityChecker @Inject constructor( + defaultOkHttpClientProvider: SuspendProvider, +) : ConnectivityChecker { - private val okHttpClient by lazy { configureOkHttpClientForChecker(defaultOkHttpClient) } + private val okHttpClientProvider = OkHttpClientProvider(defaultOkHttpClientProvider) override suspend fun dns(hostname: String): ConnectivityCheckResult = withContext(Dispatchers.IO) { try { @@ -71,7 +102,7 @@ internal class DefaultConnectivityChecker @Inject constructor(defaultOkHttpClien .url(url) .head() // Don't get the body as we are only checking TLS .build() - okHttpClient.newCall(request).execute().use { response -> + okHttpClientProvider().newCall(request).execute().use { response -> val handshake = response.handshake if (handshake != null) { Timber.d("TLS check success for ${sensitive(url)} with ${handshake.tlsVersion}") @@ -94,7 +125,7 @@ internal class DefaultConnectivityChecker @Inject constructor(defaultOkHttpClien val request = Request.Builder() .url(url) .build() - okHttpClient.newCall(request).execute().use { + okHttpClientProvider().newCall(request).execute().use { ConnectivityCheckResult.Success(commonR.string.connection_check_server_success) } } catch (e: CancellationException) { @@ -110,7 +141,7 @@ internal class DefaultConnectivityChecker @Inject constructor(defaultOkHttpClien val request = Request.Builder() .url("${UrlUtil.extractBaseUrl(url)}manifest.json") .build() - okHttpClient.newCall(request).execute().use { response -> + okHttpClientProvider().newCall(request).execute().use { response -> val responseText = response.body.string() val manifest = kotlinJsonMapper.decodeFromString(responseText) @@ -128,12 +159,4 @@ internal class DefaultConnectivityChecker @Inject constructor(defaultOkHttpClien ConnectivityCheckResult.Failure(commonR.string.connection_check_error_not_home_assistant) } } - - /** - * Preconfigures the provided [OkHttpClient] with timeouts for connectivity testing. - * */ - private fun configureOkHttpClientForChecker(client: OkHttpClient): OkHttpClient = client.newBuilder() - .connectTimeout(CONNECTIVITY_TIMEOUT) - .readTimeout(CONNECTIVITY_TIMEOUT) - .build() } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt index 326615c35f2..741cc343975 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt @@ -1,10 +1,20 @@ package io.homeassistant.companion.android.common.data.integration -import dagger.assisted.AssistedFactory +import io.homeassistant.companion.android.common.data.LocalStorage import io.homeassistant.companion.android.common.data.integration.impl.IntegrationRepositoryImpl +import io.homeassistant.companion.android.common.data.integration.impl.IntegrationService import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse +import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEvent import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse +import io.homeassistant.companion.android.common.util.di.SuspendProvider +import io.homeassistant.companion.android.di.qualifiers.NamedDeviceId +import io.homeassistant.companion.android.di.qualifiers.NamedIntegrationStorage +import io.homeassistant.companion.android.di.qualifiers.NamedManufacturer +import io.homeassistant.companion.android.di.qualifiers.NamedModel +import io.homeassistant.companion.android.di.qualifiers.NamedOsVersion +import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.flow.Flow interface IntegrationRepository { @@ -86,7 +96,26 @@ interface IntegrationRepository { suspend fun setAskNotificationPermission(shouldAsk: Boolean) } -@AssistedFactory -internal interface IntegrationRepositoryFactory { - fun create(serverId: Int): IntegrationRepositoryImpl +internal class IntegrationRepositoryFactory @Inject constructor( + private val integrationServiceProvider: SuspendProvider, + // Use a Provider to avoid a dependency circle since serverManager needs the factory + private val serverManagerProvider: Provider, + @NamedIntegrationStorage private val localStorage: LocalStorage, + @NamedManufacturer private val manufacturer: String, + @NamedModel private val model: String, + @NamedOsVersion private val osVersion: String, + @NamedDeviceId private val deviceId: String, +) { + suspend fun create(serverId: Int): IntegrationRepositoryImpl { + return IntegrationRepositoryImpl( + integrationService = integrationServiceProvider(), + serverManager = serverManagerProvider.get(), + serverId = serverId, + localStorage = localStorage, + manufacturer = manufacturer, + model = model, + osVersion = osVersion, + deviceId = deviceId, + ) + } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketCore.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketCore.kt index e14d0ddf45d..9be4b5762ab 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketCore.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketCore.kt @@ -3,6 +3,7 @@ package io.homeassistant.companion.android.common.data.websocket import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.data.websocket.impl.WebSocketCoreImpl import io.homeassistant.companion.android.common.data.websocket.impl.entities.RawMessageSocketResponse +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.database.server.Server import javax.inject.Inject import javax.inject.Provider @@ -74,13 +75,13 @@ internal interface WebSocketCore { } internal class WebSocketCoreFactory @Inject constructor( - private val okHttpClient: OkHttpClient, - // Use a Provider to avoid a dependency circle since serverManager needs WebSocketCoreFactory + private val okHttpClientProvider: SuspendProvider, + // Use a Provider to avoid a dependency circle since serverManager needs the factory private val serverManagerProvider: Provider, ) { - fun create(serverId: Int): WebSocketCore { - return WebSocketCoreImpl(okHttpClient, serverManagerProvider.get(), serverId) + suspend fun create(serverId: Int): WebSocketCore { + return WebSocketCoreImpl(okHttpClientProvider(), serverManagerProvider.get(), serverId) } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt index 18b7336c3ee..6afe0331819 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt @@ -138,11 +138,11 @@ interface WebSocketRepository { internal class WebSocketRepositoryFactory @Inject internal constructor( private val coreFactory: WebSocketCoreFactory, - // Use a Provider to avoid a dependency circle since serverManager needs WebSocketCoreFactory + // Use a Provider to avoid a dependency circle since serverManager needs the factory private val serverManagerProvider: Provider, ) { - fun create(serverId: Int): WebSocketRepository { + suspend fun create(serverId: Int): WebSocketRepository { return WebSocketRepositoryImpl(coreFactory.create(serverId), serverManagerProvider.get()) } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt index 4c40ef08fbc..00941813468 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt @@ -16,6 +16,7 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.ExoPlayer +import io.homeassistant.companion.android.common.util.di.SuspendProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -45,10 +46,14 @@ class AudioUrlPlayer @VisibleForTesting constructor( private val playerCreator: suspend (ExoPlayer.() -> Unit) -> ExoPlayer, ) { - constructor(context: Context, audioManager: AudioManager?, dataSourceFactory: DataSource.Factory) : this( + constructor( + context: Context, + audioManager: AudioManager?, + dataSourceFactoryProvider: SuspendProvider, + ) : this( audioManager, { - val player = initializePlayer(context, dataSourceFactory) + val player = initializePlayer(context, dataSourceFactoryProvider()) player.apply(it) }, ) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/ExoPlayerExt.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/ExoPlayerExt.kt index 939e5c13279..1f016ed9d0a 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/ExoPlayerExt.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/ExoPlayerExt.kt @@ -15,7 +15,6 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import dagger.Lazy import java.io.File import java.util.concurrent.Executors import kotlin.time.Duration.Companion.seconds @@ -171,15 +170,15 @@ private fun buildHttpEngineFactory(context: Context): DataSource.Factory? { * then we use the OkHttp datasource. * * @param context application context for initializing HttpEngine/Cronet - * @param okHttpClientProvider lazily provides the shared [OkHttpClient] configured with mTLS + * @param okHttpClient the shared [OkHttpClient] configured with mTLS * @param usesMtls called on every [createDataSource] to check if mTLS is currently used */ internal class MtlsAwareDataSourceFactory( context: Context, - okHttpClientProvider: Lazy, + okHttpClient: OkHttpClient, private val usesMtls: () -> Boolean, ) : DataSource.Factory { - private val okHttpDelegate by lazy { OkHttpDataSource.Factory(okHttpClientProvider.get()) } + private val okHttpDelegate by lazy { OkHttpDataSource.Factory(okHttpClient) } private val defaultDelegate by lazy { createDataSourceFactory(context) { okHttpDelegate } } @OptIn(UnstableApi::class) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/UtilModule.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/UtilModule.kt index 3e42aa06fef..ad8f3db488e 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/UtilModule.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/UtilModule.kt @@ -12,6 +12,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import io.homeassistant.companion.android.common.util.di.SuspendProvider import javax.inject.Singleton @Module @@ -38,6 +39,7 @@ object UtilModule { @Singleton fun provideAudioUrlPlayer( @ApplicationContext appContext: Context, - dataSourceFactory: DataSource.Factory, - ): AudioUrlPlayer = AudioUrlPlayer(appContext, appContext.getSystemService(), dataSourceFactory) + dataSourceFactoryProvider: SuspendProvider, + ): AudioUrlPlayer = + AudioUrlPlayer(appContext, appContext.getSystemService(), dataSourceFactoryProvider) } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt b/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt index 0e8e0e8363a..b584a2b19bf 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt @@ -6,7 +6,6 @@ import android.os.Build import android.provider.Settings import androidx.media3.datasource.DataSource import dagger.Binds -import dagger.Lazy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -53,38 +52,41 @@ internal abstract class DataModule { companion object { @Provides @Singleton - fun provideAuthenticationService(homeAssistantApis: HomeAssistantApis): AuthenticationService = - homeAssistantApis.retrofit.create(AuthenticationService::class.java) + fun provideAuthenticationService(homeAssistantApis: HomeAssistantApis): SuspendProvider = + SuspendProvider { homeAssistantApis.getRetrofit().create(AuthenticationService::class.java) } @Provides @Singleton - fun providesIntegrationService(homeAssistantApis: HomeAssistantApis): IntegrationService = - homeAssistantApis.retrofit.create(IntegrationService::class.java) + fun providesIntegrationService(homeAssistantApis: HomeAssistantApis): SuspendProvider = + SuspendProvider { homeAssistantApis.getRetrofit().create(IntegrationService::class.java) } @Provides @Singleton - fun providesOkHttpClient(homeAssistantApis: HomeAssistantApis): OkHttpClient = homeAssistantApis.okHttpClient + fun providesOkHttpClient(homeAssistantApis: HomeAssistantApis): SuspendProvider = + SuspendProvider { homeAssistantApis.getOkHttpClient() } @Provides @Singleton fun providesRealDataSourceFactory( @ApplicationContext appContext: Context, - okHttpClient: Lazy, + okHttpClientProvider: SuspendProvider, @NamedKeyChain keyChainRepository: KeyChainRepository, @NamedKeyStore keyStoreRepository: KeyChainRepository, - ): DataSource.Factory = MtlsAwareDataSourceFactory( - context = appContext, - okHttpClientProvider = okHttpClient, - usesMtls = { - val keyChainHasClientCert = - keyChainRepository.getPrivateKey() != null && - !keyChainRepository.getCertificateChain().isNullOrEmpty() - val keyStoreHasClientCert = - keyStoreRepository.getPrivateKey() != null && - !keyStoreRepository.getCertificateChain().isNullOrEmpty() - keyChainHasClientCert || keyStoreHasClientCert - }, - ) + ): SuspendProvider = SuspendProvider { + MtlsAwareDataSourceFactory( + context = appContext, + okHttpClient = okHttpClientProvider(), + usesMtls = { + val keyChainHasClientCert = + keyChainRepository.getPrivateKey() != null && + !keyChainRepository.getCertificateChain().isNullOrEmpty() + val keyStoreHasClientCert = + keyStoreRepository.getPrivateKey() != null && + !keyStoreRepository.getCertificateChain().isNullOrEmpty() + keyChainHasClientCert || keyStoreHasClientCert + }, + ) + } @Provides @NamedSessionStorage @@ -133,7 +135,7 @@ internal abstract class DataModule { @Provides @NamedDeviceId @Singleton - fun provideDeviceId(@ApplicationContext appContext: Context) = Settings.Secure.getString( + fun provideDeviceId(@ApplicationContext appContext: Context): String = Settings.Secure.getString( appContext.contentResolver, Settings.Secure.ANDROID_ID, ) diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepositoryTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepositoryTest.kt index 26c76903eaf..04a6564f3ff 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepositoryTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/authentication/ServerRegistrationRepositoryTest.kt @@ -24,7 +24,7 @@ class ServerRegistrationRepositoryTest { private val authenticationService: AuthenticationService = mockk() private val installIdProvider: SuspendProvider = mockk() - private var repository: ServerRegistrationRepository = ServerRegistrationRepository(authenticationService, installIdProvider) + private var repository: ServerRegistrationRepository = ServerRegistrationRepository({ authenticationService }, installIdProvider) @Test fun `Given valid URL and token with refresh token, when registering auth code, then return TemporaryServer and call service correctly`() = runTest { diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt index 362fa2466d3..5861f980a8b 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -21,7 +21,6 @@ import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.common.util.configureComposeDiagnosticStackTrace import io.homeassistant.companion.android.complications.ComplicationReceiver import io.homeassistant.companion.android.sensors.SensorReceiver -import io.homeassistant.companion.android.util.threadPolicyIgnoredViolationRules import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -46,7 +45,7 @@ open class HomeAssistantApplication : Application() { BuildConfig.DEBUG && !BuildConfig.NO_STRICT_MODE ) { - HAStrictMode.enable(threadPolicyIgnoredViolationRules = threadPolicyIgnoredViolationRules) + HAStrictMode.enable() } Timber.i("Running ${BuildConfig.VERSION_NAME} on SDK $SdkVersion") diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/CameraTile.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/CameraTile.kt index b578f3dd748..13cb105b417 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/CameraTile.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/CameraTile.kt @@ -22,6 +22,7 @@ import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.data.servers.UrlState +import io.homeassistant.companion.android.common.util.di.SuspendProvider import io.homeassistant.companion.android.database.wear.CameraTile import io.homeassistant.companion.android.database.wear.CameraTileDao import io.homeassistant.companion.android.home.HomeActivity @@ -63,7 +64,7 @@ class CameraTile : TileService() { lateinit var wearPrefsRepository: WearPrefsRepository @Inject - lateinit var okHttpClient: OkHttpClient + lateinit var okHttpClientProvider: SuspendProvider @Inject lateinit var cameraTileDao: CameraTileDao @@ -141,7 +142,7 @@ class CameraTile : TileService() { requestParams.deviceConfiguration.screenHeightDp * requestParams.deviceConfiguration.screenDensity withContext(Dispatchers.IO) { - val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute() + val response = okHttpClientProvider().newCall(Request.Builder().url(url).build()).execute() byteArray = response.body.byteStream().readBytes() var bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) if (bitmap.width > maxWidth || bitmap.height > maxHeight) { diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt deleted file mode 100644 index ffd687e0067..00000000000 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.homeassistant.companion.android.util - -import android.os.Build -import android.os.strictmode.DiskReadViolation -import android.os.strictmode.Violation -import androidx.annotation.RequiresApi -import io.homeassistant.companion.android.common.data.HomeAssistantApis -import io.homeassistant.companion.android.common.util.IgnoreViolationRule - -val threadPolicyIgnoredViolationRules: List = listOf( - IgnoreConfigureOkHttpClientDiskRead, -) - -/** - * Ignores a [DiskReadViolation] raised while [HomeAssistantApis] configures its OkHttpClient: - * building the TLSHelper trust managers reads CA keystores from disk on some devices. - * - * Tracked by https://github.com/home-assistant/android/pull/7042 - */ -private data object IgnoreConfigureOkHttpClientDiskRead : IgnoreViolationRule { - @RequiresApi(Build.VERSION_CODES.P) - override fun shouldIgnore(violation: Violation): Boolean { - if (violation !is DiskReadViolation) return false - - return violation.stackTrace.any { - it.className == HomeAssistantApis::class.java.name && - it.methodName == "configureOkHttpClient" - } - } -}