diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d341c0b..387d5eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.cc) + kotlin("plugin.serialization") version "2.0.0" } android { @@ -86,4 +87,5 @@ dependencies { implementation(libs.androidx.biometric) implementation(libs.androidx.fragment) implementation(libs.hiddenApiBypass) + implementation(libs.serialization) } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index 39b8970..fc22f7c 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -17,16 +17,20 @@ import android.content.pm.PackageInstaller.STATUS_FAILURE_STORAGE import android.content.pm.PackageInstaller.STATUS_FAILURE_TIMEOUT import android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION import android.content.pm.PackageInstaller.STATUS_SUCCESS +import android.os.Build.VERSION import android.util.Log import android.widget.Toast -import androidx.activity.ComponentActivity import com.bintianqi.owndroid.dpm.getDPM import com.bintianqi.owndroid.dpm.getReceiver +import com.bintianqi.owndroid.dpm.handleNetworkLogs import com.bintianqi.owndroid.dpm.isDeviceAdmin import com.bintianqi.owndroid.dpm.isDeviceOwner import com.bintianqi.owndroid.dpm.isProfileOwner import com.bintianqi.owndroid.dpm.toggleInstallAppActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch class Receiver : DeviceAdminReceiver() { override fun onEnabled(context: Context, intent: Intent) { @@ -48,6 +52,14 @@ class Receiver : DeviceAdminReceiver() { Toast.makeText(context, R.string.create_work_profile_success, Toast.LENGTH_SHORT).show() } + override fun onNetworkLogsAvailable(context: Context, intent: Intent, batchToken: Long, networkLogsCount: Int) { + super.onNetworkLogsAvailable(context, intent, batchToken, networkLogsCount) + if(VERSION.SDK_INT >= 28) { + CoroutineScope(Dispatchers.IO).launch { + handleNetworkLogs(context, batchToken) + } + } + } } val installAppDone = MutableStateFlow(false) diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index 67a7713..952a9ee 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -13,9 +13,12 @@ import androidx.activity.result.contract.ActivityResultContracts import com.bintianqi.owndroid.dpm.addDeviceAdmin import com.bintianqi.owndroid.dpm.createManagedProfile import kotlinx.coroutines.flow.MutableStateFlow +import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream +import java.nio.file.Files +import java.util.Locale lateinit var getFile: ActivityResultLauncher val fileUriFlow = MutableStateFlow(Uri.parse("")) @@ -73,6 +76,7 @@ fun writeClipBoard(context: Context, string: String):Boolean{ } lateinit var requestPermission: ActivityResultLauncher +lateinit var saveNetworkLogs: ActivityResultLauncher fun registerActivityResult(context: ComponentActivity){ getFile = context.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> @@ -92,6 +96,19 @@ fun registerActivityResult(context: ComponentActivity){ } } requestPermission = context.registerForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted.value = it } + saveNetworkLogs = context.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val intentData = result.data ?: return@registerForActivityResult + val uriData = intentData.data ?: return@registerForActivityResult + context.contentResolver.openOutputStream(uriData).use { outStream -> + if(outStream != null) { + val logFile = context.filesDir.resolve("NetworkLogs.json") + logFile.inputStream().use { inStream -> + inStream.copyTo(outStream) + } + Toast.makeText(context.applicationContext, R.string.success, Toast.LENGTH_SHORT).show() + } + } + } } val permissionGranted = MutableStateFlow(null) @@ -108,3 +125,15 @@ suspend fun prepareForNotification(context: Context, action: ()->Unit) { action() } } + +fun formatFileSize(bytes: Long): String { + val kb = 1024 + val mb = kb * 1024 + val gb = mb * 1024 + return when { + bytes >= gb -> String.format(Locale.US, "%.2f GB", bytes / gb.toDouble()) + bytes >= mb -> String.format(Locale.US, "%.2f MB", bytes / mb.toDouble()) + bytes >= kb -> String.format(Locale.US, "%.2f KB", bytes / kb.toDouble()) + else -> "$bytes bytes" + } +} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index af9b4c7..50a52a3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -3,7 +3,9 @@ package com.bintianqi.owndroid.dpm import android.Manifest import android.annotation.SuppressLint import android.app.PendingIntent +import android.app.admin.ConnectEvent import android.app.admin.DevicePolicyManager +import android.app.admin.DnsEvent import android.app.admin.FactoryResetProtectionPolicy import android.app.admin.IDevicePolicyManager import android.app.admin.SystemUpdatePolicy @@ -13,9 +15,11 @@ import android.content.Intent import android.content.pm.IPackageInstaller import android.content.pm.PackageInstaller import android.content.pm.PackageManager +import android.os.Build import android.os.Build.VERSION import androidx.activity.result.ActivityResultLauncher import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi import androidx.annotation.StringRes import com.bintianqi.owndroid.InstallAppActivity import com.bintianqi.owndroid.PackageInstallerReceiver @@ -26,8 +30,18 @@ import com.rosan.dhizuku.api.Dhizuku import com.rosan.dhizuku.api.Dhizuku.binderWrapper import com.rosan.dhizuku.api.DhizukuBinderWrapper import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream import java.io.IOException import java.io.InputStream +import kotlin.io.path.inputStream +import kotlin.io.path.notExists +import kotlin.io.path.outputStream +import kotlin.io.path.writeText lateinit var createManagedProfile: ActivityResultLauncher lateinit var addDeviceAdmin: ActivityResultLauncher @@ -305,3 +319,54 @@ fun permissionList(): List{ } return list } + +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(ExperimentalSerializationApi::class) +fun handleNetworkLogs(context: Context, batchToken: Long) { + val events = context.getDPM().retrieveNetworkLogs(context.getReceiver(), batchToken) ?: return + val eventsList = mutableListOf() + val file = context.filesDir.toPath().resolve("NetworkLogs.json") + if(file.notExists()) file.writeText("[]") + val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + var jsonObj: MutableList + file.inputStream().use { + jsonObj = json.decodeFromStream(it) + } + events.forEach { event -> + try { + val dnsEvent = event as DnsEvent + val addresses = mutableListOf() + dnsEvent.inetAddresses.forEach { inetAddresses -> + addresses += inetAddresses.hostAddress + } + eventsList += NetworkEventItem( + id = if(VERSION.SDK_INT >= 28) event.id else null, packageName = event.packageName + , timestamp = event.timestamp, type = "dns", hostName = dnsEvent.hostname, + hostAddresses = addresses, totalResolvedAddressCount = dnsEvent.totalResolvedAddressCount + ) + } catch(e: Exception) { + val connectEvent = event as ConnectEvent + eventsList += NetworkEventItem( + id = if(VERSION.SDK_INT >= 28) event.id else null, packageName = event.packageName, timestamp = event.timestamp, type = "connect", + hostAddress = connectEvent.inetAddress.hostAddress, port = connectEvent.port + ) + } + } + jsonObj.addAll(eventsList) + file.outputStream().use { + json.encodeToStream(jsonObj, it) + } +} + +@Serializable +data class NetworkEventItem( + val id: Long? = null, + @SerialName("package_name") val packageName: String, + val timestamp: Long, + val type: String, + val port: Int? = null, + @SerialName("address") val hostAddress: String? = null, + @SerialName("host_name") val hostName: String? = null, + @SerialName("count") val totalResolvedAddressCount: Int? = null, + @SerialName("addresses") val hostAddresses: List? = null +) diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 347db2d..2716988 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -16,6 +16,7 @@ import android.app.admin.WifiSsidPolicy import android.app.admin.WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_ALLOWLIST import android.app.admin.WifiSsidPolicy.WIFI_SSID_POLICY_TYPE_DENYLIST import android.content.Context +import android.content.Intent import android.content.pm.PackageManager.NameNotFoundException import android.net.ProxyInfo import android.net.Uri @@ -38,7 +39,6 @@ import android.telephony.data.ApnSetting.PROTOCOL_IPV6 import android.telephony.data.ApnSetting.PROTOCOL_NON_IP import android.telephony.data.ApnSetting.PROTOCOL_PPP import android.telephony.data.ApnSetting.PROTOCOL_UNSTRUCTURED -import android.util.Log import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize @@ -75,6 +75,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -98,6 +99,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.formatFileSize +import com.bintianqi.owndroid.saveNetworkLogs import com.bintianqi.owndroid.selectedPackage import com.bintianqi.owndroid.toText import com.bintianqi.owndroid.ui.Animations @@ -624,28 +627,40 @@ private fun NetworkLog() { val context = LocalContext.current val dpm = context.getDPM() val receiver = context.getReceiver() + val logFile = context.filesDir.resolve("NetworkLogs.json") + var fileSize by remember { mutableLongStateOf(0) } + var fileExists by remember { mutableStateOf(logFile.exists()) } + LaunchedEffect(Unit) { + fileSize = logFile.length() + } Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp).verticalScroll(rememberScrollState())) { Spacer(Modifier.padding(vertical = 10.dp)) Text(text = stringResource(R.string.retrieve_net_logs), style = typography.headlineLarge) Spacer(Modifier.padding(vertical = 5.dp)) - Text(text = stringResource(R.string.developing)) - Spacer(Modifier.padding(vertical = 5.dp)) - SwitchItem(R.string.enable,"",null, { dpm.isNetworkLoggingEnabled(receiver) }, {dpm.setNetworkLoggingEnabled(receiver,it) }, padding = false) - Spacer(Modifier.padding(vertical = 5.dp)) - Button( - onClick = { - val log = dpm.retrieveNetworkLogs(receiver,1234567890) - if(log != null) { - for(i in log) { Log.d("NetworkLog",i.toString()) } - Toast.makeText(context, R.string.success, Toast.LENGTH_SHORT).show() - }else{ - Log.d("NetworkLog",context.getString(R.string.none)) - Toast.makeText(context, R.string.none, Toast.LENGTH_SHORT).show() - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.retrieve)) + SwitchItem(R.string.enable, "", null, { dpm.isNetworkLoggingEnabled(receiver) }, { dpm.setNetworkLoggingEnabled(receiver,it) }, padding = false) + if(fileExists) { + Text(stringResource(R.string.retrieved_logs_are, formatFileSize(fileSize))) + Button( + onClick = { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.setType("application/json") + intent.putExtra(Intent.EXTRA_TITLE, "NetworkLogs.json") + saveNetworkLogs.launch(intent) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.export_logs)) + } + Button( + onClick = { + Toast.makeText(context, if(logFile.delete()) R.string.success else R.string.failed, Toast.LENGTH_SHORT).show() + fileExists = logFile.exists() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.delete_logs)) + } } } } diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 575e670..8f263c2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -241,6 +241,9 @@ Invalid config Exclude hosts Ağ kayıtları + Retrieved logs: %1$s + Delete logs + Export logs Geri al WiFi anahtar çifti Anahtar çifti diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8b4041d..be9f59c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -236,6 +236,9 @@ 无效配置 排除列表 收集网络日志 + 已收集的日志:%1$s + 删除日志 + 导出日志 收集 WiFi密钥对 密钥对 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf6f001..a2d7b46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -245,6 +245,9 @@ Invalid config Exclude hosts Network logs + Retrieved logs: %1$s + Delete logs + Export logs Retrieve WiFi keypair Keypair diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe108d8..793b9f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ biometric = "1.2.0-alpha05" fragment = "1.8.0-beta01" dhizuku = "2.5.2" hiddenApiBypass = "4.3" +serialization = "1.7.1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } @@ -28,6 +29,8 @@ dhizuku-api = { module = "io.github.iamr0s:Dhizuku-API", version.ref = "dhizuku" hiddenApiBypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenApiBypass" } androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" } +serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }