Manual refactor

This commit is contained in:
Philipp Heckel 2026-01-03 17:54:19 -05:00
parent a7cf17ce36
commit 2f70c936ac
12 changed files with 85 additions and 84 deletions

View file

@ -10,7 +10,7 @@ import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicUrl
import java.io.InputStreamReader
@ -229,8 +229,8 @@ class Backuper(val context: Context) {
}
certificates.forEach { c ->
try {
val x509Cert = SSLManager.parsePemCertificate(c.pem)
val fingerprint = SSLManager.calculateFingerprint(x509Cert)
val cert = CertUtil.parseCertificate(c.pem)
val fingerprint = CertUtil.calculateFingerprint(cert)
repository.addTrustedCertificate(fingerprint, c.pem)
} catch (e: Exception) {
Log.w(TAG, "Unable to restore trusted certificate: ${e.message}. Ignoring.", e)

View file

@ -7,7 +7,7 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.*
import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody
@ -19,7 +19,7 @@ import kotlin.random.Random
class ApiService(private val context: Context) {
private val repository = Repository.getInstance(context)
private val sslManager = SSLManager.getInstance(context)
private val sslManager = CertUtil.getInstance(context)
private val gson = Gson()
private val parser = NotificationParser()

View file

@ -12,8 +12,15 @@ import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DONE
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_FAILED
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_INDETERMINATE
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_NONE
import io.heckel.ntfy.db.Attachment
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile
import io.heckel.ntfy.util.extractBaseUrl
@ -24,11 +31,11 @@ import java.io.File
import java.util.concurrent.TimeUnit
class DownloadAttachmentWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val sslManager = SSLManager.getInstance(context)
private val certUtil = CertUtil.getInstance(context)
private fun createClient(url: String): OkHttpClient {
val baseUrl = extractBaseUrl(url)
return sslManager.getOkHttpClientBuilder(baseUrl)
return certUtil.getOkHttpClientBuilder(baseUrl)
.callTimeout(15, TimeUnit.MINUTES)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)

View file

@ -7,8 +7,11 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.db.Icon
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.extractBaseUrl
import io.heckel.ntfy.util.sha256
@ -20,11 +23,11 @@ import java.util.Date
import java.util.concurrent.TimeUnit
class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val sslManager = SSLManager.getInstance(context)
private val certUtil = CertUtil.getInstance(context)
private fun createClient(url: String): OkHttpClient {
val baseUrl = extractBaseUrl(url)
return sslManager.getOkHttpClientBuilder(baseUrl)
return certUtil.getOkHttpClientBuilder(baseUrl)
.callTimeout(1, TimeUnit.MINUTES)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)

View file

@ -5,24 +5,30 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.db.ACTION_PROGRESS_FAILED
import io.heckel.ntfy.db.ACTION_PROGRESS_ONGOING
import io.heckel.ntfy.db.ACTION_PROGRESS_SUCCESS
import io.heckel.ntfy.db.Action
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.extractBaseUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.*
import java.util.Locale
import java.util.concurrent.TimeUnit
class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val sslManager = SSLManager.getInstance(context)
private val certUtil = CertUtil.getInstance(context)
private fun createClient(url: String): OkHttpClient {
val baseUrl = extractBaseUrl(url)
return sslManager.getOkHttpClientBuilder(baseUrl)
return certUtil.getOkHttpClientBuilder(baseUrl)
.callTimeout(60, TimeUnit.SECONDS)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)

View file

@ -3,11 +3,14 @@ package io.heckel.ntfy.service
import android.app.AlarmManager
import android.content.Context
import android.os.Build
import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.db.ConnectionState
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.db.User
import io.heckel.ntfy.msg.ApiService.Companion.requestBuilder
import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.util.topicUrlWs
@ -15,7 +18,7 @@ import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.*
import java.util.Calendar
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
@ -42,9 +45,9 @@ class WsConnection(
private val alarmManager: AlarmManager
) : Connection {
private val parser = NotificationParser()
private val sslManager = SSLManager.getInstance(context)
private val certUtil = CertUtil.getInstance(context)
private val client: OkHttpClient by lazy {
sslManager.getOkHttpClientBuilder(connectionId.baseUrl)
certUtil.getOkHttpClientBuilder(connectionId.baseUrl)
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(1, TimeUnit.MINUTES) // The server pings us too, so this doesn't matter much
.connectTimeout(10, TimeUnit.SECONDS)

View file

@ -19,7 +19,7 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -271,10 +271,9 @@ class AddFragment : DialogFragment(), CertificateTrustFragment.CertificateTrustL
private fun handleSSLException(baseUrl: String) {
// Try to fetch the server's certificate
val sslManager = SSLManager.getInstance(requireContext())
val certificate = sslManager.fetchServerCertificate(baseUrl)
val activity = activity ?: return
val certUtil = CertUtil.getInstance(requireContext())
val certificate = certUtil.fetchServerCertificate(baseUrl)
activity.runOnUiThread {
if (certificate != null) {
showCertificateTrustDialog(baseUrl, certificate)

View file

@ -22,7 +22,7 @@ import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.TrustedCertificate
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.AfterChangedTextWatcher
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.validUrl
@ -30,7 +30,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.KeyStore
import java.security.cert.X509Certificate
import java.io.ByteArrayInputStream
import java.text.SimpleDateFormat
import java.util.*
@ -244,13 +243,13 @@ class CertificateFragment : DialogFragment() {
deleteMenuItem.isVisible = true
try {
val x509Cert = SSLManager.parsePemCertificate(trustedCert.pem)
val cert = CertUtil.parseCertificate(trustedCert.pem)
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
subjectText.text = x509Cert.subjectX500Principal.name
issuerText.text = x509Cert.issuerX500Principal.name
subjectText.text = cert.subjectX500Principal.name
issuerText.text = cert.issuerX500Principal.name
fingerprintText.text = trustedCert.fingerprint
validFromText.text = dateFormat.format(x509Cert.notBefore)
validUntilText.text = dateFormat.format(x509Cert.notAfter)
validFromText.text = dateFormat.format(cert.notBefore)
validUntilText.text = dateFormat.format(cert.notAfter)
} catch (e: Exception) {
fingerprintText.text = trustedCert.fingerprint
}
@ -359,8 +358,8 @@ class CertificateFragment : DialogFragment() {
}
lifecycleScope.launch(Dispatchers.IO) {
try {
val x509Cert = SSLManager.parsePemCertificate(certPem!!)
val fingerprint = SSLManager.calculateFingerprint(x509Cert)
val cert = CertUtil.parseCertificate(certPem!!)
val fingerprint = CertUtil.calculateFingerprint(cert)
repository.addTrustedCertificate(fingerprint, certPem!!)
withContext(Dispatchers.Main) {
Toast.makeText(context, R.string.certificate_dialog_added_toast, Toast.LENGTH_SHORT).show()

View file

@ -8,7 +8,7 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.db.ClientCertificate
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.TrustedCertificate
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.shortUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -50,11 +50,11 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen
certs.forEach { trustedCert ->
try {
val x509Cert = SSLManager.parsePemCertificate(trustedCert.pem)
val cert = CertUtil.parseCertificate(trustedCert.pem)
val pref = Preference(preferenceScreen.context)
pref.title = getDisplaySubject(x509Cert)
pref.summary = if (isValid(x509Cert)) {
getString(R.string.settings_certificates_prefs_expires, dateFormat.format(x509Cert.notAfter))
pref.title = getDisplaySubject(cert)
pref.summary = if (isValid(cert)) {
getString(R.string.settings_certificates_prefs_expires, dateFormat.format(cert.notAfter))
} else {
getString(R.string.settings_certificates_prefs_expired)
}

View file

@ -12,9 +12,10 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.tls.SSLManager
import io.heckel.ntfy.util.CertUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.*
@ -26,6 +27,7 @@ import java.util.*
class CertificateTrustFragment : DialogFragment() {
private lateinit var listener: CertificateTrustListener
private lateinit var certificate: X509Certificate
private lateinit var certificatePem: String
private lateinit var baseUrl: String
private lateinit var repository: Repository
@ -61,8 +63,9 @@ class CertificateTrustFragment : DialogFragment() {
?: throw IllegalArgumentException("Base URL required")
// Parse the certificate
val certFactory = java.security.cert.CertificateFactory.getInstance("X.509")
val certFactory = CertificateFactory.getInstance("X.509")
certificate = certFactory.generateCertificate(java.io.ByteArrayInputStream(certBytes)) as X509Certificate
certificatePem = certBytes.toString()
// Build the view
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_certificate_trust_dialog, null)
@ -118,7 +121,7 @@ class CertificateTrustFragment : DialogFragment() {
// Populate certificate details
subjectText.text = certificate.subjectX500Principal.name
issuerText.text = certificate.issuerX500Principal.name
fingerprintText.text = SSLManager.calculateFingerprint(certificate)
fingerprintText.text = CertUtil.calculateFingerprint(certificate)
validFromText.text = dateFormat.format(certificate.notBefore)
validUntilText.text = dateFormat.format(certificate.notAfter)
@ -141,9 +144,8 @@ class CertificateTrustFragment : DialogFragment() {
private fun trustCertificate() {
lifecycleScope.launch(Dispatchers.IO) {
val fingerprint = SSLManager.calculateFingerprint(certificate)
val pem = SSLManager.encodeToPem(certificate)
repository.addTrustedCertificate(fingerprint, pem)
val fingerprint = CertUtil.calculateFingerprint(certificate)
repository.addTrustedCertificate(fingerprint, certificatePem)
}
listener.onCertificateTrusted(baseUrl, certificate)
dismiss()

View file

@ -1,4 +1,4 @@
package io.heckel.ntfy.tls
package io.heckel.ntfy.util
import android.annotation.SuppressLint
import android.content.Context
@ -6,22 +6,25 @@ import android.util.Base64
import io.heckel.ntfy.db.ClientCertificate
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.TrustedCertificate
import io.heckel.ntfy.util.Log
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import java.io.ByteArrayInputStream
import java.net.URL
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlin.collections.addAll
/**
* Manages SSL/TLS configuration for OkHttpClient instances.
@ -32,9 +35,9 @@ import javax.net.ssl.X509TrustManager
*
* Uses standard TrustManagerFactory and KeyManagerFactory (not custom implementations).
*/
class SSLManager private constructor(context: Context) {
class CertUtil private constructor(context: Context) {
private val appContext: Context = context.applicationContext
private val repository: Repository by lazy { Repository.getInstance(appContext) }
private val repository: Repository by lazy { Repository.Companion.getInstance(appContext) }
/**
* Get an OkHttpClient.Builder configured with custom SSL for a specific server
@ -87,7 +90,7 @@ class SSLManager private constructor(context: Context) {
// Custom hostname verifier that bypasses only for user-trusted certs
if (trustedFingerprints.isNotEmpty()) {
builder.hostnameVerifier { hostname, session ->
val defaultVerifier = javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier()
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
if (defaultVerifier.verify(hostname, session)) {
return@hostnameVerifier true
}
@ -134,7 +137,7 @@ class SSLManager private constructor(context: Context) {
capturedCert = chain[0]
}
// Always throw to prevent actual connection
throw javax.net.ssl.SSLException("Certificate captured for inspection")
throw SSLException("Certificate captured for inspection")
}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
@ -144,7 +147,7 @@ class SSLManager private constructor(context: Context) {
sslContext.init(null, arrayOf(trustManager), null)
try {
val url = java.net.URL(baseUrl)
val url = URL(baseUrl)
val host = url.host
val port = when {
url.port != -1 -> url.port
@ -179,7 +182,7 @@ class SSLManager private constructor(context: Context) {
// Add user-trusted certificates
trustedCerts.forEachIndexed { index, trustedCert ->
try {
val cert = parsePemCertificate(trustedCert.pem)
val cert = parseCertificate(trustedCert.pem)
keyStore.setCertificateEntry("user$index", cert)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse trusted certificate: ${trustedCert.fingerprint}", e)
@ -240,44 +243,23 @@ class SSLManager private constructor(context: Context) {
@Volatile
@SuppressLint("StaticFieldLeak") // Only holds applicationContext
private var instance: SSLManager? = null
private var instance: CertUtil? = null
fun getInstance(context: Context): SSLManager {
fun getInstance(context: Context): CertUtil {
return instance ?: synchronized(this) {
instance ?: SSLManager(context).also { instance = it }
instance ?: CertUtil(context).also { instance = it }
}
}
/**
* Calculate SHA-256 fingerprint of a certificate
*/
fun calculateFingerprint(cert: X509Certificate): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(cert.encoded)
return digest.joinToString(":") { "%02X".format(it) }
}
/**
* Encode certificate to PEM format
*/
fun encodeToPem(cert: X509Certificate): String {
val base64 = Base64.encodeToString(cert.encoded, Base64.NO_WRAP)
val sb = StringBuilder()
sb.append("-----BEGIN CERTIFICATE-----\n")
var i = 0
while (i < base64.length) {
val end = minOf(i + 64, base64.length)
sb.append(base64.substring(i, end))
sb.append("\n")
i += 64
}
sb.append("-----END CERTIFICATE-----")
return sb.toString()
}
fun parsePemCertificate(pem: String): X509Certificate {
fun parseCertificate(pem: String): X509Certificate {
val factory = CertificateFactory.getInstance("X.509")
return factory.generateCertificate(pem.byteInputStream()) as X509Certificate
}
}
}
}

View file

@ -502,11 +502,11 @@
<string name="settings_certificates_prefs_trusted_header">Trusted server certificates</string>
<string name="settings_certificates_prefs_trusted_add">Add certificate</string>
<string name="settings_certificates_prefs_trusted_add_title">Add a trusted certificate</string>
<string name="settings_certificates_prefs_trusted_add_summary">Import a PEM-formatted server or CA certificate file to trust</string>
<string name="settings_certificates_prefs_trusted_add_summary">Import a server or CA certificate to the trust store (PEM format)</string>
<string name="settings_certificates_prefs_client_header">Client certificates (mTLS)</string>
<string name="settings_certificates_prefs_client_add">Add client certificate</string>
<string name="settings_certificates_prefs_client_add_title">Add a client certificate</string>
<string name="settings_certificates_prefs_client_add_summary">Import PKCS#12 certificate for mutual TLS authentication</string>
<string name="settings_certificates_prefs_client_add_summary">Import certificate for mutual TLS authentication (PKCS#12 format)</string>
<string name="settings_certificates_prefs_client_configured">Client certificate configured</string>
<string name="settings_certificates_prefs_expires">Expires %1$s</string>
<string name="settings_certificates_prefs_expired">Expired</string>