Refactor network logging

This commit is contained in:
BinTianqi
2025-10-17 12:54:47 +08:00
parent 5b9ce9f984
commit fde191adc5
17 changed files with 232 additions and 98 deletions

View File

@@ -440,7 +440,10 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) {
composable<RecommendedGlobalProxy> {
RecommendedGlobalProxyScreen(vm::setRecommendedGlobalProxy, ::navigateUp)
}
composable<NetworkLogging> { NetworkLoggingScreen(::navigateUp) }
composable<NetworkLogging> {
NetworkLoggingScreen(vm::getNetworkLoggingEnabled, vm::setNetworkLoggingEnabled,
vm::getNetworkLogsCount, vm::exportNetworkLogs, vm::deleteNetworkLogs, ::navigateUp)
}
//composable<WifiAuthKeypair> { WifiAuthKeypairScreen(::navigateUp) }
composable<PreferentialNetworkService> {
PreferentialNetworkServiceScreen(vm::getPnsEnabled, vm::setPnsEnabled, vm.pnsConfigs,

View File

@@ -4,7 +4,7 @@ import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 2) {
class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 3) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," +
"signature TEXT, permissions TEXT)")
@@ -14,5 +14,12 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 2) {
db.execSQL("CREATE TABLE security_logs (id INTEGER, tag INTEGER, level INTEGER," +
"time INTEGER, data TEXT)")
}
if (oldVersion < 3) {
db.execSQL(
"CREATE TABLE network_logs (id INTEGER, package INTEGER, time INTEGER," +
"type TEXT, host TEXT, count INTEGER, addresses TEXT, address TEXT," +
"port INTEGER)"
)
}
}
}

View File

@@ -6,7 +6,10 @@ import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.os.Build.VERSION
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
import com.bintianqi.owndroid.dpm.NetworkLog
import com.bintianqi.owndroid.dpm.SecurityEvent
import com.bintianqi.owndroid.dpm.SecurityEventWithData
import com.bintianqi.owndroid.dpm.transformSecurityEventData
@@ -154,4 +157,71 @@ class MyRepository(val dbHelper: MyDbHelper) {
fun deleteSecurityLogs() {
dbHelper.writableDatabase.execSQL("DELETE FROM security_logs")
}
fun getNetworkLogsCount(): Long {
return DatabaseUtils.queryNumEntries(dbHelper.readableDatabase, "network_logs")
}
fun writeNetworkLogs(logs: List<NetworkLog>) {
val db = dbHelper.writableDatabase
val statement = db.compileStatement(
"INSERT INTO network_logs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
db.beginTransaction()
logs.forEach { event ->
if (event.id == null) statement.bindNull(1)
else statement.bindLong(1, event.id)
statement.bindString(2, event.packageName)
statement.bindLong(3, event.time)
statement.bindString(4, event.type)
if (event.host == null) statement.bindNull(5)
else statement.bindString(5, event.host)
if (event.count == null) statement.bindNull(6)
else statement.bindLong(6, event.count.toLong())
if (event.addresses == null) statement.bindNull(7)
else statement.bindString(7, event.addresses.joinToString(","))
if (event.address == null) statement.bindNull(8)
else statement.bindString(8, event.address)
if (event.port == null) statement.bindNull(9)
else statement.bindLong(9, event.port.toLong())
statement.executeInsert()
statement.clearBindings()
}
db.setTransactionSuccessful()
db.endTransaction()
statement.close()
}
fun exportNetworkLogs(stream: OutputStream) {
val bw = stream.bufferedWriter()
val json = Json {
explicitNulls = false
}
var offset = 0
var addComma = false
bw.write("[")
while (true) {
val cursor = dbHelper.readableDatabase.rawQuery(
"SELECT * FROM network_logs LIMIT ? OFFSET ?",
arrayOf(100.toString(), offset.toString())
)
if (cursor.count == 0) break
while (cursor.moveToNext()) {
if (addComma) bw.write(",")
addComma = true
val log = NetworkLog(
cursor.getLongOrNull(0), cursor.getString(1), cursor.getLong(2),
cursor.getString(3), cursor.getStringOrNull(4), cursor.getIntOrNull(5),
cursor.getStringOrNull(6)?.split(',')?.filter { it.isNotEmpty() },
cursor.getStringOrNull(7), cursor.getIntOrNull(8)
)
bw.write(json.encodeToString(log))
offset += 100
}
cursor.close()
}
bw.write("]")
bw.close()
}
fun deleteNetworkLogs() {
dbHelper.writableDatabase.execSQL("DELETE FROM network_logs")
}
}

View File

@@ -1753,6 +1753,28 @@ class MyViewModel(application: Application): AndroidViewModel(application) {
fun removeApnConfig(id: Int): Boolean {
return DPM.removeOverrideApn(DAR, id)
}
@RequiresApi(26)
fun getNetworkLoggingEnabled(): Boolean {
return DPM.isNetworkLoggingEnabled(DAR)
}
@RequiresApi(26)
fun setNetworkLoggingEnabled(enabled: Boolean) {
DPM.setNetworkLoggingEnabled(DAR, enabled)
}
fun getNetworkLogsCount(): Int {
return myRepo.getNetworkLogsCount().toInt()
}
fun exportNetworkLogs(uri: Uri, callback: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
application.contentResolver.openOutputStream(uri)?.use {
myRepo.exportNetworkLogs(it)
}
withContext(Dispatchers.Main) { callback() }
}
}
fun deleteNetworkLogs() {
myRepo.deleteNetworkLogs()
}
@RequiresApi(29)
fun getPasswordComplexity(): PasswordComplexity {

View File

@@ -72,10 +72,15 @@ enum class NotificationType(
12, R.string.security_logs_collected, R.drawable.description_fill0,
MyNotificationChannel.SecurityLogging
),
NetworkLogsCollected(
13, R.string.network_logs_collected, R.drawable.description_fill0,
MyNotificationChannel.NetworkLogging
),
}
enum class MyNotificationChannel(val id: String, val text: Int, val importance: Int) {
LockTaskMode("LockTaskMode", R.string.lock_task_mode, NotificationManagerCompat.IMPORTANCE_HIGH),
Events("Events", R.string.events, NotificationManagerCompat.IMPORTANCE_LOW),
SecurityLogging("SecurityLogging", R.string.security_logging, NotificationManagerCompat.IMPORTANCE_MIN)
SecurityLogging("SecurityLogging", R.string.security_logging, NotificationManagerCompat.IMPORTANCE_MIN),
NetworkLogging("NetworkLogging", R.string.network_logging, NotificationManagerCompat.IMPORTANCE_MIN)
}

View File

@@ -173,7 +173,7 @@ fun AppChooserScreen(
}
}
}
item { Spacer(Modifier.height(60.dp)) }
item { Spacer(Modifier.height(BottomPadding)) }
}
}
}

View File

@@ -11,12 +11,9 @@ import android.os.Build.VERSION
import android.os.UserHandle
import android.os.UserManager
import androidx.core.app.NotificationCompat
import com.bintianqi.owndroid.dpm.handleNetworkLogs
import com.bintianqi.owndroid.dpm.handlePrivilegeChange
import com.bintianqi.owndroid.dpm.retrieveNetworkLogs
import com.bintianqi.owndroid.dpm.retrieveSecurityLogs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class Receiver : DeviceAdminReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@@ -47,10 +44,8 @@ class Receiver : DeviceAdminReceiver() {
override fun onNetworkLogsAvailable(context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int) {
super.onNetworkLogsAvailable(context, intent, batchToken, networkLogsCount)
if(VERSION.SDK_INT >= 26) {
CoroutineScope(Dispatchers.IO).launch {
handleNetworkLogs(context, batchToken)
}
if (VERSION.SDK_INT >= 26) {
retrieveNetworkLogs(context.applicationContext as MyApplication, batchToken)
}
}

View File

@@ -273,7 +273,7 @@ fun ApplicationDetailsScreen(
)
if(VERSION.SDK_INT >= 28) FunctionItem(R.string.clear_app_storage, icon = R.drawable.mop_fill0) { dialog = 1 }
FunctionItem(R.string.uninstall, icon = R.drawable.delete_fill0) { dialog = 2 }
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
if(dialog == 1 && VERSION.SDK_INT >= 28)
ClearAppStorageDialog(packageName, vm::clearAppData) { dialog = 0 }
@@ -606,7 +606,7 @@ fun CredentialManagerPolicyScreen(
) {
Text(stringResource(R.string.apply))
}
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
}
}
@@ -661,7 +661,7 @@ fun PermittedAsAndImPackages(
}
Spacer(Modifier.height(10.dp))
Notes(note, HorizontalPadding)
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
}
}
@@ -771,7 +771,7 @@ fun PackageFunctionScreen(
Text(stringResource(R.string.add))
}
if (notes != null) Notes(notes, HorizontalPadding)
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
}
}

View File

@@ -29,12 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
@SuppressLint("PrivateApi")
fun binderWrapperDevicePolicyManager(appContext: Context): DevicePolicyManager? {
@@ -137,39 +132,37 @@ val runtimePermissions = listOf(
PermissionItem(Manifest.permission.ACTIVITY_RECOGNITION, R.string.permission_ACTIVITY_RECOGNITION, R.drawable.history_fill0, true, 29)
).filter { VERSION.SDK_INT >= it.requiresApi }
@Serializable
class NetworkLog(
val id: Long?, @SerialName("package") val packageName: String, val time: Long, val type: String,
val host: String?, val count: Int?, val addresses: List<String>?,
val address: String?, val port: Int?
)
@RequiresApi(26)
fun handleNetworkLogs(context: Context, batchToken: Long) {
val networkEvents = Privilege.DPM.retrieveNetworkLogs(Privilege.DAR, batchToken) ?: return
val file = context.filesDir.resolve("NetworkLogs.json")
val fileExist = file.exists()
val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
val buffer = file.bufferedWriter()
networkEvents.forEachIndexed { index, event ->
if(fileExist && index == 0) buffer.write(",")
val item = buildJsonObject {
if(VERSION.SDK_INT >= 28) put("id", event.id)
put("time", event.timestamp)
put("package", event.packageName)
if(event is DnsEvent) {
put("type", "dns")
put("host", event.hostname)
put("count", event.totalResolvedAddressCount)
putJsonArray("addresses") {
event.inetAddresses.forEach { inetAddresses ->
add(inetAddresses.hostAddress)
fun retrieveNetworkLogs(app: MyApplication, token: Long) {
CoroutineScope(Dispatchers.IO).launch {
val logs = Privilege.DPM.retrieveNetworkLogs(Privilege.DAR, token)?.mapNotNull {
when (it) {
is DnsEvent -> NetworkLog(
if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp, "dns",
it.hostname, it.totalResolvedAddressCount,
it.inetAddresses.mapNotNull { address -> address.hostAddress }, null, null
)
is ConnectEvent -> NetworkLog(
if (VERSION.SDK_INT >= 28) it.id else null, it.packageName, it.timestamp,
"connect", null, null, null, it.inetAddress.hostAddress, it.port
)
else -> null
}
}
if (logs.isNullOrEmpty()) return@launch
app.myRepo.writeNetworkLogs(logs)
NotificationUtils.sendBasicNotification(
app, NotificationType.NetworkLogsCollected,
app.getString(R.string.n_logs_in_total, logs.size)
)
}
if(event is ConnectEvent) {
put("type", "connect")
put("address", event.inetAddress.hostAddress)
put("port", event.port)
}
}
buffer.write(json.encodeToString(item))
if(index < networkEvents.size - 1) buffer.write(",")
}
buffer.close()
}
@Serializable
@@ -493,7 +486,8 @@ fun transformSecurityEventData(tag: Int, payload: Any): SecurityEventData? {
@RequiresApi(24)
fun retrieveSecurityLogs(app: MyApplication) {
CoroutineScope(Dispatchers.IO).launch {
val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR) ?: return@launch
val logs = Privilege.DPM.retrieveSecurityLogs(Privilege.DAR)
if (logs.isNullOrEmpty()) return@launch
app.myRepo.writeSecurityLogs(logs)
NotificationUtils.sendBasicNotification(
app, NotificationType.SecurityLogsCollected,

View File

@@ -10,6 +10,7 @@ import android.app.admin.DevicePolicyManager.WIFI_SECURITY_PERSONAL
import android.app.admin.WifiSsidPolicy
import android.app.usage.NetworkStats
import android.net.ConnectivityManager
import android.net.Uri
import android.net.wifi.WifiConfiguration
import android.os.Build.VERSION
import android.provider.Telephony
@@ -106,6 +107,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bintianqi.owndroid.BottomPadding
import com.bintianqi.owndroid.HorizontalPadding
import com.bintianqi.owndroid.MyViewModel
import com.bintianqi.owndroid.Privilege
@@ -116,6 +118,7 @@ import com.bintianqi.owndroid.formatFileSize
import com.bintianqi.owndroid.adaptiveInsets
import com.bintianqi.owndroid.popToast
import com.bintianqi.owndroid.showOperationResultToast
import com.bintianqi.owndroid.ui.CircularProgressDialog
import com.bintianqi.owndroid.ui.ErrorDialog
import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem
import com.bintianqi.owndroid.ui.FullWidthRadioButtonItem
@@ -135,6 +138,9 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Serializable object Network
@@ -803,7 +809,7 @@ private fun AddNetworkScreen(
) {
Text(stringResource(if (updating) R.string.update else R.string.add))
}
Spacer(Modifier.height(60.dp))
Spacer(Modifier.height(BottomPadding))
}
}
@@ -1539,50 +1545,82 @@ fun RecommendedGlobalProxyScreen(
@RequiresApi(26)
@Composable
fun NetworkLoggingScreen(onNavigateUp: () -> Unit) {
fun NetworkLoggingScreen(
getEnabled: () -> Boolean, setEnabled: (Boolean) -> Unit, getCount: () -> Int,
exportLogs: (Uri, () -> Unit) -> Unit, deleteLogs: () -> Unit, onNavigateUp: () -> Unit
) {
val context = LocalContext.current
val logFile = context.filesDir.resolve("NetworkLogs.json")
var fileSize by remember { mutableLongStateOf(0) }
LaunchedEffect(Unit) { fileSize = logFile.length() }
val exportNetworkLogsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
if(uri != null) context.contentResolver.openOutputStream(uri)?.use { outStream ->
outStream.write("[".encodeToByteArray())
logFile.inputStream().use { it.copyTo(outStream) }
outStream.write("]".encodeToByteArray())
var enabled by remember { mutableStateOf(false) }
var count by remember { mutableIntStateOf(0) }
var dialog by rememberSaveable { mutableStateOf(false) }
var exporting by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
enabled = getEnabled()
count = getCount()
}
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
if (uri != null) {
exporting = true
exportLogs(uri) {
exporting = false
context.showOperationResultToast(true)
}
}
MyScaffold(R.string.network_logging, onNavigateUp) {
}
MyScaffold(R.string.network_logging, onNavigateUp, 0.dp) {
SwitchItem(
R.string.enable,
getState = { Privilege.DPM.isNetworkLoggingEnabled(Privilege.DAR) },
onCheckedChange = { Privilege.DPM.setNetworkLoggingEnabled(Privilege.DAR, it) },
padding = false
R.string.enable, enabled, {
setEnabled(it)
enabled = it
}
)
Text(
stringResource(R.string.n_logs_in_total, count),
Modifier.padding(HorizontalPadding, 5.dp)
)
Text(stringResource(R.string.log_file_size_is, formatFileSize(fileSize)))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Button(
onClick = {
exportNetworkLogsLauncher.launch("NetworkLogs.json")
{
val date = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
exportLauncher.launch("network_logs_$date")
},
enabled = fileSize > 0,
modifier = Modifier.fillMaxWidth(0.49F)
Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding),
count > 0
) {
Text(stringResource(R.string.export_logs))
}
Button(
onClick = {
logFile.delete()
fileSize = logFile.length()
if (count > 0) Button(
{
dialog = true
},
enabled = fileSize > 0,
modifier = Modifier.fillMaxWidth(0.96F)
Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding),
) {
Text(stringResource(R.string.delete_logs))
}
Spacer(Modifier.height(10.dp))
Notes(R.string.info_network_log, HorizontalPadding)
}
Notes(R.string.info_network_log)
if (exporting) CircularProgressDialog { exporting = false }
if (dialog) AlertDialog(
text = {
Text(stringResource(R.string.delete_logs))
},
confirmButton = {
TextButton({
deleteLogs()
dialog = false
}) {
Text(stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton({ dialog = false }) {
Text(stringResource(R.string.cancel))
}
},
onDismissRequest = { dialog = false }
)
}
@Serializable object PreferentialNetworkService

View File

@@ -81,6 +81,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bintianqi.owndroid.AppInfo
import com.bintianqi.owndroid.BottomPadding
import com.bintianqi.owndroid.DhizukuClientInfo
import com.bintianqi.owndroid.DhizukuPermissions
import com.bintianqi.owndroid.HorizontalPadding
@@ -615,7 +616,7 @@ fun AddDelegatedAdminScreen(
) {
Text(stringResource(R.string.delete))
}
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
}

View File

@@ -101,6 +101,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bintianqi.owndroid.AppInfo
import com.bintianqi.owndroid.BottomPadding
import com.bintianqi.owndroid.HorizontalPadding
import com.bintianqi.owndroid.MyViewModel
import com.bintianqi.owndroid.Privilege
@@ -1281,7 +1282,7 @@ private fun LockTaskPackages(
Text(stringResource(R.string.add))
}
Notes(R.string.info_lock_task_packages)
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
}
}
@@ -1331,7 +1332,7 @@ private fun LockTaskFeatures(
) {
Text(stringResource(R.string.apply))
}
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
ErrorDialog(errorMessage) { errorMessage = null }
}
}
@@ -1417,7 +1418,7 @@ fun CaCertScreen(
HorizontalDivider()
}
item {
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
}
if (selectedCaCert != null && (dialog == 1 || dialog == 2)) {

View File

@@ -239,7 +239,7 @@ fun UserRestrictionEditorScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions { add() }
)
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(BottomPadding))
}
}
}

View File

@@ -277,7 +277,6 @@
<string name="invalid_config">Неверная конфигурация</string>
<string name="excluded_hosts">Исключить хосты</string>
<string name="network_logging">Сетевой журнал</string>
<string name="log_file_size_is">Размер файла журнала: %1$s</string>
<string name="delete_logs">Удалить журналы</string>
<string name="export_logs">Экспортировать журналы</string>
<string name="wifi_auth_keypair">Пара ключей Wi-Fi</string>

View File

@@ -306,7 +306,6 @@
<string name="invalid_config">Geçersiz yapılandırma</string>
<string name="excluded_hosts">Hariç tutulan ana bilgisayarlar</string>
<string name="network_logging">Ağ Kayıtları</string>
<string name="log_file_size_is">Kayıt dosyası boyutu: %1$s</string>
<string name="delete_logs">Kayıtları Sil</string>
<string name="export_logs">Kayıtları Dışa Aktar</string>
<string name="wifi_auth_keypair">Wi-Fi Kimlik Doğrulama Anahtar Çifti</string>

View File

@@ -291,7 +291,7 @@
<string name="invalid_config">无效配置</string>
<string name="excluded_hosts">排除的主机</string>
<string name="network_logging">网络日志</string>
<string name="log_file_size_is">日志文件大小:%1$s</string>
<string name="network_logs_collected">网络日志已收集</string>
<string name="delete_logs">删除日志</string>
<string name="export_logs">导出日志</string>
<string name="wifi_auth_keypair">Wi-Fi密钥对</string>

View File

@@ -325,7 +325,7 @@
<string name="invalid_config">Invalid config</string>
<string name="excluded_hosts">Excluded hosts</string>
<string name="network_logging">Network logging</string>
<string name="log_file_size_is">Log file size: %1$s</string>
<string name="network_logs_collected">Network logs collected</string>
<string name="delete_logs">Delete logs</string>
<string name="export_logs">Export logs</string>
<string name="wifi_auth_keypair">Wi-Fi keypair</string>