Fix bugs and add AOSP Android.bp

- gen-db.py: fix DB output path (assets/ not res/raw/)
- DeviceName: use Build.DEVICE for DB lookup, Build.PRODUCT for AltDeviceNames
- DeviceName: fix capitalize() for API 36 (replaceFirstChar)
- DBHelper: add 24h rate-limit on update checks via SharedPreferences
- DBHelper: make DB download atomic (write to .tmp then rename)
- DeviceInfo: wrap getCurrentDeviceInfo() in withContext(Dispatchers.IO)
- DeviceInfo: add 30-day SharedPreferences cache for device image URLs
- AltDeviceNames: rewrite with Entry class + byModel index for O(1) lookup
- Android.bp: add android_library module for AOSP source build
This commit is contained in:
oxmc
2026-06-17 06:12:27 -07:00
parent 22177d3be4
commit 13bc3ae50e
6 changed files with 145 additions and 154 deletions
+14
View File
@@ -0,0 +1,14 @@
android_library {
name: "android-device-info",
srcs: ["library/src/main/java/**/*.kt"],
asset_dirs: ["library/src/main/assets"],
sdk_version: "current",
min_sdk_version: "36",
kotlincflags: ["-Xjvm-default=all"],
static_libs: [
"kotlinx-coroutines-android",
"androidx.annotation_annotation",
"androidx.core_core-ktx",
],
visibility: ["//visibility:public"],
}
+1 -1
View File
@@ -7,7 +7,7 @@ from io import StringIO
# Constants
CSV_URL = "https://storage.googleapis.com/play_public/supported_devices.csv"
DB_PATH = "library/src/main/res/raw/android-devices.db"
DB_PATH = "library/src/main/assets/android_devices.db"
ZIP_PATH = "database/android-devices.zip"
# Ensure output directory exists
@@ -19,135 +19,101 @@ package dev.oxmc.androiddeviceinfo
object AltDeviceNames {
/**
* A map of device identifiers to their alternate names.
* The key is a Triple containing:
* - List of model names
* - List of manufacturer names
* - List of codenames (can be empty)
*
* The value is the alternate name for the device.
* Represents a consumer device product, which may have multiple model numbers, manufacturer
* names (e.g. rebrands), and codenames (Build.PRODUCT values). Empty codenames means the
* entry matches regardless of codename.
*/
private val deviceNames: Map<Triple<List<String>, List<String>, List<String>>, String> = mapOf(
private class Entry(
models: List<String>,
manufacturers: List<String>,
codenames: List<String> = emptyList(),
val name: String
) {
private val modelSet = models.mapTo(HashSet()) { it.uppercase() }
private val mfrSet = manufacturers.mapTo(HashSet()) { it.uppercase() }
// empty = match any codename; non-empty = must match one of these
private val codenameSet = codenames.filterNot { it.isEmpty() }.mapTo(HashSet()) { it.uppercase() }
val models: List<String> = models
fun matches(model: String, manufacturer: String, codename: String): Boolean =
model.uppercase() in modelSet &&
manufacturer.uppercase() in mfrSet &&
(codenameSet.isEmpty() || codename.uppercase() in codenameSet)
}
private val entries = listOf(
// Meta / Oculus / Facebook
Triple(listOf("Meta Quest 3"), listOf("Meta", "oculus"), listOf("")) to "Meta Quest 3",
Triple(listOf("Meta Quest Pro"), listOf("Meta", "oculus"), listOf("")) to "Meta Quest Pro",
Triple(listOf("Meta Quest 2"), listOf("Meta", "oculus"), listOf("")) to "Meta Quest 2",
Triple(listOf("Meta Quest"), listOf("Meta", "oculus"), listOf("")) to "Meta Quest",
Entry(listOf("Meta Quest 3"), listOf("Meta", "oculus"), name = "Meta Quest 3"),
Entry(listOf("Meta Quest Pro"), listOf("Meta", "oculus"), name = "Meta Quest Pro"),
Entry(listOf("Meta Quest 2"), listOf("Meta", "oculus"), name = "Meta Quest 2"),
Entry(listOf("Meta Quest"), listOf("Meta", "oculus"), name = "Meta Quest"),
// ZTE
Triple(listOf("Z6251"), listOf("ZTE"), listOf("Z6251")) to "ZMax 11",
Entry(listOf("Z6251"), listOf("ZTE"), listOf("Z6251"), "ZMax 11"),
// OnePlus
Triple(listOf("CPH2195"), listOf("Oppo"), listOf("")) to "OPPO A54 5G",
Triple(listOf("OP595DL1", "CPH2583"), listOf("Oppo"), listOf("")) to "OnePlus 12",
// OnePlus / Oppo
Entry(listOf("CPH2195"), listOf("Oppo"), name = "OPPO A54 5G"),
Entry(listOf("OP595DL1", "CPH2583"), listOf("Oppo"), name = "OnePlus 12"),
// Emulators/sdk
Triple(listOf("sdk_gphone_x86"), listOf("Google"), listOf("")) to "Android SDK (x86)",
Triple(listOf("sdk_gphone_x86_64"), listOf("Google"), listOf("")) to "Android SDK (x86_64)",
Triple(listOf("sdk_gphone_arm64"), listOf("Google"), listOf("")) to "Android SDK (arm64)",
Triple(listOf("sdk_gphone_arm"), listOf("Google"), listOf("")) to "Android SDK (arm)",
// Emulators
Entry(listOf("sdk_gphone_x86"), listOf("Google"), name = "Android SDK (x86)"),
Entry(listOf("sdk_gphone_x86_64"), listOf("Google"), name = "Android SDK (x86_64)"),
Entry(listOf("sdk_gphone_arm64"), listOf("Google"), name = "Android SDK (arm64)"),
Entry(listOf("sdk_gphone_arm"), listOf("Google"), name = "Android SDK (arm)"),
// Google
Triple(listOf("Pixel 6"), listOf("Google"), listOf("")) to "Pixel 6",
Entry(listOf("Pixel 6"), listOf("Google"), name = "Pixel 6"),
// Motorola
Triple(listOf("moto g power (2021)"), listOf("Motorola"), listOf("")) to "Moto G Power (2021)",
Triple(listOf("XT2163-4"), listOf("Motorola"), listOf("")) to "Moto G Pure",
Entry(listOf("moto g power (2021)"), listOf("Motorola"), name = "Moto G Power (2021)"),
Entry(listOf("XT2163-4"), listOf("Motorola"), name = "Moto G Pure"),
// LG
Triple(listOf("LGLS675", "LS675"), listOf("LG"), listOf("LG M1", "")) to "LG Tribute 5",
Entry(listOf("LGLS675", "LS675"), listOf("LG"), listOf("LG M1"), "LG Tribute 5"),
// Samsung (some with empty codenames)
Triple(listOf("SM-S928U"), listOf("SAMSUNG"), listOf("e3qsqw")) to "Galaxy S24 Ultra",
Triple(listOf("SM-S901U"), listOf("SAMSUNG"), listOf("r0qsqw")) to "Samsung Galaxy S22",
Triple(listOf("SM-A526U"), listOf("SAMSUNG"), listOf("")) to "Galaxy A52 5G (US)",
Triple(listOf("SM-A326U"), listOf("SAMSUNG"), listOf("")) to "Galaxy A32 5G (US)",
Triple(listOf("SM-G998U"), listOf("SAMSUNG"), listOf("")) to "Galaxy S21 Ultra 5G (US)",
Triple(listOf("SM-G998W"), listOf("SAMSUNG"), listOf("p3qcsx")) to "Galaxy S21 Ultra 5G (CA)",
Triple(listOf("SM-G991U"), listOf("SAMSUNG"), listOf("")) to "Galaxy S21 5G",
Triple(listOf("SM-A125F"), listOf("SAMSUNG"), listOf("")) to "Galaxy A12",
Triple(listOf("SM-T290"), listOf("SAMSUNG"), listOf("gtowifixx")) to "Galaxy Tab A8",
Triple(listOf("SM-G780F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S20 FE",
Triple(listOf("SM-N986U"), listOf("SAMSUNG"), listOf("")) to "Galaxy Note 20 Ultra 5G",
Triple(listOf("SM-N981U"), listOf("SAMSUNG"), listOf("")) to "Galaxy Note 20 5G",
Triple(listOf("A013G"), listOf("SAMSUNG"), listOf("")) to "Galaxy A01 Core",
Triple(listOf("SM-A715F"), listOf("SAMSUNG"), listOf("")) to "Galaxy A71",
Triple(listOf("SM-A515F"), listOf("SAMSUNG"), listOf("")) to "Galaxy A51",
Triple(listOf("SM-G975F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S10+",
Triple(listOf("SM-G973F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S10",
Triple(listOf("SM-G970F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S10e",
Triple(listOf("SM-N960F"), listOf("SAMSUNG"), listOf("")) to "Galaxy Note 9",
Triple(listOf("SM-G965F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S9+",
Triple(listOf("SM-G960F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S9",
Triple(listOf("SM-N950F"), listOf("SAMSUNG"), listOf("")) to "Galaxy Note 8",
Triple(listOf("SM-J730F"), listOf("SAMSUNG"), listOf("")) to "Galaxy J7 (2017)",
Triple(listOf("SM-J530F"), listOf("SAMSUNG"), listOf("")) to "Galaxy J5 (2017)",
Triple(listOf("SM-J330F"), listOf("SAMSUNG"), listOf("")) to "Galaxy J3 (2017)",
Triple(listOf("SM-G955F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S8+",
Triple(listOf("SM-G950F"), listOf("SAMSUNG"), listOf("")) to "Galaxy S8"
// Samsung
Entry(listOf("SM-S928U"), listOf("SAMSUNG"), listOf("e3qsqw"), "Galaxy S24 Ultra"),
Entry(listOf("SM-S901U"), listOf("SAMSUNG"), listOf("r0qsqw"), "Samsung Galaxy S22"),
Entry(listOf("SM-A526U"), listOf("SAMSUNG"), name = "Galaxy A52 5G (US)"),
Entry(listOf("SM-A326U"), listOf("SAMSUNG"), name = "Galaxy A32 5G (US)"),
Entry(listOf("SM-G998U"), listOf("SAMSUNG"), name = "Galaxy S21 Ultra 5G (US)"),
Entry(listOf("SM-G998W"), listOf("SAMSUNG"), listOf("p3qcsx"), "Galaxy S21 Ultra 5G (CA)"),
Entry(listOf("SM-G991U"), listOf("SAMSUNG"), name = "Galaxy S21 5G"),
Entry(listOf("SM-A125F"), listOf("SAMSUNG"), name = "Galaxy A12"),
Entry(listOf("SM-T290"), listOf("SAMSUNG"), listOf("gtowifixx"), "Galaxy Tab A8"),
Entry(listOf("SM-G780F"), listOf("SAMSUNG"), name = "Galaxy S20 FE"),
Entry(listOf("SM-N986U"), listOf("SAMSUNG"), name = "Galaxy Note 20 Ultra 5G"),
Entry(listOf("SM-N981U"), listOf("SAMSUNG"), name = "Galaxy Note 20 5G"),
Entry(listOf("A013G"), listOf("SAMSUNG"), name = "Galaxy A01 Core"),
Entry(listOf("SM-A715F"), listOf("SAMSUNG"), name = "Galaxy A71"),
Entry(listOf("SM-A515F"), listOf("SAMSUNG"), name = "Galaxy A51"),
Entry(listOf("SM-G975F"), listOf("SAMSUNG"), name = "Galaxy S10+"),
Entry(listOf("SM-G973F"), listOf("SAMSUNG"), name = "Galaxy S10"),
Entry(listOf("SM-G970F"), listOf("SAMSUNG"), name = "Galaxy S10e"),
Entry(listOf("SM-N960F"), listOf("SAMSUNG"), name = "Galaxy Note 9"),
Entry(listOf("SM-G965F"), listOf("SAMSUNG"), name = "Galaxy S9+"),
Entry(listOf("SM-G960F"), listOf("SAMSUNG"), name = "Galaxy S9"),
Entry(listOf("SM-N950F"), listOf("SAMSUNG"), name = "Galaxy Note 8"),
Entry(listOf("SM-J730F"), listOf("SAMSUNG"), name = "Galaxy J7 (2017)"),
Entry(listOf("SM-J530F"), listOf("SAMSUNG"), name = "Galaxy J5 (2017)"),
Entry(listOf("SM-J330F"), listOf("SAMSUNG"), name = "Galaxy J3 (2017)"),
Entry(listOf("SM-G955F"), listOf("SAMSUNG"), name = "Galaxy S8+"),
Entry(listOf("SM-G950F"), listOf("SAMSUNG"), name = "Galaxy S8"),
)
/**
* Returns an alternate name for the device based on its model, manufacturer, and codename.
* If no alternate name is found, returns null.
*
* @param model The model of the device.
* @param manufacturer The manufacturer of the device.
* @param codename The codename of the device (can be empty).
* @return The alternate name or null if not found.
*/
private fun getAlternateName(model: String, manufacturer: String, codename: String): String? {
return deviceNames.entries.find { (key, _) ->
model in key.first &&
manufacturer in key.second &&
((key.third.isEmpty() && codename.isBlank()) || (key.third.isNotEmpty() && codename in key.third))
}?.value
}
/**
* Returns an alternate name for the device based on its model and manufacturer.
* If no alternate name is found, returns null.
*
* @param model The model of the device.
* @param manufacturer The manufacturer of the device.
* @param codename Optional codename of the device (can be null).
* @return The alternate name or null if not found.
*/
private fun getAlternateNameFlexible(model: String, manufacturer: String, codename: String? = null): String? {
return deviceNames.entries.find { (key, _) ->
model in key.first &&
manufacturer in key.second &&
((key.third.isEmpty() && codename.isNullOrBlank()) || (key.third.isNotEmpty() && codename != null && codename in key.third))
}?.value
}
/**
* Returns an alternate name for the device based on its model and manufacturer.
* If no alternate name is found, returns null.
*
* @param model The model of the device.
* @param manufacturer The manufacturer of the device.
* @return The alternate name or null if not found.
*/
fun getAlternateNameOrDefault(model: String, manufacturer: String, codename: String, default: String): String {
return if (codename.isBlank()) {
getAlternateNameFlexible(model, manufacturer)
} else {
getAlternateName(model, manufacturer, codename)
} ?: default
}
/**
* Checks if the device identifiers are present in the alternate names map.
*
* @param model The model of the device.
* @param manufacturer The manufacturer of the device.
* @param codename The codename of the device (can be empty).
* @return True if the identifiers are found, false otherwise.
*/
fun containsIdentifier(model: String, manufacturer: String, codename: String): Boolean {
return deviceNames.entries.any { (key, _) ->
model in key.first && manufacturer in key.second && codename in key.third
// Index built at class init: model (uppercase) → entries that contain that model.
// O(1) first-level lookup; secondary filter is O(small) since model collisions are rare.
private val byModel: Map<String, List<Entry>> = buildMap<String, MutableList<Entry>> {
for (entry in entries) {
for (model in entry.models) {
getOrPut(model.uppercase()) { mutableListOf() }.add(entry)
}
}
}
}
fun getAlternateNameOrDefault(model: String, manufacturer: String, codename: String, default: String): String =
byModel[model.uppercase()]?.find { it.matches(model, manufacturer, codename) }?.name ?: default
fun containsIdentifier(model: String, manufacturer: String, codename: String): Boolean =
byModel[model.uppercase()]?.any { it.matches(model, manufacturer, codename) } ?: false
}
@@ -180,6 +180,10 @@ class DeviceDatabase(context: Context, private val autoUpdateDB: Boolean = true)
}
private fun checkForUpdates() {
val prefs = context.getSharedPreferences("device_db_prefs", Context.MODE_PRIVATE)
val lastCheck = prefs.getLong("last_update_check", 0L)
if (System.currentTimeMillis() - lastCheck < UPDATE_CHECK_INTERVAL_MS) return
prefs.edit().putLong("last_update_check", System.currentTimeMillis()).apply()
CoroutineScope(Dispatchers.IO).launch {
try {
val url = URL(UPDATE_URL)
@@ -203,22 +207,21 @@ class DeviceDatabase(context: Context, private val autoUpdateDB: Boolean = true)
}
}
} catch (e: Exception) {
// Handle error during update check, e.g., log it
e.printStackTrace()
}
}
}
private suspend fun downloadAndUpdateDatabase(downloadUrl: String) = withContext(Dispatchers.IO) {
val tempFile = File(file.parent, "$NAME.tmp")
try {
val url = URL(downloadUrl)
val connection = url.openConnection() as HttpURLConnection
val connection = URL(downloadUrl).openConnection() as HttpURLConnection
connection.connect()
context.deleteDatabase(NAME) // Delete old database
val outputStream = FileOutputStream(file)
connection.inputStream.copyTo(outputStream)
outputStream.close()
FileOutputStream(tempFile).use { connection.inputStream.copyTo(it) }
context.deleteDatabase(NAME)
tempFile.renameTo(file)
} catch (e: IOException) {
tempFile.delete()
throw SQLException("Error downloading or updating $NAME database", e)
}
}
@@ -260,8 +263,9 @@ class DeviceDatabase(context: Context, private val autoUpdateDB: Boolean = true)
private const val COLUMN_CODENAME = "codename"
private const val COLUMN_MODEL = "model"
private const val NAME = "android_devices.db"
private const val VERSION_CODE = 2 // Integer, increment for schema changes
private const val VERSION_NAME = "1.0.1" // SemVer for content updates
private const val VERSION_CODE = 2
private const val VERSION_NAME = "1.0.1"
private const val UPDATE_URL = "https://cdn.oxmc.me/android-devicenames/latest.json"
private const val UPDATE_CHECK_INTERVAL_MS = 86_400_000L // 24 hours
}
}
@@ -29,12 +29,12 @@ import java.net.URL
import java.net.URLEncoder
object DeviceInfo {
// API base URL and endpoints
var api_url_base = "https://cdn.oxmc.me/api";
var api_device = "device-info";
var api_deviceSearch = "${api_device}/search-device";
var api_reportDevice = "${api_device}/report-device-info";
var api_version = "v2";
var api_url_base = "https://cdn.oxmc.me/api"
var api_device = "device-info"
var api_deviceSearch = "${api_device}/search-device"
var api_reportDevice = "${api_device}/report-device-info"
var api_version = "v2"
private const val IMAGE_CACHE_MS = 2_592_000_000L // 30 days
/**
* Get device information including name, manufacturer, model, codename, and image URL.
@@ -43,8 +43,9 @@ object DeviceInfo {
* @return An array containing device name, manufacturer, model, codename, and image URL.
*/
suspend fun getDeviceInfo(context: Context): Array<String?> {
// Get basic device information
val deviceInfo = AndroidDeviceInfo.getCurrentDeviceInfo(context)
val deviceInfo = withContext(Dispatchers.IO) {
AndroidDeviceInfo.getCurrentDeviceInfo(context)
}
// Set all the device information
val deviceName = deviceInfo.name ?: "Unknown"
@@ -101,34 +102,35 @@ object DeviceInfo {
* @return The URL of the device image or null if not found.
*/
suspend fun getDeviceImage(context: Context, codename: String, model: String, manufacturer: String): String? {
val prefs = context.getSharedPreferences("device_images", Context.MODE_PRIVATE)
val cacheKey = "$codename:$model"
prefs.getString(cacheKey, null)?.let { cached ->
try {
val json = JSONObject(cached)
if (System.currentTimeMillis() - json.getLong("ts") < IMAGE_CACHE_MS) {
val url = json.optString("url", "")
return if (url.isEmpty()) null else url
}
} catch (_: Exception) {}
}
val baseUrl = "${api_url_base}/${api_deviceSearch}/${api_version}"
val queryParams = mutableListOf<String>()
val fullUrl = "$baseUrl?codename=${URLEncoder.encode(codename, "UTF-8")}" +
"&model=${URLEncoder.encode(model, "UTF-8")}" +
"&manufacturer=${URLEncoder.encode(manufacturer, "UTF-8")}"
// Encode parameters to ensure they are URL-safe
queryParams.add("codename=${URLEncoder.encode(codename, "UTF-8")}")
queryParams.add("model=${URLEncoder.encode(model, "UTF-8")}")
queryParams.add("manufacturer=${URLEncoder.encode(manufacturer, "UTF-8")}")
// Construct the full URL with query parameters
val fullUrl = "$baseUrl?${queryParams.joinToString("&")}"
// Get the device image from the API
var deviceImage: String? = null
withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
val url = URL(fullUrl)
connection = url.openConnection() as HttpURLConnection
connection = URL(fullUrl).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 5000
connection.readTimeout = 5000
// Check the response code
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().use(BufferedReader::readText)
val jsonObject = JSONObject(response)
deviceImage = jsonObject.optString("image")
if (deviceImage.isEmpty()) deviceImage = null
deviceImage = jsonObject.optString("image").ifEmpty { null }
} else {
println("HTTP Error: ${connection.responseCode} ${connection.responseMessage} for URL: $fullUrl")
}
@@ -138,7 +140,12 @@ object DeviceInfo {
connection?.disconnect()
}
}
try {
val cacheJson = JSONObject()
cacheJson.put("url", deviceImage ?: "")
cacheJson.put("ts", System.currentTimeMillis())
prefs.edit().putString(cacheKey, cacheJson.toString()).apply()
} catch (_: Exception) {}
return deviceImage
}
@@ -40,7 +40,7 @@ object AndroidDeviceInfo {
@WorkerThread
fun getCurrentDeviceInfo(context: Context?): DeviceInfo {
val codename = Build.PRODUCT
val codename = Build.DEVICE
val model = Build.MODEL
val cachedInfo = getDeviceInfoFromCache(context, codename, model)
@@ -67,8 +67,8 @@ object AndroidDeviceInfo {
e.printStackTrace()
}
// Attempt to get the alternate name of the device
val altName = AltDeviceNames.getAlternateNameOrDefault(capitalize(model), capitalize(manufacturer), codename, codename)
val product = Build.PRODUCT
val altName = AltDeviceNames.getAlternateNameOrDefault(capitalize(model), capitalize(manufacturer), product, product)
val finalDeviceName: String = if (!TextUtils.isEmpty(altName)) {
altName
} else {
@@ -125,7 +125,7 @@ object AndroidDeviceInfo {
if (TextUtils.isEmpty(str)) {
return str
}
return str.uppercase()
return str.replaceFirstChar { it.uppercaseChar() }
}
class DeviceInfo @JvmOverloads constructor(