Skip to content

Commit 7740f42

Browse files
committed
Fix: HTTPS localhost support and related lint/test updates
1 parent c5bc834 commit 7740f42

18 files changed

Lines changed: 324 additions & 31 deletions

AnkiDroid/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,8 @@ dependencies {
514514
implementation libs.commons.io // FileUtils.contentEquals
515515
implementation libs.mikehardy.google.analytics.java7
516516
implementation libs.okhttp
517+
implementation 'org.bouncycastle:bcprov-jdk18on:1.81'
518+
implementation 'org.bouncycastle:bcpkix-jdk18on:1.81'
517519
implementation libs.slf4j.timber
518520
implementation libs.jakewharton.timber
519521
implementation libs.jsoup

AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ abstract class AbstractFlashcardViewer :
550550
setContentView(getContentViewAttr(fullscreenMode))
551551

552552
val port = StudyScreenRepository().getServerPort()
553-
server = AnkiServer(this, port).also { it.start() }
553+
server = AnkiServer(this, cacheDir, port).also { it.start() }
554554
// Make ACTION_PROCESS_TEXT for in-app searching possible on > Android 4.0
555555
delegate.isHandleNativeActionModesEnabled = true
556556

AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,27 @@ import fi.iki.elonen.NanoHTTPD
2121
import kotlinx.coroutines.runBlocking
2222
import timber.log.Timber
2323
import java.io.ByteArrayInputStream
24+
import java.io.File
2425

2526
open class AnkiServer(
2627
private val postHandler: PostRequestHandler,
28+
cacheDir: File? = null,
2729
port: Int = 0,
2830
) : NanoHTTPD(LOCALHOST, port) {
29-
fun baseUrl(): String = "http://$LOCALHOST:$listeningPort/"
31+
private val sslContext =
32+
cacheDir?.let { cacheDir ->
33+
runCatching { SslUtil.getSSLContext(cacheDir) }
34+
.onFailure { Timber.w(it, "Failed to initialize HTTPS for AnkiServer") }
35+
.getOrNull()
36+
}
37+
38+
init {
39+
// Enable HTTPS when a keystore was created successfully (Issue #15991)
40+
// This allows WebView to load cards without cleartext restrictions on modern Android.
41+
sslContext?.let { makeSecure(it.serverSocketFactory, null) }
42+
}
43+
44+
fun baseUrl(): String = "${if (sslContext != null) "https" else "http"}://$LOCALHOST:$listeningPort/"
3045

3146
// it's faster to serve local files without GZip. see 'page render' in logs
3247
// This also removes 'W/System: A resource failed to call end.'

AnkiDroid/src/main/java/com/ichi2/anki/pages/PageFragment.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ abstract class PageFragment(
104104
view: View,
105105
savedInstanceState: Bundle?,
106106
) {
107-
server = AnkiServer(this).also { it.start() }
107+
// Store the TLS keystore in the app cache directory: it is per-profile by design and can be
108+
// regenerated if the OS clears it.
109+
server = AnkiServer(this, requireContext().cacheDir).also { it.start() }
108110
webViewLayout = view.findViewById(R.id.webview_layout)
109111

110112
minimumWebViewVersion?.let { minVersion ->
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2026 mixelas <michelakisgio@gmail.com>
3+
4+
package com.ichi2.anki.pages
5+
6+
import org.bouncycastle.asn1.x500.X500Name
7+
import org.bouncycastle.asn1.x509.BasicConstraints
8+
import org.bouncycastle.asn1.x509.Extension
9+
import org.bouncycastle.asn1.x509.GeneralName
10+
import org.bouncycastle.asn1.x509.GeneralNames
11+
import org.bouncycastle.asn1.x509.KeyUsage
12+
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
13+
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
14+
import org.bouncycastle.jce.provider.BouncyCastleProvider
15+
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
16+
import timber.log.Timber
17+
import java.io.File
18+
import java.io.FileInputStream
19+
import java.io.FileOutputStream
20+
import java.math.BigInteger
21+
import java.security.KeyPair
22+
import java.security.KeyPairGenerator
23+
import java.security.KeyStore
24+
import java.security.SecureRandom
25+
import java.security.Security
26+
import java.util.Date
27+
import javax.net.ssl.KeyManagerFactory
28+
import javax.net.ssl.SSLContext
29+
30+
/** Manages SSL/TLS support for HTTPS connections in AnkiServer */
31+
object SslUtil {
32+
private const val KEYSTORE_FILENAME = "anki_keystore.p12"
33+
private const val KEYSTORE_ALIAS = "localhost"
34+
private const val KEY_SIZE = 2048
35+
36+
/**
37+
* Password for the keystore. Used for both loading and storing the PKCS12 keystore.
38+
* This is an app-local credential and not transmitted over the network.
39+
*/
40+
private const val KEYSTORE_PASSWORD = "localhost"
41+
private const val KEY_PASSWORD = "localhost"
42+
43+
/**
44+
* Get or create an SSLContext for localhost HTTPS.
45+
* The keystore is cached in the app cache directory after first generation.
46+
*/
47+
fun getSSLContext(cacheDir: File): SSLContext {
48+
val keystoreFile = File(cacheDir, KEYSTORE_FILENAME)
49+
50+
val keyStore =
51+
if (keystoreFile.exists()) {
52+
loadKeystore(keystoreFile)
53+
} else {
54+
generateKeystore(keystoreFile)
55+
}
56+
57+
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
58+
kmf.init(keyStore, KEY_PASSWORD.toCharArray())
59+
60+
return SSLContext.getInstance("TLSv1.2").apply {
61+
init(kmf.keyManagers, null, java.security.SecureRandom())
62+
}
63+
}
64+
65+
private fun loadKeystore(file: File): KeyStore {
66+
val keyStore = KeyStore.getInstance("PKCS12")
67+
FileInputStream(file).use { fis ->
68+
keyStore.load(fis, KEYSTORE_PASSWORD.toCharArray())
69+
}
70+
return keyStore
71+
}
72+
73+
@Suppress("DirectDateInstantiation", "DirectSystemCurrentTimeMillisUsage")
74+
private fun generateKeystore(file: File): KeyStore {
75+
// Ensure BouncyCastle provider is available for certificate generation
76+
try {
77+
Security.addProvider(BouncyCastleProvider())
78+
} catch (_: Exception) {
79+
// provider may already be present
80+
}
81+
82+
val keyStore = KeyStore.getInstance("PKCS12")
83+
keyStore.load(null, null)
84+
85+
try {
86+
// CA key pair
87+
val caKeyPair = generateKeyPair()
88+
val now = Date()
89+
val caNotBefore = Date(now.time - 1000L * 60)
90+
val caNotAfter = Date(now.time + 3650L * 24 * 60 * 60 * 1000) // 10 years
91+
92+
val caName = X500Name("CN=Anki Local CA")
93+
val caSerial = BigInteger.valueOf(System.currentTimeMillis())
94+
95+
val caBuilder =
96+
JcaX509v3CertificateBuilder(
97+
caName,
98+
caSerial,
99+
caNotBefore,
100+
caNotAfter,
101+
caName,
102+
caKeyPair.public,
103+
)
104+
105+
caBuilder.addExtension(
106+
Extension.basicConstraints,
107+
true,
108+
BasicConstraints(true),
109+
)
110+
111+
val caSigner = JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(caKeyPair.private)
112+
val caHolder = caBuilder.build(caSigner)
113+
val caCert = JcaX509CertificateConverter().setProvider("BC").getCertificate(caHolder)
114+
115+
// Leaf key pair
116+
val leafKeyPair = generateKeyPair()
117+
val leafName = X500Name("CN=localhost")
118+
val leafSerial = BigInteger.valueOf(System.currentTimeMillis() + 1)
119+
val leafNotBefore = caNotBefore
120+
val leafNotAfter = Date(now.time + 365L * 24 * 60 * 60 * 1000) // 1 year
121+
122+
val sanNames =
123+
GeneralNames(
124+
arrayOf(
125+
GeneralName(GeneralName.dNSName, "localhost"),
126+
GeneralName(GeneralName.iPAddress, "127.0.0.1"),
127+
),
128+
)
129+
130+
val leafBuilder =
131+
JcaX509v3CertificateBuilder(
132+
caName,
133+
leafSerial,
134+
leafNotBefore,
135+
leafNotAfter,
136+
leafName,
137+
leafKeyPair.public,
138+
)
139+
140+
leafBuilder.addExtension(Extension.basicConstraints, false, BasicConstraints(false))
141+
leafBuilder.addExtension(Extension.subjectAlternativeName, false, sanNames)
142+
leafBuilder.addExtension(Extension.keyUsage, true, KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment))
143+
144+
val leafSigner = JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(caKeyPair.private)
145+
val leafHolder = leafBuilder.build(leafSigner)
146+
val leafCert = JcaX509CertificateConverter().setProvider("BC").getCertificate(leafHolder)
147+
148+
// Store leaf private key and certificate chain (leaf, ca)
149+
keyStore.setKeyEntry(
150+
KEYSTORE_ALIAS,
151+
leafKeyPair.private,
152+
KEY_PASSWORD.toCharArray(),
153+
arrayOf(leafCert, caCert),
154+
)
155+
} catch (e: Exception) {
156+
Timber.w(e, "Failed to generate certificate chain for keystore")
157+
}
158+
159+
FileOutputStream(file).use { fos ->
160+
keyStore.store(fos, KEYSTORE_PASSWORD.toCharArray())
161+
}
162+
163+
return keyStore
164+
}
165+
166+
private fun generateKeyPair(): KeyPair {
167+
val keyGen = KeyPairGenerator.getInstance("RSA")
168+
keyGen.initialize(KEY_SIZE)
169+
return keyGen.generateKeyPair()
170+
}
171+
}

AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ import kotlinx.coroutines.flow.MutableSharedFlow
3737
import kotlinx.coroutines.launch
3838
import kotlinx.serialization.json.Json
3939
import timber.log.Timber
40+
import java.io.File
4041

4142
abstract class CardViewerViewModel(
43+
protected val cacheDir: File,
4244
val savedStateHandle: SavedStateHandle,
4345
) : ViewModel(),
4446
OnErrorListener,

AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ import androidx.core.view.ViewCompat
2828
import androidx.core.view.doOnLayout
2929
import androidx.core.view.isVisible
3030
import androidx.fragment.app.viewModels
31+
import androidx.lifecycle.createSavedStateHandle
3132
import androidx.lifecycle.flowWithLifecycle
3233
import androidx.lifecycle.lifecycleScope
34+
import androidx.lifecycle.viewmodel.initializer
35+
import androidx.lifecycle.viewmodel.viewModelFactory
3336
import com.google.android.material.slider.Slider
3437
import com.ichi2.anki.CollectionManager.TR
3538
import com.ichi2.anki.DispatchKeyEventListener
@@ -60,7 +63,13 @@ class PreviewerFragment :
6063
BaseSnackbarBuilderProvider,
6164
DispatchKeyEventListener,
6265
BindingProcessor<MappableBinding, PreviewerAction> {
63-
override val viewModel: PreviewerViewModel by viewModels()
66+
override val viewModel: PreviewerViewModel by viewModels {
67+
viewModelFactory {
68+
initializer {
69+
PreviewerViewModel(requireContext().cacheDir, createSavedStateHandle())
70+
}
71+
}
72+
}
6473
private val binding by viewBinding(FragmentPreviewerBinding::bind)
6574
override val webViewLayout: SafeWebViewLayout get() = binding.webViewLayout
6675

AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
4141
import kotlinx.coroutines.flow.combine
4242
import kotlinx.coroutines.flow.update
4343
import timber.log.Timber
44+
import java.io.File
4445

4546
class PreviewerViewModel(
47+
cacheDir: File,
4648
savedStateHandle: SavedStateHandle,
47-
) : CardViewerViewModel(savedStateHandle),
49+
) : CardViewerViewModel(cacheDir, savedStateHandle),
4850
ChangeManager.Subscriber {
4951
val currentIndex =
5052
savedStateHandle.getMutableStateFlow(
@@ -73,7 +75,7 @@ class PreviewerViewModel(
7375
asyncIO {
7476
withCol { getCard(selectedCardIds[savedStateHandle.require(PreviewerFragment.CURRENT_INDEX_ARG)]) }
7577
}
76-
override val server = AnkiServer(this).also { it.start() }
78+
override val server = AnkiServer(this, cacheDir).also { it.start() }
7779

7880
init {
7981
ChangeManager.subscribe(this)

AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerFragment.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ package com.ichi2.anki.previewer
1818
import android.os.Bundle
1919
import android.view.View
2020
import androidx.fragment.app.viewModels
21+
import androidx.lifecycle.createSavedStateHandle
2122
import androidx.lifecycle.lifecycleScope
23+
import androidx.lifecycle.viewmodel.initializer
24+
import androidx.lifecycle.viewmodel.viewModelFactory
2225
import com.ichi2.anki.R
2326
import com.ichi2.anki.databinding.FragmentTemplatePreviewerBinding
2427
import com.ichi2.anki.libanki.CardOrdinal
@@ -32,7 +35,13 @@ import timber.log.Timber
3235
class TemplatePreviewerFragment :
3336
CardViewerFragment(R.layout.fragment_template_previewer),
3437
BaseSnackbarBuilderProvider {
35-
override val viewModel: TemplatePreviewerViewModel by viewModels()
38+
override val viewModel: TemplatePreviewerViewModel by viewModels {
39+
viewModelFactory {
40+
initializer {
41+
TemplatePreviewerViewModel(requireContext().cacheDir, createSavedStateHandle())
42+
}
43+
}
44+
}
3645

3746
lateinit var binding: FragmentTemplatePreviewerBinding
3847

AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ import kotlinx.coroutines.flow.collectLatest
4343
import kotlinx.parcelize.Parcelize
4444
import org.intellij.lang.annotations.Language
4545
import org.jetbrains.annotations.VisibleForTesting
46+
import java.io.File
4647

4748
class TemplatePreviewerViewModel(
49+
cacheDir: File,
4850
savedStateHandle: SavedStateHandle,
49-
) : CardViewerViewModel(savedStateHandle) {
51+
) : CardViewerViewModel(cacheDir, savedStateHandle) {
5052
private val notetype: NotetypeJson
5153
private val fillEmpty: Boolean
5254
private val isCloze: Boolean
@@ -63,7 +65,7 @@ class TemplatePreviewerViewModel(
6365
private val templateNames: Deferred<List<String>>
6466
private val clozeOrds: Deferred<List<CardOrdinal>>?
6567
override var currentCard: Deferred<Card>
66-
override val server = AnkiServer(this).also { it.start() }
68+
override val server = AnkiServer(this, cacheDir).also { it.start() }
6769

6870
/**
6971
* Ordered list of cards with empty fronts

0 commit comments

Comments
 (0)