Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b008091
[refactor] Add fallback base URL build config aliases
DongJun-H Jun 9, 2026
ea76729
[feat] Add Remote Config base URL provider
DongJun-H Jun 9, 2026
44b6bdb
[feat] Rewrite network base URL dynamically
DongJun-H Jun 9, 2026
73e6380
[feat] Provide runtime base URL to entry screens
DongJun-H Jun 9, 2026
c63da77
[refactor] Use runtime base URL in post list views
DongJun-H Jun 9, 2026
041c9c9
[refactor] Use runtime base URL in detail and comment views
DongJun-H Jun 9, 2026
07e2a73
[refactor] Use runtime base URL in folder views
DongJun-H Jun 9, 2026
60701cb
[refactor] Use runtime base URL in write screens
DongJun-H Jun 9, 2026
604e915
[refactor] Use runtime base URL in bookmark screen
DongJun-H Jun 9, 2026
c012375
[refactor] Use runtime base URL in mypage screens
DongJun-H Jun 9, 2026
aa59c4f
[refactor] Use runtime base URL in follow profile screens
DongJun-H Jun 9, 2026
5a75ae3
[refactor] Use runtime base URL in settings screens
DongJun-H Jun 9, 2026
5c77a56
[refactor] Use runtime base URL in search screens
DongJun-H Jun 9, 2026
5652577
[refactor] Use runtime base URL in notification user screens
DongJun-H Jun 9, 2026
39930f4
[refactor] Use runtime base URL in rules screen
DongJun-H Jun 9, 2026
834b25a
[refactor] Remove legacy base URL build config aliases
DongJun-H Jun 9, 2026
4f6511d
[fix] Harden dynamic base URL security
DongJun-H Jun 9, 2026
8248e10
[fix] Canonicalize dynamic base URL origins
DongJun-H Jun 10, 2026
0e99cd2
[fix] Require Remote Config public key for release
DongJun-H Jun 10, 2026
b669f8c
[fix] Skip blank remote image URLs
DongJun-H Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,26 @@ Properties properties = new Properties()
properties.load(project.rootProject.file('local.properties').newDataInputStream())
def NATIVE_APP_KEY = properties.getProperty('NATIVE_APP_KEY')
def ADS_APPLICATION_ID = properties.getProperty('ADS_APPLICATION_ID')
def REMOTE_CONFIG_BASE_URL_PUBLIC_KEY = properties.getProperty('REMOTE_CONFIG_BASE_URL_PUBLIC_KEY', '').trim()
def keystorePropertiesFile = rootProject.file("app/keystore-release.properties")
def buildConfigString = { String value ->
def escaped = (value ?: '')
.replace('\\', '\\\\')
.replace('"', '\\"')
.replace('\n', '\\n')
return "\"${escaped}\""
}
def fallbackBaseUrlProperty = { String fallbackKey, String legacyKey ->
def value = properties.getProperty(fallbackKey) ?: properties.getProperty(legacyKey)
if (!value) {
throw new GradleException("Missing ${fallbackKey} in local.properties")
}
return value
}
def FALLBACK_BASE_URL_DEV = fallbackBaseUrlProperty('FALLBACK_BASE_URL_DEV', 'BASE_URL_DEV')
def FALLBACK_BASE_URL_PROD = fallbackBaseUrlProperty('FALLBACK_BASE_URL_PROD', 'BASE_URL_PROD')
def USES_DEV_CLEARTEXT_TRAFFIC = FALLBACK_BASE_URL_DEV?.contains('http://') == true
def USES_PROD_CLEARTEXT_TRAFFIC = FALLBACK_BASE_URL_PROD?.contains('http://') == true

kotlin {
jvmToolchain(17)
Expand All @@ -35,9 +54,11 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

buildConfigField ("String", "NATIVE_APP_KEY", properties['NATIVE_APP_KEY_STR'])
buildConfigField("String", "REMOTE_CONFIG_BASE_URL_PUBLIC_KEY", buildConfigString(REMOTE_CONFIG_BASE_URL_PUBLIC_KEY))
manifestPlaceholders = [
NATIVE_APP_KEY : NATIVE_APP_KEY,
ADS_APPLICATION_ID: ADS_APPLICATION_ID
NATIVE_APP_KEY : NATIVE_APP_KEY,
ADS_APPLICATION_ID : ADS_APPLICATION_ID,
USES_CLEARTEXT_TRAFFIC: false
]
}

Expand All @@ -64,6 +85,7 @@ android {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
resValue "string", "app_name", "DAYO (Debug)"
manifestPlaceholders.USES_CLEARTEXT_TRAFFIC = true
}
release {
resValue "string", "app_name", "DAYO"
Expand All @@ -76,16 +98,40 @@ android {
productFlavors {
dev {
dimension "environment"
buildConfigField("String", "BASE_URL", properties['BASE_URL_DEV'])
manifestPlaceholders.USES_CLEARTEXT_TRAFFIC = USES_DEV_CLEARTEXT_TRAFFIC
buildConfigField("String", "FALLBACK_BASE_URL", FALLBACK_BASE_URL_DEV)
buildConfigField("String", "REMOTE_CONFIG_BASE_URL_KEY", "\"api_base_url_dev\"")
buildConfigField("String", "REMOTE_CONFIG_BASE_URL_SIGNATURE_KEY", "\"api_base_url_dev_signature\"")
}
prod {
dimension "environment"
buildConfigField("String", "BASE_URL", properties['BASE_URL_PROD'])
manifestPlaceholders.USES_CLEARTEXT_TRAFFIC = USES_PROD_CLEARTEXT_TRAFFIC
buildConfigField("String", "FALLBACK_BASE_URL", FALLBACK_BASE_URL_PROD)
buildConfigField("String", "REMOTE_CONFIG_BASE_URL_KEY", "\"api_base_url_prod\"")
buildConfigField("String", "REMOTE_CONFIG_BASE_URL_SIGNATURE_KEY", "\"api_base_url_prod_signature\"")
}
}
namespace 'com.daily.dayo'
}

def validateRemoteConfigBaseUrlPublicKeyForRelease = tasks.register(
'validateRemoteConfigBaseUrlPublicKeyForRelease'
) {
doLast {
if (!REMOTE_CONFIG_BASE_URL_PUBLIC_KEY) {
throw new GradleException(
'Missing REMOTE_CONFIG_BASE_URL_PUBLIC_KEY in local.properties for release build'
)
}
}
}

tasks.configureEach { task ->
if (task.name ==~ /^pre.*ReleaseBuild$/) {
task.dependsOn(validateRemoteConfigBaseUrlPublicKeyForRelease)
}
}

dependencies {
// multiModule
implementation project(':presentation')
Expand All @@ -107,7 +153,8 @@ dependencies {
implementation platform('com.google.firebase:firebase-bom:34.6.0')
implementation "com.google.firebase:firebase-crashlytics"
implementation "com.google.firebase:firebase-analytics"
implementation "com.google.firebase:firebase-config"

// Google Ads
implementation 'com.google.android.gms:play-services-ads:24.7.0'
}
}
4 changes: 2 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<application
android:name=".DayoApplication"
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="${USES_CLEARTEXT_TRAFFIC}"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
Expand Down Expand Up @@ -73,4 +73,4 @@
</service>
</application>

</manifest>
</manifest>
262 changes: 262 additions & 0 deletions app/src/main/java/com/daily/dayo/config/RemoteConfigBaseUrlProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
package com.daily.dayo.config

import android.content.Context
import android.content.SharedPreferences
import com.daily.dayo.BuildConfig
import com.google.android.gms.tasks.Task
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings
import daily.dayo.domain.provider.BaseUrlSource
import daily.dayo.domain.provider.BaseUrlProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.net.InetAddress
import java.net.URI
import java.security.KeyFactory
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

class RemoteConfigBaseUrlProvider(
context: Context,
private val remoteConfig: FirebaseRemoteConfig
) : BaseUrlProvider {

private val sharedPreferences: SharedPreferences = context.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE
)

@Volatile
private var currentBaseUrlState: BaseUrlState = getInitialBaseUrlState()

init {
remoteConfig.setConfigSettingsAsync(
FirebaseRemoteConfigSettings.Builder()
.setFetchTimeoutInSeconds(FETCH_TIMEOUT_SECONDS)
.setMinimumFetchIntervalInSeconds(MINIMUM_FETCH_INTERVAL_SECONDS)
.build()
)
remoteConfig.setDefaultsAsync(
mapOf(
BuildConfig.REMOTE_CONFIG_BASE_URL_KEY to BuildConfig.FALLBACK_BASE_URL,
BuildConfig.REMOTE_CONFIG_BASE_URL_SIGNATURE_KEY to ""
)
)
}

override fun getBaseUrl(): String = currentBaseUrlState.baseUrl

override fun getBaseUrlSource(): BaseUrlSource = currentBaseUrlState.source

override fun isTrustedBaseUrl(baseUrl: String): Boolean {
val normalizedBaseUrl = normalizeBaseUrl(baseUrl) ?: return false
return normalizedBaseUrl == currentBaseUrlState.baseUrl ||
normalizedBaseUrl == normalizeBaseUrl(BuildConfig.FALLBACK_BASE_URL)
}

override suspend fun refreshBaseUrl(): Boolean {
return try {
remoteConfig.fetchAndActivate().awaitWithTimeout() ?: return false
val remoteBaseUrlValue = remoteConfig.getValue(BuildConfig.REMOTE_CONFIG_BASE_URL_KEY)
if (remoteBaseUrlValue.source != FirebaseRemoteConfig.VALUE_SOURCE_REMOTE) {
return false
}

val remoteBaseUrl = remoteBaseUrlValue.asString()
val remoteBaseUrlSignature = remoteConfig.getString(BuildConfig.REMOTE_CONFIG_BASE_URL_SIGNATURE_KEY)
val normalizedBaseUrl = normalizeBaseUrl(remoteBaseUrl) ?: run {
return false
}
if (!isTrustedRemoteBaseUrl(normalizedBaseUrl, remoteBaseUrlSignature)) {
return false
}

currentBaseUrlState = BaseUrlState(
baseUrl = normalizedBaseUrl,
source = BaseUrlSource.REMOTE_CONFIG
)
sharedPreferences.edit()
.putString(KEY_LAST_SUCCESSFUL_BASE_URL, normalizedBaseUrl)
.putString(KEY_LAST_SUCCESSFUL_BASE_URL_SIGNATURE, remoteBaseUrlSignature)
.apply()
true
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
false
}
}

private fun getInitialBaseUrlState(): BaseUrlState {
val cachedBaseUrl = sharedPreferences.getString(KEY_LAST_SUCCESSFUL_BASE_URL, null)
val cachedBaseUrlSignature = sharedPreferences.getString(KEY_LAST_SUCCESSFUL_BASE_URL_SIGNATURE, null)
normalizeBaseUrl(cachedBaseUrl)
?.takeIf { baseUrl -> isTrustedCachedBaseUrl(baseUrl, cachedBaseUrlSignature) }
?.let { baseUrl ->
return BaseUrlState(
baseUrl = baseUrl,
source = BaseUrlSource.CACHED_LAST_SUCCESS
)
}
if (cachedBaseUrl != null) {
sharedPreferences.edit()
.remove(KEY_LAST_SUCCESSFUL_BASE_URL)
.remove(KEY_LAST_SUCCESSFUL_BASE_URL_SIGNATURE)
.apply()
}

return BaseUrlState(
baseUrl = normalizeBaseUrl(BuildConfig.FALLBACK_BASE_URL) ?: BuildConfig.FALLBACK_BASE_URL,
source = BaseUrlSource.FALLBACK
)
}

private suspend fun Task<Boolean>.awaitWithTimeout(): Boolean? {
return withTimeoutOrNull(TASK_TIMEOUT_MILLIS) {
suspendCancellableCoroutine { continuation ->
addOnCompleteListener { task ->
if (!continuation.isActive) return@addOnCompleteListener

val exception = task.exception
if (exception != null) {
continuation.resumeWithException(exception)
} else {
continuation.resume(task.result == true)
}
}
}
}
}

private fun normalizeBaseUrl(baseUrl: String?): String? {
val trimmedBaseUrl = baseUrl?.trim().orEmpty()
if (trimmedBaseUrl.isEmpty()) return null

return try {
val uri = URI(trimmedBaseUrl)
if (uri.rawQuery != null || uri.rawFragment != null) return null

val scheme = uri.scheme?.lowercase(Locale.US) ?: return null
val host = uri.host?.takeIf { it.isNotBlank() }?.lowercase(Locale.US) ?: return null
if (scheme != SCHEME_HTTP && scheme != SCHEME_HTTPS) return null
if (!BuildConfig.DEBUG && scheme == SCHEME_HTTP && !isReleaseHttpAllowed()) return null
if (uri.userInfo != null) return null
if (!BuildConfig.DEBUG && isUnsafeReleaseHost(host)) return null

val path = uri.path.orEmpty()
if (path.isNotBlank() && path != "/") return null

val canonicalPort = when {
scheme == SCHEME_HTTP && uri.port == DEFAULT_HTTP_PORT -> -1
scheme == SCHEME_HTTPS && uri.port == DEFAULT_HTTPS_PORT -> -1
else -> uri.port
}

val normalizedUri = URI(scheme, null, host, canonicalPort, "/", null, null)
normalizedUri.toString().ensureTrailingSlash()
} catch (exception: Exception) {
null
}
}

private fun isTrustedRemoteBaseUrl(normalizedBaseUrl: String, signature: String): Boolean {
if (normalizedBaseUrl == normalizeBaseUrl(BuildConfig.FALLBACK_BASE_URL)) return true
if (BuildConfig.DEBUG) return true

return verifyBaseUrlSignature(
baseUrl = normalizedBaseUrl,
signature = signature
)
}

private fun isTrustedCachedBaseUrl(normalizedBaseUrl: String, signature: String?): Boolean {
if (normalizedBaseUrl == normalizeBaseUrl(BuildConfig.FALLBACK_BASE_URL)) return true
if (BuildConfig.DEBUG) return true

return verifyBaseUrlSignature(
baseUrl = normalizedBaseUrl,
signature = signature.orEmpty()
)
}

private fun verifyBaseUrlSignature(baseUrl: String, signature: String): Boolean {
val publicKeyText = BuildConfig.REMOTE_CONFIG_BASE_URL_PUBLIC_KEY.stripPemFormatting()
val signatureText = signature.stripPemFormatting()
if (publicKeyText.isBlank() || signatureText.isBlank()) return false

return runCatching {
val publicKey = KeyFactory.getInstance(KEY_ALGORITHM_RSA).generatePublic(
X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyText))
)
val verifier = Signature.getInstance(SIGNATURE_ALGORITHM)
verifier.initVerify(publicKey)
verifier.update(baseUrl.toByteArray(Charsets.UTF_8))
verifier.verify(Base64.getDecoder().decode(signatureText))
}.getOrDefault(false)
}

private fun isReleaseHttpAllowed(): Boolean =
runCatching {
URI(BuildConfig.FALLBACK_BASE_URL).scheme?.lowercase(Locale.US) == SCHEME_HTTP
}.getOrDefault(false)

private fun isUnsafeReleaseHost(host: String): Boolean {
val normalizedHost = host.trim('[', ']')
if (normalizedHost.equals("localhost", ignoreCase = true)) return true
if (normalizedHost.endsWith(".localhost", ignoreCase = true)) return true
if (normalizedHost.endsWith(".local", ignoreCase = true)) return true
if (!normalizedHost.looksLikeIpLiteral()) return false

return runCatching {
val address = InetAddress.getByName(normalizedHost)
address.isAnyLocalAddress ||
address.isLoopbackAddress ||
address.isLinkLocalAddress ||
address.isSiteLocalAddress ||
address.isMulticastAddress
}.getOrDefault(true)
}

private fun String.looksLikeIpLiteral(): Boolean =
IPV4_ADDRESS_REGEX.matches(this) || contains(':')

private fun String.stripPemFormatting(): String =
replace(PEM_PUBLIC_KEY_BEGIN, "")
.replace(PEM_PUBLIC_KEY_END, "")
.replace(PEM_SIGNATURE_BEGIN, "")
.replace(PEM_SIGNATURE_END, "")
.filterNot { it.isWhitespace() }

private fun String.ensureTrailingSlash(): String =
if (endsWith('/')) this else "$this/"

private data class BaseUrlState(
val baseUrl: String,
val source: BaseUrlSource
)

companion object {
private const val PREFERENCES_NAME = "remote_config_base_url"
private const val KEY_LAST_SUCCESSFUL_BASE_URL = "last_successful_base_url"
private const val KEY_LAST_SUCCESSFUL_BASE_URL_SIGNATURE = "last_successful_base_url_signature"
private const val FETCH_TIMEOUT_SECONDS = 5L
private const val TASK_TIMEOUT_MILLIS = 6_000L
private val MINIMUM_FETCH_INTERVAL_SECONDS = if (BuildConfig.DEBUG) 0L else 3_600L
private const val SCHEME_HTTP = "http"
private const val SCHEME_HTTPS = "https"
private const val DEFAULT_HTTP_PORT = 80
private const val DEFAULT_HTTPS_PORT = 443
private const val KEY_ALGORITHM_RSA = "RSA"
private const val SIGNATURE_ALGORITHM = "SHA256withRSA"
private const val PEM_PUBLIC_KEY_BEGIN = "-----BEGIN PUBLIC KEY-----"
private const val PEM_PUBLIC_KEY_END = "-----END PUBLIC KEY-----"
private const val PEM_SIGNATURE_BEGIN = "-----BEGIN SIGNATURE-----"
private const val PEM_SIGNATURE_END = "-----END SIGNATURE-----"
private val IPV4_ADDRESS_REGEX = Regex("""\d{1,3}(\.\d{1,3}){3}""")
}
}
Loading
Loading