Replace visible collection IDs with steganographic encoding

This commit is contained in:
Arnau Mora
2025-08-11 19:13:59 +02:00
parent eb4224780a
commit 37762d8ddf
4 changed files with 52 additions and 4 deletions

View File

@@ -22,6 +22,8 @@ import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.MARKER
import at.bitfire.davdroid.util.encodeNumber
import com.google.common.base.CharMatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
@@ -74,7 +76,7 @@ class LocalAddressBookStore @Inject constructor(
sb.append(" (${service.accountName})")
}
// Add the collection ID for uniqueness
sb.append(" #${info.id}")
sb.append(MARKER + encodeNumber(info.id))
return sb.toString()
}

View File

@@ -8,6 +8,8 @@ import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import at.bitfire.davdroid.util.MARKER
import at.bitfire.davdroid.util.decodeWithMarker
import java.util.logging.Logger
object SystemAccountUtils {
@@ -67,4 +69,9 @@ fun AccountManager.setAndVerifyUserData(account: Account, key: String, value: St
Thread.sleep(100)
}
Logger.getGlobal().warning("AccountManager failed to set $account user data $key := $value")
}
}
fun Account.nameWithNumber(): String {
val number = decodeWithMarker(name) ?: return name
return name.substringBeforeLast(MARKER) + " (#" + number + ")"
}

View File

@@ -12,6 +12,9 @@ import android.os.Bundle
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.nameWithNumber
import at.bitfire.davdroid.util.MARKER
import at.bitfire.davdroid.util.decodeWithMarker
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -256,7 +259,7 @@ class SyncFrameworkIntegration @Inject constructor(
private fun anyPendingSync(accounts: List<Account>, authority: String): Boolean =
accounts.any { account ->
ContentResolver.isSyncPending(account, authority).also { pending ->
logger.finer("Sync pending($account, $authority) = $pending")
logger.finer("Sync pending(name=${account.nameWithNumber()}, type=${account.type}, $authority) = $pending")
}
}

View File

@@ -13,4 +13,40 @@ fun String.withTrailingSlash() =
if (this.endsWith('/'))
this
else
"$this/"
"$this/"
const val MARKER = '\uFEFF' // Start of hidden payload
const val ZERO = '\u200B' // Zero Width Space for 0
const val ONE = '\u200C' // Zero Width Non-Joiner for 1
/**
* Encodes a number using our own steganographic encoding scheme to hide a number in UTF-8 invisible characters.
*/
fun encodeNumber(num: Long): String {
require(num >= 0) { "Only non-negative integers are supported" }
if (num == 0L) return ZERO.toString() // Represent zero as a single zero-width space
val binary = num.toString(2) // e.g., 8 -> "1000"
val builder = StringBuilder()
for (bit in binary) {
builder.append(
if (bit == '0') ZERO // Zero Width Space
else ONE // Zero Width Non-Joiner
)
}
return builder.toString()
}
fun decodeWithMarker(text: String): Int? {
val markerIndex = text.indexOf(MARKER)
if (markerIndex == -1) return null // No hidden payload
val hiddenPart = text.substring(markerIndex + 1)
val binary = hiddenPart.map {
when (it) {
ZERO -> '0'
ONE -> '1'
else -> throw IllegalArgumentException("Invalid hidden character")
}
}.joinToString("")
return binary.toInt(2)
}