Skip to content

Commit 088a84c

Browse files
sunkupArnyminerZ
andauthored
Allow user to set custom user agent (#663)
* Move companion object to the end of class * Rename preview composable * Add possibility edit user agent string in UI and save it to database * Save user agent string edits to database * Add custom user agent string per subscription * Use Ktor's UserAgent plugin for setting the User-Agent header * Fix tests * Clarify textbox behaviour * Make url scheme check more generic * Add advanced section to animated visibility * Change placeholder to label * Set custom user agent to null if blank * Add spacer * Remove some dead code and optimize imports * Remove companion object * Add custom user agent toggle and input field - Introduce `ToggleTextField` composable for toggling and setting custom user agent - Update strings for custom user agent title, description, and placeholder - Replace `ResourceInput` with `ToggleTextField` in `EnterUrlComposable` * Rename last occurrence of supportsAuthentication to acceptedProtocol * Shorten expression * Rename ToggleTextField params to be generic * Allow passing null for user agent string * Make custom user agent input a ToggleTextField and move it to EditSubscriptionScreen --------- Co-authored-by: Arnau Mora <arnyminerz@proton.me>
1 parent dbe5d54 commit 088a84c

18 files changed

Lines changed: 389 additions & 118 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
{
2+
"formatVersion": 1,
3+
"database": {
4+
"version": 5,
5+
"identityHash": "446ac6c5910d731ad6700fac116d9f50",
6+
"entities": [
7+
{
8+
"tableName": "subscriptions",
9+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `calendarId` INTEGER, `url` TEXT NOT NULL, `eTag` TEXT, `displayName` TEXT NOT NULL, `lastModified` INTEGER, `lastSync` INTEGER, `errorMessage` TEXT, `ignoreEmbeddedAlerts` INTEGER NOT NULL, `defaultAlarmMinutes` INTEGER, `defaultAllDayAlarmMinutes` INTEGER, `ignoreDescription` INTEGER NOT NULL DEFAULT 0, `color` INTEGER, `customUserAgent` TEXT)",
10+
"fields": [
11+
{
12+
"fieldPath": "id",
13+
"columnName": "id",
14+
"affinity": "INTEGER",
15+
"notNull": true
16+
},
17+
{
18+
"fieldPath": "calendarId",
19+
"columnName": "calendarId",
20+
"affinity": "INTEGER"
21+
},
22+
{
23+
"fieldPath": "url",
24+
"columnName": "url",
25+
"affinity": "TEXT",
26+
"notNull": true
27+
},
28+
{
29+
"fieldPath": "eTag",
30+
"columnName": "eTag",
31+
"affinity": "TEXT"
32+
},
33+
{
34+
"fieldPath": "displayName",
35+
"columnName": "displayName",
36+
"affinity": "TEXT",
37+
"notNull": true
38+
},
39+
{
40+
"fieldPath": "lastModified",
41+
"columnName": "lastModified",
42+
"affinity": "INTEGER"
43+
},
44+
{
45+
"fieldPath": "lastSync",
46+
"columnName": "lastSync",
47+
"affinity": "INTEGER"
48+
},
49+
{
50+
"fieldPath": "errorMessage",
51+
"columnName": "errorMessage",
52+
"affinity": "TEXT"
53+
},
54+
{
55+
"fieldPath": "ignoreEmbeddedAlerts",
56+
"columnName": "ignoreEmbeddedAlerts",
57+
"affinity": "INTEGER",
58+
"notNull": true
59+
},
60+
{
61+
"fieldPath": "defaultAlarmMinutes",
62+
"columnName": "defaultAlarmMinutes",
63+
"affinity": "INTEGER"
64+
},
65+
{
66+
"fieldPath": "defaultAllDayAlarmMinutes",
67+
"columnName": "defaultAllDayAlarmMinutes",
68+
"affinity": "INTEGER"
69+
},
70+
{
71+
"fieldPath": "ignoreDescription",
72+
"columnName": "ignoreDescription",
73+
"affinity": "INTEGER",
74+
"notNull": true,
75+
"defaultValue": "0"
76+
},
77+
{
78+
"fieldPath": "color",
79+
"columnName": "color",
80+
"affinity": "INTEGER"
81+
},
82+
{
83+
"fieldPath": "customUserAgent",
84+
"columnName": "customUserAgent",
85+
"affinity": "TEXT"
86+
}
87+
],
88+
"primaryKey": {
89+
"autoGenerate": true,
90+
"columnNames": [
91+
"id"
92+
]
93+
}
94+
},
95+
{
96+
"tableName": "credentials",
97+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`subscriptionId`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
98+
"fields": [
99+
{
100+
"fieldPath": "subscriptionId",
101+
"columnName": "subscriptionId",
102+
"affinity": "INTEGER",
103+
"notNull": true
104+
},
105+
{
106+
"fieldPath": "username",
107+
"columnName": "username",
108+
"affinity": "TEXT",
109+
"notNull": true
110+
},
111+
{
112+
"fieldPath": "password",
113+
"columnName": "password",
114+
"affinity": "TEXT",
115+
"notNull": true
116+
}
117+
],
118+
"primaryKey": {
119+
"autoGenerate": false,
120+
"columnNames": [
121+
"subscriptionId"
122+
]
123+
},
124+
"foreignKeys": [
125+
{
126+
"table": "subscriptions",
127+
"onDelete": "CASCADE",
128+
"onUpdate": "NO ACTION",
129+
"columns": [
130+
"subscriptionId"
131+
],
132+
"referencedColumns": [
133+
"id"
134+
]
135+
}
136+
]
137+
}
138+
],
139+
"setupQueries": [
140+
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
141+
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '446ac6c5910d731ad6700fac116d9f50')"
142+
]
143+
}
144+
}

app/src/androidTest/java/at/bitfire/icsdroid/HttpUtilsTest.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ class HttpUtilsTest {
3131
}
3232

3333
@Test
34-
fun testSupportsAuthentication() {
35-
assertEquals(true, HttpUtils.supportsAuthentication(Uri.parse("https://example.com")))
36-
assertEquals(true, HttpUtils.supportsAuthentication(Uri.parse("http://example.com")))
37-
assertEquals(false, HttpUtils.supportsAuthentication(Uri.parse("content://example.com")))
38-
assertEquals(false, HttpUtils.supportsAuthentication(Uri.parse("garbage://example.com")))
34+
fun testAcceptedProtocol() {
35+
assertEquals(true, HttpUtils.acceptedProtocol(Uri.parse("https://example.com")))
36+
assertEquals(true, HttpUtils.acceptedProtocol(Uri.parse("http://example.com")))
37+
assertEquals(false, HttpUtils.acceptedProtocol(Uri.parse("content://example.com")))
38+
assertEquals(false, HttpUtils.acceptedProtocol(Uri.parse("garbage://example.com")))
3939
}
4040

4141
}

app/src/androidTest/java/at/bitfire/icsdroid/MockServer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ object MockServer {
4949
.toURI()
5050
.toUri()
5151

52-
fun httpClient(context: Context) = AppHttpClient(context, engine)
52+
fun httpClient(context: Context) = AppHttpClient(null, engine, context)
5353

5454
private class Response(val content: String, val status: HttpStatusCode, val headers: Headers)
5555
}

app/src/androidTest/java/at/bitfire/icsdroid/model/ValidationUseCaseTest.kt

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,48 @@
44

55
package at.bitfire.icsdroid.model
66

7-
import android.app.Application
87
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
98
import androidx.test.platform.app.InstrumentationRegistry
109
import at.bitfire.ical4android.Css3Color
1110
import at.bitfire.icsdroid.AppHttpClient
1211
import at.bitfire.icsdroid.MockServer
1312
import at.bitfire.icsdroid.ui.ResourceInfo
13+
import dagger.hilt.android.testing.HiltAndroidRule
14+
import dagger.hilt.android.testing.HiltAndroidTest
15+
import io.ktor.client.engine.HttpClientEngine
1416
import kotlinx.coroutines.runBlocking
1517
import org.junit.Assert.assertEquals
1618
import org.junit.Assert.assertNull
1719
import org.junit.Before
1820
import org.junit.Rule
1921
import org.junit.Test
22+
import javax.inject.Inject
2023

24+
@HiltAndroidTest
2125
class ValidationUseCaseTest {
2226

23-
companion object {
27+
@get:Rule
28+
val instantTaskExecutor = InstantTaskExecutorRule()
2429

25-
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
30+
@get:Rule
31+
val hiltRule = HiltAndroidRule(this)
2632

27-
}
33+
@Inject
34+
lateinit var appHttpClientFactory: AppHttpClient.Factory
2835

29-
@get:Rule
30-
val instantTaskExecutor = InstantTaskExecutorRule()
36+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
3137

3238
@Before
3339
fun setUp() {
40+
hiltRule.inject()
3441
MockServer.clear()
42+
43+
appHttpClientFactory = object : AppHttpClient.Factory {
44+
override fun create(
45+
customUserAgent: String?,
46+
engine: HttpClientEngine
47+
): AppHttpClient = MockServer.httpClient(context)
48+
}
3549
}
3650

3751
@Test
@@ -85,11 +99,15 @@ class ValidationUseCaseTest {
8599
private fun validate(iCal: String): ResourceInfo {
86100
MockServer.enqueue(content = iCal)
87101

88-
val client = AppHttpClient(app, MockServer.engine)
89-
val model = ValidationUseCase(app, client)
102+
val model = ValidationUseCase(context, appHttpClientFactory)
90103
runBlocking {
91104
// Wait until the validation completed
92-
model.validate(MockServer.uri(), null, null).join()
105+
model.validate(
106+
MockServer.uri(),
107+
null,
108+
null,
109+
null
110+
).join()
93111
}
94112

95113
return model.uiState.result!!

app/src/main/java/at/bitfire/icsdroid/AppHttpClient.kt

Lines changed: 25 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,60 @@ package at.bitfire.icsdroid
66

77
import android.content.Context
88
import at.bitfire.cert4android.CustomCertManager
9-
import dagger.Module
10-
import dagger.Provides
11-
import dagger.hilt.InstallIn
9+
import dagger.assisted.Assisted
10+
import dagger.assisted.AssistedFactory
11+
import dagger.assisted.AssistedInject
1212
import dagger.hilt.android.qualifiers.ApplicationContext
13-
import dagger.hilt.components.SingletonComponent
1413
import io.ktor.client.HttpClient
1514
import io.ktor.client.HttpClientConfig
1615
import io.ktor.client.engine.HttpClientEngine
1716
import io.ktor.client.engine.okhttp.OkHttp
1817
import io.ktor.client.engine.okhttp.OkHttpConfig
1918
import io.ktor.client.engine.okhttp.OkHttpEngine
19+
import io.ktor.client.plugins.UserAgent
2020
import kotlinx.coroutines.flow.MutableStateFlow
21-
import okhttp3.Interceptor
22-
import okhttp3.Response
2321
import okhttp3.brotli.BrotliInterceptor
2422
import okhttp3.internal.tls.OkHostnameVerifier
2523
import java.util.concurrent.TimeUnit
26-
import javax.inject.Singleton
2724
import javax.net.ssl.SSLContext
2825

29-
class AppHttpClient(
30-
context: Context,
31-
engine: HttpClientEngine = OkHttp.create()
26+
class AppHttpClient @AssistedInject constructor(
27+
@Assisted customUserAgent: String? = null,
28+
@Assisted engine: HttpClientEngine,
29+
@ApplicationContext context: Context
3230
) {
3331

34-
companion object {
35-
private val appInForeground = MutableStateFlow(false)
36-
37-
fun setForeground(foreground: Boolean) {
38-
appInForeground.tryEmit(foreground)
39-
}
32+
@AssistedFactory
33+
interface Factory {
34+
fun create(
35+
customUserAgent: String? = null,
36+
engine: HttpClientEngine = OkHttp.create(),
37+
): AppHttpClient
4038
}
4139

40+
/**
41+
* The user agent to use in requests
42+
*/
43+
val userAgent = customUserAgent ?: Constants.USER_AGENT
44+
4245
// CustomCertManager is Closeable, but HttpClient will live as long as the application is in memory,
4346
// so we don't need to close it
44-
private val certManager = CustomCertManager(context, appInForeground = appInForeground)
47+
private val certManager = CustomCertManager(context, appInForeground = MutableStateFlow(false))
4548

4649
private val sslContext = SSLContext.getInstance("TLS")
4750
init {
4851
sslContext.init(null, arrayOf(certManager), null)
4952
}
5053

5154
val httpClient = HttpClient(engine) {
55+
// Add user given user agent to all engines
56+
install(UserAgent) {
57+
agent = userAgent
58+
}
59+
5260
@Suppress("UNCHECKED_CAST")
5361
if (engine is OkHttpEngine) (this as HttpClientConfig<OkHttpConfig>).engine {
5462
addNetworkInterceptor(BrotliInterceptor)
55-
addNetworkInterceptor(UserAgentInterceptor)
5663
config {
5764
followRedirects(false)
5865
connectTimeout(20, TimeUnit.SECONDS)
@@ -65,27 +72,4 @@ class AppHttpClient(
6572
}
6673
}
6774

68-
object UserAgentInterceptor : Interceptor {
69-
70-
override fun intercept(chain: Interceptor.Chain): Response {
71-
val request = chain.request()
72-
.newBuilder()
73-
.header("User-Agent", Constants.USER_AGENT)
74-
return chain.proceed(request.build())
75-
}
76-
77-
}
78-
79-
@Module
80-
@InstallIn(SingletonComponent::class)
81-
object HttpClientModule {
82-
@Singleton
83-
@Provides
84-
fun provideAppHttpClient(
85-
@ApplicationContext context: Context
86-
): AppHttpClient {
87-
return AppHttpClient(context, OkHttp.create())
88-
}
89-
}
90-
9175
}

0 commit comments

Comments
 (0)