diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 099fb4f99..91b059213 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,13 +2,12 @@ name: Unit tests on: push: - branches: - - 'main' + branches: [ "main" ] paths-ignore: - '**.md' - '**.yml' pull_request: - types: [ opened, reopened, ready_for_review ] + branches: [ "main" ] workflow_dispatch: jobs: diff --git a/app/src/androidTest/kotlin/com/looker/droidify/sync/Downloader.kt b/app/src/androidTest/kotlin/com/looker/droidify/sync/Downloader.kt index 6bf5a8f6f..a2905efc1 100644 --- a/app/src/androidTest/kotlin/com/looker/droidify/sync/Downloader.kt +++ b/app/src/androidTest/kotlin/com/looker/droidify/sync/Downloader.kt @@ -6,17 +6,13 @@ import com.looker.droidify.network.ProgressListener import com.looker.droidify.network.header.HeadersBuilder import com.looker.droidify.network.validation.FileValidator import com.looker.droidify.sync.common.assets +import java.io.File +import java.io.InputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext -import java.io.File -import java.io.InputStream -import java.net.Proxy val FakeDownloader = object : Downloader { - override fun setProxy(proxy: Proxy) { - TODO("Not yet implemented") - } override suspend fun headCall( url: String, diff --git a/app/src/main/kotlin/com/looker/droidify/Droidify.kt b/app/src/main/kotlin/com/looker/droidify/Droidify.kt index d8caa9c7e..6ef0dafb1 100644 --- a/app/src/main/kotlin/com/looker/droidify/Droidify.kt +++ b/app/src/main/kotlin/com/looker/droidify/Droidify.kt @@ -18,6 +18,7 @@ import coil3.disk.DiskCache import coil3.disk.directory import coil3.intercept.Interceptor import coil3.memory.MemoryCache +import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.ImageResult import coil3.request.SuccessResult import coil3.request.crossfade @@ -26,8 +27,6 @@ import com.looker.droidify.database.Database import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.datastore.get import com.looker.droidify.datastore.model.AutoSync -import com.looker.droidify.datastore.model.ProxyPreference -import com.looker.droidify.datastore.model.ProxyType import com.looker.droidify.index.RepositoryUpdater import com.looker.droidify.installer.InstallManager import com.looker.droidify.network.Downloader @@ -41,12 +40,10 @@ import com.looker.droidify.utility.common.cache.Cache import com.looker.droidify.utility.common.extension.getDrawableCompat import com.looker.droidify.utility.common.extension.getInstalledPackagesCompat import com.looker.droidify.utility.common.extension.jobScheduler -import com.looker.droidify.utility.common.log import com.looker.droidify.utility.extension.toInstalledItem import com.looker.droidify.work.CleanUpWorker import dagger.hilt.android.HiltAndroidApp -import java.net.InetSocketAddress -import java.net.Proxy +import io.ktor.client.HttpClient import javax.inject.Inject import kotlin.time.Duration.Companion.INFINITE import kotlin.time.Duration.Companion.hours @@ -73,6 +70,9 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov @Inject lateinit var downloader: Downloader + @Inject + lateinit var httpClient: HttpClient + @Inject lateinit var workerFactory: HiltWorkerFactory @@ -147,36 +147,9 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov } } } - launch { - settingsRepository.get { proxy }.collect(::updateProxy) - } } } - private fun updateProxy(proxyPreference: ProxyPreference) { - val type = proxyPreference.type - val host = proxyPreference.host - val port = proxyPreference.port - val socketAddress = when (type) { - ProxyType.DIRECT -> null - ProxyType.HTTP, ProxyType.SOCKS -> { - try { - InetSocketAddress.createUnresolved(host, port) - } catch (e: IllegalArgumentException) { - log(e) - null - } - } - } - val androidProxyType = when (type) { - ProxyType.DIRECT -> Proxy.Type.DIRECT - ProxyType.HTTP -> Proxy.Type.HTTP - ProxyType.SOCKS -> Proxy.Type.SOCKS - } - val determinedProxy = socketAddress?.let { Proxy(androidProxyType, it) } ?: Proxy.NO_PROXY - downloader.setProxy(determinedProxy) - } - private fun updateSyncJob(force: Boolean, autoSync: AutoSync) { if (autoSync == AutoSync.NEVER) { jobScheduler?.cancel(Constants.JOB_ID_SYNC) @@ -243,7 +216,10 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov .diskCache(diskCache) .error(getDrawableCompat(R.drawable.ic_cannot_load).asImage()) .crossfade(350) - .components { add(FallbackIconInterceptor()) } + .components { + add(KtorNetworkFetcherFactory(httpClient = { httpClient })) + add(FallbackIconInterceptor()) + } .build() } } diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt index 48f83a70e..b6eed2396 100644 --- a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -69,18 +68,10 @@ fun SettingsScreen( val customButtons by viewModel.customButtons.collectAsStateWithLifecycle() val isBackgroundAllowed by viewModel.isBackgroundAllowed.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { viewModel.updateBackgroundAccessState(context.isIgnoreBatteryEnabled()) } - LaunchedEffect(Unit) { - viewModel.snackbarMessage.collect { messageRes -> - snackbarHostState.showSnackbar(context.getString(messageRes)) - } - } - val exportSettingsLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument(BACKUP_MIME_TYPE), ) { uri -> uri?.let { viewModel.exportSettings(it) } } @@ -132,7 +123,7 @@ fun SettingsScreen( navigationIcon = { BackButton(onBackClick) }, ) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, + snackbarHost = { SnackbarHost(viewModel.snackbarHostState) }, ) { contentPadding -> LazyColumn( modifier = Modifier diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt index 6eb2be859..ad05e2517 100644 --- a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt @@ -4,11 +4,13 @@ import android.content.Context import android.net.Uri import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.material3.SnackbarHostState import androidx.core.os.LocaleListCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.looker.droidify.BuildConfig import com.looker.droidify.R +import com.looker.droidify.data.StringHandler import com.looker.droidify.database.Database import com.looker.droidify.database.RepositoryExporter import com.looker.droidify.datastore.CustomButtonRepository @@ -35,11 +37,9 @@ import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @HiltViewModel @@ -47,8 +47,11 @@ class SettingsViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val repositoryExporter: RepositoryExporter, private val customButtonRepository: CustomButtonRepository, + private val handler: StringHandler, ) : ViewModel() { + val snackbarHostState = SnackbarHostState() + val settings = settingsRepository.data.asStateFlow(Settings()) val customButtons: StateFlow> = customButtonRepository.buttons @@ -57,16 +60,13 @@ class SettingsViewModel @Inject constructor( private val _isBackgroundAllowed = MutableStateFlow(true) val isBackgroundAllowed = _isBackgroundAllowed.asStateFlow() - private val _snackbarMessage = Channel(Channel.BUFFERED) - val snackbarMessage = _snackbarMessage.receiveAsFlow() - fun updateBackgroundAccessState(allowed: Boolean) { _isBackgroundAllowed.value = allowed } fun showSnackbar(@StringRes messageRes: Int) { viewModelScope.launch { - _snackbarMessage.send(messageRes) + snackbarHostState.showSnackbar(handler.getString(messageRes)) } } @@ -191,21 +191,25 @@ class SettingsViewModel @Inject constructor( fun setProxyType(proxyType: ProxyType) { viewModelScope.launch { settingsRepository.setProxyType(proxyType) + showSnackbar(R.string.proxy_restart_required) } } fun setProxyHost(host: String) { viewModelScope.launch { settingsRepository.setProxyHost(host) + showSnackbar(R.string.proxy_restart_required) } } fun setProxyPort(port: String) { viewModelScope.launch { - try { - settingsRepository.setProxyPort(port.toInt()) - } catch (_: NumberFormatException) { + val portInt = port.toIntOrNull() + if (portInt == null) { showSnackbar(R.string.proxy_port_error_not_int) + } else { + settingsRepository.setProxyPort(portInt) + showSnackbar(R.string.proxy_restart_required) } } } diff --git a/app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt b/app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt index edc92a3af..11fa8fd8d 100644 --- a/app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt @@ -1,26 +1,75 @@ package com.looker.droidify.di +import com.looker.droidify.BuildConfig.BUILD_TYPE +import com.looker.droidify.BuildConfig.VERSION_NAME +import com.looker.droidify.datastore.SettingsRepository +import com.looker.droidify.datastore.model.ProxyPreference +import com.looker.droidify.datastore.model.ProxyType import com.looker.droidify.network.Downloader import com.looker.droidify.network.KtorDownloader +import com.looker.droidify.utility.common.log import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.UserAgent +import java.net.InetSocketAddress +import java.net.Proxy import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking @Module @InstallIn(SingletonComponent::class) object NetworkModule { + @Singleton + @Provides + fun provideHttpClient(settingsRepository: SettingsRepository): HttpClient { + val proxyPreference = runBlocking { settingsRepository.getInitial().proxy } + val engine = OkHttp.create { proxy = proxyPreference.toProxy() } + return HttpClient(engine) { + install(UserAgent) { + agent = "Droid-ify/${VERSION_NAME}-${BUILD_TYPE}" + } + install(HttpTimeout) { + connectTimeoutMillis = 30_000L + socketTimeoutMillis = 15_000L + } + } + } + @Singleton @Provides fun provideDownloader( + httpClient: HttpClient, @IoDispatcher dispatcher: CoroutineDispatcher, ): Downloader = KtorDownloader( - httpClientEngine = OkHttp.create(), + client = httpClient, dispatcher = dispatcher, ) } + +private fun ProxyPreference.toProxy(): Proxy { + val socketAddress = when (type) { + ProxyType.DIRECT -> null + ProxyType.HTTP, ProxyType.SOCKS -> { + try { + InetSocketAddress.createUnresolved(host, port) + } catch (e: IllegalArgumentException) { + log(e) + null + } + } + } + val androidProxyType = when (type) { + ProxyType.DIRECT -> Proxy.Type.DIRECT + ProxyType.HTTP -> Proxy.Type.HTTP + ProxyType.SOCKS -> Proxy.Type.SOCKS + } + return socketAddress?.let { Proxy(androidProxyType, it) } ?: Proxy.NO_PROXY +} diff --git a/app/src/main/kotlin/com/looker/droidify/network/Downloader.kt b/app/src/main/kotlin/com/looker/droidify/network/Downloader.kt index 561c0bf0d..cf6031488 100644 --- a/app/src/main/kotlin/com/looker/droidify/network/Downloader.kt +++ b/app/src/main/kotlin/com/looker/droidify/network/Downloader.kt @@ -3,15 +3,12 @@ package com.looker.droidify.network import com.looker.droidify.network.header.HeadersBuilder import com.looker.droidify.network.validation.FileValidator import java.io.File -import java.net.Proxy interface Downloader { - fun setProxy(proxy: Proxy) - suspend fun headCall( url: String, - headers: HeadersBuilder.() -> Unit = {} + headers: HeadersBuilder.() -> Unit = {}, ): NetworkResponse suspend fun downloadToFile( @@ -19,7 +16,7 @@ interface Downloader { target: File, validator: FileValidator? = null, headers: HeadersBuilder.() -> Unit = {}, - block: ProgressListener? = null + block: ProgressListener? = null, ): NetworkResponse } diff --git a/app/src/main/kotlin/com/looker/droidify/network/KtorDownloader.kt b/app/src/main/kotlin/com/looker/droidify/network/KtorDownloader.kt index 0db49f3c9..c1042774a 100644 --- a/app/src/main/kotlin/com/looker/droidify/network/KtorDownloader.kt +++ b/app/src/main/kotlin/com/looker/droidify/network/KtorDownloader.kt @@ -1,18 +1,12 @@ package com.looker.droidify.network -import com.looker.droidify.BuildConfig import com.looker.droidify.network.header.HeadersBuilder import com.looker.droidify.network.header.KtorHeadersBuilder import com.looker.droidify.network.validation.FileValidator import com.looker.droidify.network.validation.ValidationException import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.network.sockets.ConnectTimeoutException import io.ktor.client.network.sockets.SocketTimeoutException -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.onDownload import io.ktor.client.request.head import io.ktor.client.request.headers @@ -29,27 +23,16 @@ import io.ktor.utils.io.jvm.javaio.copyTo import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.net.Proxy import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext internal class KtorDownloader( - httpClientEngine: HttpClientEngine, + private val client: HttpClient, private val dispatcher: CoroutineDispatcher, ) : Downloader { - private var client = client(httpClientEngine) - set(newClient) { - field.close() - field = newClient - } - - override fun setProxy(proxy: Proxy) { - client = client(OkHttp.create { this.proxy = proxy }) - } - override suspend fun headCall( url: String, headers: HeadersBuilder.() -> Unit, @@ -108,13 +91,6 @@ internal class KtorDownloader( } } - private fun client( - engine: HttpClientEngine = OkHttp.create(), - ) = HttpClient(engine) { - userAgentConfig() - timeoutConfig() - } - private fun request( url: String, fileSize: Long = 0L, @@ -134,19 +110,6 @@ internal class KtorDownloader( } } -private const val CONNECTION_TIMEOUT = 30_000L -private const val SOCKET_TIMEOUT = 15_000L -private const val USER_AGENT = "Droid-ify/${BuildConfig.VERSION_NAME}-${BuildConfig.BUILD_TYPE}" - -private fun HttpClientConfig<*>.userAgentConfig() = install(UserAgent) { - agent = USER_AGENT -} - -private fun HttpClientConfig<*>.timeoutConfig() = install(HttpTimeout) { - connectTimeoutMillis = CONNECTION_TIMEOUT - socketTimeoutMillis = SOCKET_TIMEOUT -} - private fun HttpResponse.asNetworkResponse(): NetworkResponse = if (status.isSuccess() || status == HttpStatusCode.NotModified) { NetworkResponse.Success(status.value, lastModified(), etag()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99388c7f0..fc5b0358b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -174,6 +174,7 @@ Promotes non-free add-ons Provided by %s Proxy + Restart required for changes to take effect. Proxy host Proxy port The proxy port can only be an integer diff --git a/app/src/test/kotlin/com/looker/droidify/network/KtorDownloaderTest.kt b/app/src/test/kotlin/com/looker/droidify/network/KtorDownloaderTest.kt index 1b3a5f997..cfe9cf7d6 100644 --- a/app/src/test/kotlin/com/looker/droidify/network/KtorDownloaderTest.kt +++ b/app/src/test/kotlin/com/looker/droidify/network/KtorDownloaderTest.kt @@ -1,5 +1,6 @@ package com.looker.droidify.network +import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.engine.mock.respondError @@ -7,12 +8,12 @@ import io.ktor.client.engine.mock.respondOk import io.ktor.client.plugins.ConnectTimeoutException import io.ktor.client.plugins.SocketTimeoutException import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test import java.io.File import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test class KtorDownloaderTest { @@ -31,7 +32,8 @@ class KtorDownloaderTest { private val dispatcher = StandardTestDispatcher() - private val downloader = KtorDownloader(engine, dispatcher) + private val httpClient = HttpClient(engine) + private val downloader = KtorDownloader(httpClient, dispatcher) @Test fun `head call success`() = runTest(dispatcher) {