Improved device model detection

This commit is contained in:
Jonas Lochmann 2022-05-09 02:00:00 +02:00
parent c64875f1c7
commit 148af959c2
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
14 changed files with 338 additions and 62 deletions

View file

@ -197,8 +197,6 @@ dependencies {
implementation 'com.google.android:flexbox:1.0.0' implementation 'com.google.android:flexbox:1.0.0'
implementation 'com.jaredrummler:android-device-names:1.1.7'
implementation 'com.squareup.okhttp3:okhttp:4.9.0' implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-tls:4.9.0' implementation 'com.squareup.okhttp3:okhttp-tls:4.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1' implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1'

Binary file not shown.

View file

@ -0,0 +1,38 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.devicename
import android.content.Context
import android.os.Build
object DeviceName {
private val selfMarketingNameLock = Object()
private var selfMarketingName: String? = null
fun getDeviceNameSync(context: Context): String {
if (selfMarketingName == null) {
synchronized(selfMarketingNameLock) {
if (selfMarketingName == null) {
selfMarketingName = getDeviceNameSync(context, Build.DEVICE, Build.MODEL) ?: Build.MODEL
}
}
}
return selfMarketingName!!
}
private fun getDeviceNameSync(context: Context, device: String, model: String): String? = DeviceNameDatabase.fromAssets(context).getDeviceMarketingName(device, model)
}

View file

@ -0,0 +1,105 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.devicename
import android.content.Context
import android.util.Log
import io.timelimit.android.BuildConfig
import java.util.zip.InflaterInputStream
class DeviceNameDatabase (private val data: ByteArray) {
companion object {
private const val LOG_TAG = "DeviceNameDatabase"
fun fromAssets(context: Context): DeviceNameDatabase {
val data = context.assets.open("device-names.bin").use { stream ->
InflaterInputStream(stream).use { decompressed ->
decompressed.readBytes()
}
}
return DeviceNameDatabase(data)
}
}
private val stringBufferLength = read4(0)
private val deviceCounter = read4(4)
private val deviceDataStartIndex = stringBufferLength + 8
private fun read1(index: Int): Int = data[index].toInt() and 0xff
private fun read3(index: Int) = (read1(index) shl 16) or
(read1(index + 1) shl 8) or
read1(index + 2)
private fun read4(index: Int) = (read1(index) shl 24) or
(read1(index + 1) shl 16) or
(read1(index + 2) shl 8) or
read1(index + 3)
private fun readString(index: Int): String {
return String(data, read3(index) + 8, read1(index + 3), Charsets.UTF_8)
}
private fun getDeviceName(index: Int): String = readString(deviceDataStartIndex + index * 12 + 0)
private fun getDeviceModel(index: Int): String = readString(deviceDataStartIndex + index * 12 + 4)
private fun getDeviceMarketingName(index: Int): String = readString(deviceDataStartIndex + index * 12 + 8)
fun getDeviceMarketingName(device: String, model: String): String? {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "search ($device, $model)")
}
var low = 0
var high = deviceCounter - 1
while (low <= high) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "low = $low; high = $high")
}
val index = low + (high - low) / 2
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "compare with (${getDeviceName(index)}, ${getDeviceModel(index)}, ${getDeviceMarketingName(index)})")
}
val cmp1 = getDeviceName(index).compareTo(device)
if (cmp1 < 0) low = index + 1
else if (cmp1 > 0) high = index - 1
else {
val cmp2 = getDeviceModel(index).compareTo(model)
if (cmp2 < 0) low = index + 1
else if (cmp2 > 0) high = index - 1
else {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got match")
}
return getDeviceMarketingName(index)
}
}
}
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "nothing found")
}
return null
}
}

View file

@ -16,7 +16,6 @@
package io.timelimit.android.logic package io.timelimit.android.logic
import android.content.Context import android.content.Context
import com.jaredrummler.android.device.DeviceName
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.async.Threads import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.executeAndWait
@ -25,6 +24,7 @@ import io.timelimit.android.crypto.PasswordHashing
import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.backup.DatabaseBackup import io.timelimit.android.data.backup.DatabaseBackup
import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.customtypes.ImmutableBitmask
import io.timelimit.android.data.devicename.DeviceName
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.integration.platform.NewPermissionStatus import io.timelimit.android.integration.platform.NewPermissionStatus
import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.ProtectionLevel
@ -76,7 +76,7 @@ class AppSetupLogic(private val appLogic: AppLogic) {
run { run {
// add device // add device
val deviceName = DeviceName.getDeviceName() val deviceName = DeviceName.getDeviceNameSync(appLogic.context)
val device = Device( val device = Device(
id = ownDeviceId, id = ownDeviceId,

View file

@ -18,11 +18,11 @@ package io.timelimit.android.ui.setup.child
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.jaredrummler.android.device.DeviceName
import io.timelimit.android.async.Threads import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsync import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.backup.DatabaseBackup import io.timelimit.android.data.backup.DatabaseBackup
import io.timelimit.android.data.devicename.DeviceName
import io.timelimit.android.livedata.castDown import io.timelimit.android.livedata.castDown
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
@ -50,11 +50,12 @@ class SetupRemoteChildViewModel(application: Application): AndroidViewModel(appl
runAsync { runAsync {
try { try {
val api = logic.serverLogic.getServerConfigCoroutine().api val api = logic.serverLogic.getServerConfigCoroutine().api
val deviceModelName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(getApplication()) }
val registerResponse = api.registerChildDevice( val registerResponse = api.registerChildDevice(
childDeviceInfo = NewDeviceInfo(model = DeviceName.getDeviceName()), childDeviceInfo = NewDeviceInfo(model = deviceModelName),
registerToken = registerToken, registerToken = registerToken,
deviceName = DeviceName.getDeviceName() deviceName = deviceModelName
) )
val clientStatusResponse = api.pullChanges(registerResponse.deviceAuthToken, ClientDataStatus.empty) val clientStatusResponse = api.pullChanges(registerResponse.deviceAuthToken, ClientDataStatus.empty)

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -23,8 +23,11 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.navigation.Navigation import androidx.navigation.Navigation
import com.jaredrummler.android.device.DeviceName
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.devicename.DeviceName
import io.timelimit.android.databinding.FragmentSetupParentModeBinding import io.timelimit.android.databinding.FragmentSetupParentModeBinding
import io.timelimit.android.livedata.liveDataFromNonNullValue import io.timelimit.android.livedata.liveDataFromNonNullValue
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
@ -99,8 +102,14 @@ class SetupParentModeFragment : Fragment(), AuthenticateByMailFragmentListener {
}) })
if (savedInstanceState == null) { if (savedInstanceState == null) {
val ctx = requireContext()
runAsync {
// provide an useful default value // provide an useful default value
binding.deviceName.setText(DeviceName.getDeviceName()) val deviceName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(ctx) }
binding.deviceName.setText(deviceName)
}
} }
binding.ok.setOnClickListener { binding.ok.setOnClickListener {

View file

@ -20,13 +20,13 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.jaredrummler.android.device.DeviceName
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.async.Threads import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsync import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.backup.DatabaseBackup import io.timelimit.android.data.backup.DatabaseBackup
import io.timelimit.android.data.devicename.DeviceName
import io.timelimit.android.livedata.castDown import io.timelimit.android.livedata.castDown
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
@ -89,12 +89,13 @@ class SetupParentModeModel(application: Application): AndroidViewModel(applicati
runAsync { runAsync {
try { try {
val api = logic.serverLogic.getServerConfigCoroutine().api val api = logic.serverLogic.getServerConfigCoroutine().api
val deviceModelName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(getApplication()) }
val registerResponse = api.createFamilyByMailToken( val registerResponse = api.createFamilyByMailToken(
mailToken = mailAuthToken.value!!, mailToken = mailAuthToken.value!!,
parentPassword = ParentPassword.createCoroutine(parentPassword), parentPassword = ParentPassword.createCoroutine(parentPassword),
parentDevice = NewDeviceInfo( parentDevice = NewDeviceInfo(
model = DeviceName.getDeviceName() model = deviceModelName
), ),
deviceName = deviceName, deviceName = deviceName,
parentName = parentName, parentName = parentName,
@ -163,11 +164,12 @@ class SetupParentModeModel(application: Application): AndroidViewModel(applicati
runAsync { runAsync {
try { try {
val api = logic.serverLogic.getServerConfigCoroutine().api val api = logic.serverLogic.getServerConfigCoroutine().api
val deviceModelName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(getApplication()) }
val registerResponse = api.signInToFamilyByMailToken( val registerResponse = api.signInToFamilyByMailToken(
mailToken = mailAuthToken.value!!, mailToken = mailAuthToken.value!!,
parentDevice = NewDeviceInfo( parentDevice = NewDeviceInfo(
model = DeviceName.getDeviceName() model = deviceModelName
), ),
deviceName = deviceName deviceName = deviceName
) )

View file

@ -83,8 +83,6 @@
(<a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License, Version 2.0</a>) (<a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License, Version 2.0</a>)
\nAndroid Work Manager \nAndroid Work Manager
(<a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License, Version 2.0</a>) (<a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License, Version 2.0</a>)
\n<a href="https://github.com/jaredrummler/AndroidDeviceNames">Android Device Names</a>
(<a href="https://github.com/jaredrummler/AndroidDeviceNames/blob/master/LICENSE.txt">Apache License, Version 2.0</a>)
\n<a href="https://github.com/JakeWharton/ThreeTenABP">Three Ten Android Backport</a> \n<a href="https://github.com/JakeWharton/ThreeTenABP">Three Ten Android Backport</a>
(<a href="https://github.com/JakeWharton/ThreeTenABP/blob/master/LICENSE.txt">Apache License, Version 2.0</a>) (<a href="https://github.com/JakeWharton/ThreeTenABP/blob/master/LICENSE.txt">Apache License, Version 2.0</a>)
\n<a href="https://github.com/jeremyh/jBCrypt">JBcrypt</a> \n<a href="https://github.com/jeremyh/jBCrypt">JBcrypt</a>

2
contrib/.gitignore vendored
View file

@ -1 +1 @@
*.json node_modules

26
contrib/package-lock.json generated Normal file
View file

@ -0,0 +1,26 @@
{
"name": "contrib",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"devDependencies": {
"csv-parse": "^5.0.4"
}
},
"node_modules/csv-parse": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.0.4.tgz",
"integrity": "sha512-5AIdl8l6n3iYQYxan5djB5eKDa+vBnhfWZtRpJTcrETWfVLYN0WSj3L9RwvgYt+psoO77juUr8TG8qpfGZifVQ==",
"dev": true
}
},
"dependencies": {
"csv-parse": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.0.4.tgz",
"integrity": "sha512-5AIdl8l6n3iYQYxan5djB5eKDa+vBnhfWZtRpJTcrETWfVLYN0WSj3L9RwvgYt+psoO77juUr8TG8qpfGZifVQ==",
"dev": true
}
}
}

5
contrib/package.json Normal file
View file

@ -0,0 +1,5 @@
{
"devDependencies": {
"csv-parse": "^5.0.4"
}
}

View file

@ -1,45 +0,0 @@
const { resolve } = require('path')
const { readFileSync } = require('fs')
const data = JSON.parse(readFileSync(resolve(__dirname, 'timelimit-usage-stats-export.json')))
const events = data.events
.map((item) => item.native)
.map((event) => {
// fix wrong exported instanceIds
const binary = Buffer.from(event.binary, 'base64')
const instanceId = binary.readUInt32LE(8)
return {...event, instanceId}
})
const instanceIdToApp = new Map()
for (event of events) {
function printStatus(message) {
let apps = []
for(const app of instanceIdToApp.values()) {
apps.push(app)
}
console.log(new Date(event.timestamp) + ': ' + message + ' -> ' + (apps.join(', ') || 'none'))
}
if (event.type === 27 /* reboot */) {
instanceIdToApp.clear()
printStatus('reboot')
} else if (event.type === 1 /* move to foreground */) {
instanceIdToApp.set(event.instanceId, event.packageName + ':' + event.className)
printStatus('start ' + event.instanceId)
} else if (event.type === 2 /* move to background */) {
instanceIdToApp.delete(event.instanceId)
printStatus('stop ' + event.instanceId)
} else if (event.type === 23 /* stopped */) {
instanceIdToApp.delete(event.instanceId)
printStatus('kill ' + event.instanceId)
}
}

View file

@ -0,0 +1,139 @@
import { deepStrictEqual } from 'assert'
import { get } from 'https'
import { parse } from 'csv-parse/sync'
import { deflateSync } from 'zlib'
import { writeFileSync } from 'fs'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
function pullString(url) {
return new Promise((resolve, reject) => {
get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error('unexpected status code'))
res.resume()
return
}
const buffers = []
res
.on('data', buffers.push.bind(buffers))
.on('end', () => {
resolve(Buffer.concat(buffers))
})
.on('error', reject)
}).on('error', reject)
})
}
async function main() {
const data =
(await pullString('https://storage.googleapis.com/play_public/supported_devices.csv'))
.toString('utf-16le')
const parsedData = parse(data, {})
deepStrictEqual(parsedData[0], ['Retail Branding', 'Marketing Name', 'Device', 'Model'])
const rows = parsedData.slice(1).map((row) => {
if (row.length !== 4) throw new Error(`got ${row.length} columns: ${row}`)
const [brand, marketingName, device, model] = row
return { brand, marketingName, device, model }
})
const relevantRows = rows.filter((row) => row.marketingName !== row.model)
relevantRows.sort((a, b) => {
if (a.device < b.device) return -1
else if (a.device > b.device) return 1
else if (a.model < b.model) return -1
else if (a.model > b.model) return 1
else return 0
})
const relevantStringsSet = new Set()
relevantRows.forEach((row) => {
relevantStringsSet.add(row.device)
relevantStringsSet.add(row.model)
relevantStringsSet.add(row.marketingName)
})
const relevantStrings = Array.from(relevantStringsSet)
relevantStrings.sort(); relevantStrings.reverse()
const stringToIndex = new Map()
let relevantStringPool = Buffer.alloc(0)
let relevantStringPoolAddCounter = 0
for (const str of relevantStrings) {
if (stringToIndex.has(str)) continue
const oldIndex = relevantStringPool.indexOf(str)
if (oldIndex === -1) {
stringToIndex.set(str, relevantStringPool.length)
relevantStringPool = Buffer.concat([relevantStringPool, Buffer.from(str)])
relevantStringPoolAddCounter++
} else {
stringToIndex.set(str, oldIndex)
}
}
// 4 bytes per string: start index (3 byte) + length (1 byte)
// 3 strings (device, model, marketingName)
const deviceInfoBuffer = Buffer.alloc(relevantRows.length * (3 * 4))
const writeString = (outputIndex, str) => {
const startIndex = stringToIndex.get(str)
if (startIndex === undefined) throw new Error()
if (startIndex > 0xffffff) throw new Error()
deviceInfoBuffer.writeUInt8((startIndex >> 16) & 0xff, outputIndex * 4 + 0)
deviceInfoBuffer.writeUInt8((startIndex >> 8) & 0xff, outputIndex * 4 + 1)
deviceInfoBuffer.writeUInt8(startIndex & 0xff, outputIndex * 4 + 2)
deviceInfoBuffer.writeUInt8(str.length, outputIndex * 4 + 3)
}
relevantRows.forEach((row, index) => {
const outputIndex = index * 3
writeString(outputIndex + 0, row.device)
writeString(outputIndex + 1, row.model)
writeString(outputIndex + 2, row.marketingName)
})
const relevantStringBuffer = Buffer.from(relevantStringPool, 'utf8')
const lengthDataBuffer = Buffer.alloc(8)
lengthDataBuffer.writeUInt32BE(relevantStringBuffer.length, 0)
lengthDataBuffer.writeUInt32BE(relevantRows.length, 4)
const resultData = Buffer.concat([lengthDataBuffer, relevantStringBuffer, deviceInfoBuffer])
const resultDataCompressed = deflateSync(resultData, {})
writeFileSync(resolve(__dirname, '../app/src/main/assets/device-names.bin'), resultDataCompressed)
console.log({
rows: rows.length,
relevantRows: relevantRows.length,
relevantStringCount: relevantStrings.length,
relevantStringPoolAddCounter,
relevantStringPoolLength: relevantStringPool.length,
resultDataLength: resultData.length,
resultDataCompressedLength: resultDataCompressed.length,
compressedBytesPerDevices: resultDataCompressed.length / relevantRows.length
})
}
main().catch((ex) => {
console.warn(ex)
process.exit(1)
})