feat: Implement CoreCaptureEngine and ScannerEngine for barcode scanning functionality
- Added CoreCaptureEngine to manage capture features and configurations. - Introduced CaptureFeature enum to define available capture features. - Created CaptureFeatureEngine interface for feature-specific implementations. - Developed ScannerEngine to handle QR and barcode scanning using ML Kit. - Implemented ScannerDelegate interface for handling scan results and errors. - Added ScannerEngineActivity to provide a UI for scanning with Compose. - Created UploadEngine class for handling uploads with presigned URLs. - Established iCameraSDK for SDK initialization and telemetry reporting. - Added TelemetryDetector for monitoring device and app telemetry. - Defined ICameraSDKError enum for error handling within the SDK. - Included example unit test for basic functionality verification. - Updated settings.gradle to include the new SDK module.
This commit is contained in:
@@ -124,4 +124,7 @@ dependencies {
|
||||
|
||||
// QR Code generation
|
||||
implementation 'com.google.zxing:core:3.5.1'
|
||||
|
||||
// iCameraSDK integration
|
||||
implementation(project(':sdk'))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "36.0.0"
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 26
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
ndkVersion = "27.1.12297006"
|
||||
|
||||
1
android/sdk/.gitignore
vendored
Normal file
1
android/sdk/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
71
android/sdk/build.gradle.kts
Normal file
71
android/sdk/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.lynkedup.icamera.sdk"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
implementation("androidx.activity:activity:1.11.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
|
||||
|
||||
// Camera dependencies
|
||||
implementation("androidx.camera:camera-view:1.5.1")
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.1")
|
||||
implementation("androidx.camera:camera-camera2:1.5.1")
|
||||
|
||||
// ML Kit Barcode Scanning
|
||||
implementation("com.google.mlkit:barcode-scanning-common:17.0.0")
|
||||
implementation("com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1")
|
||||
|
||||
// Compose dependencies
|
||||
implementation("androidx.activity:activity-compose:1.11.0")
|
||||
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3:1.4.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
0
android/sdk/consumer-rules.pro
Normal file
0
android/sdk/consumer-rules.pro
Normal file
21
android/sdk/proguard-rules.pro
vendored
Normal file
21
android/sdk/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.lynkedup.icamera.sdk
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.lynkedup.icamera.sdk.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
16
android/sdk/src/main/AndroidManifest.xml
Normal file
16
android/sdk/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.lynkedup.icamera.sdk.CoreCaptureEngine
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.ImageView
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.lynkedup.icamera.captureengine.scanner.ScannerEngine
|
||||
import com.lynkedup.icamera.sdk.ScannerEngine.ScannerDelegate
|
||||
import com.lynkedup.icamera.sdk.UploadEngine.UploadEngine
|
||||
import java.lang.ref.WeakReference
|
||||
import java.net.URL
|
||||
|
||||
public enum class CaptureFeature {
|
||||
CAPTURE_3D,
|
||||
QR_CODE,
|
||||
BAR_CODE,
|
||||
LENS
|
||||
}
|
||||
|
||||
interface CaptureFeatureEngine{
|
||||
fun start()
|
||||
fun stop()
|
||||
fun makePreview(imageView : PreviewView)
|
||||
}
|
||||
|
||||
data class CoreCaptureConfiguration(
|
||||
val enabledFeatures: List<CaptureFeature>,
|
||||
val presignedUploadURL: URL
|
||||
)
|
||||
|
||||
class CoreCaptureEngine(configuration: CoreCaptureConfiguration, context: Context, lifecycleOwner: LifecycleOwner) {
|
||||
|
||||
private val uploadEngine: UploadEngine = UploadEngine(configuration.presignedUploadURL)
|
||||
private var featureEngines: MutableMap<CaptureFeature, CaptureFeatureEngine> = mutableMapOf()
|
||||
|
||||
var scannerDelegate: ScannerDelegate? = null
|
||||
|
||||
init {
|
||||
|
||||
for (feature in configuration.enabledFeatures) {
|
||||
when (feature) {
|
||||
// CaptureFeature.CAPTURE_3D -> featureEngines[CaptureFeature.CAPTURE_3D] = CaptureEngine()
|
||||
CaptureFeature.QR_CODE -> featureEngines[CaptureFeature.QR_CODE] = ScannerEngine(
|
||||
context = context,
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
supportedFormats = listOf(Barcode.FORMAT_QR_CODE),
|
||||
delegate = scannerDelegate)
|
||||
CaptureFeature.BAR_CODE -> featureEngines[CaptureFeature.BAR_CODE] = ScannerEngine(
|
||||
context = context,
|
||||
lifecycleOwner=lifecycleOwner,
|
||||
supportedFormats = listOf(Barcode.FORMAT_QR_CODE),
|
||||
delegate = scannerDelegate)
|
||||
// CaptureFeature.LENS -> featureEngines[CaptureFeature.LENS] = LensEngine()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lynkedup.icamera.sdk.ScannerEngine
|
||||
|
||||
interface ScannerDelegate {
|
||||
fun onScanned(code: String, format: Int)
|
||||
fun onScanFailed(error: Exception)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.lynkedup.icamera.captureengine.scanner
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.lynkedup.icamera.sdk.CoreCaptureEngine.CaptureFeatureEngine
|
||||
import com.lynkedup.icamera.sdk.ScannerEngine.ScannerDelegate
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class ScannerEngine(
|
||||
private val context: Context,
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
private val supportedFormats: List<Int> = listOf(Barcode.FORMAT_QR_CODE),
|
||||
delegate: ScannerDelegate?
|
||||
) : CaptureFeatureEngine {
|
||||
|
||||
private var previewView: PreviewView? = null
|
||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var bound = false
|
||||
private val delegate: WeakReference<ScannerDelegate?> = WeakReference(delegate)
|
||||
|
||||
override fun makePreview(imageView: PreviewView) {
|
||||
if (imageView is PreviewView) {
|
||||
previewView = imageView
|
||||
} else {
|
||||
// Or handle the error appropriately, maybe by logging or throwing an exception
|
||||
// if PreviewView is strictly required.
|
||||
previewView = PreviewView(imageView.context).also {
|
||||
// You might need to add this new PreviewView to the layout manually.
|
||||
// This part depends on how you want to handle a generic ImageView.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
startScanner()
|
||||
}
|
||||
|
||||
private fun startScanner() {
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
providerFuture.addListener({
|
||||
try {
|
||||
cameraProvider = providerFuture.get()
|
||||
bindCameraUseCases()
|
||||
} catch (e: Exception) {
|
||||
delegate.get()?.onScanFailed(e)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
private fun bindCameraUseCases() {
|
||||
val provider = cameraProvider ?: run {
|
||||
delegate.get()?.onScanFailed(IllegalStateException("CameraProvider not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
// Unbind previous use cases
|
||||
provider.unbindAll()
|
||||
|
||||
val preview = Preview.Builder().build().also { p ->
|
||||
previewView?.let { p.surfaceProvider = it.surfaceProvider }
|
||||
}
|
||||
|
||||
val options = BarcodeScannerOptions.Builder().apply {
|
||||
if (supportedFormats.isEmpty()) {
|
||||
setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
} else {
|
||||
val first = supportedFormats.first()
|
||||
val rest = if (supportedFormats.size > 1) supportedFormats.drop(1).toIntArray() else intArrayOf()
|
||||
setBarcodeFormats(first, *rest)
|
||||
}
|
||||
}.build()
|
||||
val scanner = BarcodeScanning.getClient(options)
|
||||
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
analysis.setAnalyzer(cameraExecutor) { imageProxy: ImageProxy ->
|
||||
processImageProxy(scanner, imageProxy)
|
||||
}
|
||||
|
||||
try {
|
||||
provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
bound = true
|
||||
} catch (ex: Exception) {
|
||||
delegate.get()?.onScanFailed(ex)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalGetImage::class)
|
||||
private fun processImageProxy(scanner: BarcodeScanner, imageProxy: ImageProxy) {
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage == null) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
|
||||
|
||||
scanner.process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
if (barcodes.isNotEmpty()) {
|
||||
val barcode = barcodes.first()
|
||||
val raw = barcode.rawValue
|
||||
val format = barcode.format
|
||||
if (!raw.isNullOrEmpty()) {
|
||||
delegate.get()?.onScanned(raw, format)
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
delegate.get()?.onScanFailed(Exception(e))
|
||||
}
|
||||
.addOnCompleteListener {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
try {
|
||||
cameraProvider?.unbindAll()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
bound = false
|
||||
// Do not shut down executor immediately if you plan to restart quickly; otherwise:
|
||||
if (!cameraExecutor.isShutdown) {
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.lynkedup.icamera.sdk.ScannerEngine
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.lynkedup.icamera.captureengine.scanner.ScannerEngine
|
||||
|
||||
class ScannerEngineActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!com.lynkedup.icamera.sdk.iCameraSDK.isInitialized) {
|
||||
throw Exception("iCameraSDK is not initialized. Call initialize(...) first.")
|
||||
}
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
ScannerEngineScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScannerEngineScreen() {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = context as LifecycleOwner
|
||||
val previewView = remember { PreviewView(context) }
|
||||
val scannerEngine = remember {
|
||||
ScannerEngine(
|
||||
context = context,
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
supportedFormats = listOf(), // Add supported formats as needed
|
||||
delegate = object : ScannerDelegate {
|
||||
override fun onScanned(code: String, format: Int) {
|
||||
Toast.makeText(context, "Scanned: $code", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onScanFailed(error: Exception) {
|
||||
Toast.makeText(context, "Scan failed: ${'$'}{error.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
).apply {
|
||||
makePreview(previewView)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
scannerEngine.start()
|
||||
onDispose {
|
||||
scannerEngine.stop()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lynkedup.icamera.sdk.UploadEngine
|
||||
|
||||
import java.net.URL
|
||||
|
||||
class UploadEngine(uploadURL: URL) {
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.lynkedup.icamera.sdk
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.lynkedup.icamera.sdk.CoreCaptureEngine.CaptureFeature
|
||||
import com.lynkedup.icamera.sdk.idp_kit.TelemeteryReporter
|
||||
import com.lynkedup.icamera.sdk.idp_kit.TelemetryDetector
|
||||
|
||||
|
||||
|
||||
object iCameraSDK {
|
||||
|
||||
@Volatile
|
||||
private var config: SDKConfig? = null
|
||||
|
||||
@Volatile
|
||||
private var telemetry: TelemetryDetector? = null
|
||||
|
||||
|
||||
private val telemetryListener = object : TelemeteryReporter {
|
||||
override fun onReport(json: String) {
|
||||
Log.d("iCameraSDK.Telemetry", json)
|
||||
}
|
||||
}
|
||||
|
||||
val isInitialized: Boolean
|
||||
get() = config != null
|
||||
|
||||
/**
|
||||
* Initialize the SDK. Safe to call from any thread. Uses application context.
|
||||
* Throws IllegalArgumentException on invalid inputs.
|
||||
*/
|
||||
fun initialize(context: Context, presignedUrl: String, features: List<CaptureFeature>) {
|
||||
require(presignedUrl.isNotBlank()) { "presignedUrl must not be empty" }
|
||||
require(features.isNotEmpty()) { "features must contain at least one CaptureFeature" }
|
||||
|
||||
val appCtx = context.applicationContext
|
||||
val cfg = SDKConfig(appContext = appCtx, presignedUrl = presignedUrl.trim(), features = features.toSet())
|
||||
config = cfg
|
||||
|
||||
// start telemetry after config is set
|
||||
startTelemetryIfNeeded(appCtx)
|
||||
}
|
||||
|
||||
fun initialize(context: Context, presignedUrl: String, vararg features: CaptureFeature) {
|
||||
initialize(context, presignedUrl, features.toList())
|
||||
}
|
||||
|
||||
fun getConfig(): SDKConfig {
|
||||
return config ?: throw IllegalStateException("iCameraSDK is not initialized. Call initialize(...) first.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears SDK state. Use carefully (for tests or app shutdown).
|
||||
*/
|
||||
fun shutdown() {
|
||||
stopTelemetry()
|
||||
config = null
|
||||
}
|
||||
|
||||
private fun startTelemetryIfNeeded(appCtx: Context) {
|
||||
if (telemetry != null) return
|
||||
try {
|
||||
val td = TelemetryDetector(appCtx, telemetryListener)
|
||||
td.start()
|
||||
telemetry = td
|
||||
Log.d("iCameraSDK", "Telemetry started")
|
||||
} catch (e: Exception) {
|
||||
Log.w("iCameraSDK", "Failed to start telemetry: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTelemetry() {
|
||||
try {
|
||||
telemetry?.stop()
|
||||
telemetry = null
|
||||
Log.d("iCameraSDK", "Telemetry stopped")
|
||||
} catch (e: Exception) {
|
||||
Log.w("iCameraSDK", "Failed to stop telemetry: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class SDKConfig(
|
||||
val appContext: Context,
|
||||
val presignedUrl: String,
|
||||
val features: Set<CaptureFeature>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lynkedup.icamera.sdk.idp_kit
|
||||
|
||||
|
||||
interface TelemeteryReporter {
|
||||
fun onReport(json: String)
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
package com.lynkedup.icamera.sdk.idp_kit
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.MemoryInfo
|
||||
import android.app.AppOpsManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.BatteryManager
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import android.telephony.PhoneStateListener
|
||||
import android.telephony.TelephonyManager
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
|
||||
/**
|
||||
*{
|
||||
* "trigger": "wifi_change",
|
||||
* "timestamp": 1764607091305,
|
||||
* "uptime_millis": 560764,
|
||||
* "sdk_int": 35,
|
||||
* "package_name": "com.lynkedup.icamera.app",
|
||||
* "app": {
|
||||
* "version_name": "1.0",
|
||||
* "version_code": 1,
|
||||
* "first_install_time": 1764443073758,
|
||||
* "last_update_time": 1764443073758,
|
||||
* "requested_permissions": [
|
||||
* {
|
||||
* "name": "android.permission.INTERNET",
|
||||
* "granted": true
|
||||
* },
|
||||
* {
|
||||
* "name": "android.permission.ACCESS_NETWORK_STATE",
|
||||
* "granted": true
|
||||
* },
|
||||
* {
|
||||
* "name": "android.permission.READ_PRIVILEGED_PHONE_STATE",
|
||||
* "granted": false
|
||||
* },
|
||||
* {
|
||||
* "name": "android.permission.READ_PHONE_STATE",
|
||||
* "granted": false
|
||||
* },
|
||||
* {
|
||||
* "name": "android.permission.ACCESS_WIFI_STATE",
|
||||
* "granted": true
|
||||
* },
|
||||
* {
|
||||
* "name": "android.permission.WAKE_LOCK",
|
||||
* "granted": true
|
||||
* },
|
||||
* {
|
||||
* "name": "android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
* "granted": true
|
||||
* },
|
||||
* {
|
||||
* "name": "android.permission.ACCESS_COARSE_LOCATION",
|
||||
* "granted": false
|
||||
* },
|
||||
* {
|
||||
* "name": "com.lynkedup.icamera.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
|
||||
* "granted": true
|
||||
* }
|
||||
* ]
|
||||
* },
|
||||
* "permissions": {
|
||||
* "android.permission.ACCESS_NETWORK_STATE": true,
|
||||
* "android.permission.INTERNET": true,
|
||||
* "android.permission.WAKE_LOCK": true,
|
||||
* "android.permission.QUERY_ALL_PACKAGES": false,
|
||||
* "android.permission.READ_PHONE_STATE": false,
|
||||
* "android.permission.ACTIVITY_RECOGNITION": false,
|
||||
* "android.permission.RECEIVE_BOOT_COMPLETED": true,
|
||||
* "android.permission.ACCESS_WIFI_STATE": true,
|
||||
* "android.permission.PACKAGE_USAGE_STATS": false,
|
||||
* "android.permission.FOREGROUND_SERVICE_DATA_SYNC": false
|
||||
* },
|
||||
* "usage_stats_allowed": false,
|
||||
* "network": {
|
||||
* "isConnected": true,
|
||||
* "type": "WIFI",
|
||||
* "type_code": null,
|
||||
* "down_kbps": 30000,
|
||||
* "up_kbps": 12000,
|
||||
* "subtype_name": null,
|
||||
* "subtype": null
|
||||
* },
|
||||
* "wifi": {
|
||||
* "ssid": "<unknown ssid>",
|
||||
* "bssid": "02:00:00:00:00:00",
|
||||
* "rssi": -50,
|
||||
* "link_speed_mbps": 11,
|
||||
* "local_ip": "10.0.2.16"
|
||||
* },
|
||||
* "wifi_ssid": "<unknown ssid>",
|
||||
* "device_id": null,
|
||||
* "battery": {
|
||||
* "level_percent": 100,
|
||||
* "status": "not_charging"
|
||||
* },
|
||||
* "memory": {
|
||||
* "avail_mem": 888184832,
|
||||
* "total_mem": 2070986752,
|
||||
* "low_memory": false
|
||||
* },
|
||||
* "storage": {
|
||||
* "total_bytes": 6228115456,
|
||||
* "free_bytes": 996352000
|
||||
* },
|
||||
* "developer_options_enabled": false,
|
||||
* "is_rooted": false,
|
||||
* "root_checks": [
|
||||
*
|
||||
* ],
|
||||
* "is_emulator": true,
|
||||
* "device_type": "EMULATOR",
|
||||
* "emulator_checks": [
|
||||
* "product_indicates_emulator",
|
||||
* "hardware_indicates_emulator"
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
*
|
||||
* */
|
||||
|
||||
class TelemetryDetector(private val context: Context, private val listener: TelemeteryReporter) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val connectivity =
|
||||
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
private val telephony =
|
||||
appContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private var wifiReceiver: BroadcastReceiver? = null
|
||||
private var packageReceiver: BroadcastReceiver? = null
|
||||
private var phoneListener: PhoneStateListener? = null
|
||||
private var running = false
|
||||
|
||||
// periodic reporting
|
||||
private val reportIntervalMs = 15000L
|
||||
private val reportHandler = Handler(Looper.getMainLooper())
|
||||
private var reportRunnable: Runnable? = null
|
||||
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
registerNetworkCallback()
|
||||
registerWifiReceiver()
|
||||
registerPackageReceiver()
|
||||
registerPhoneListener()
|
||||
// emit initial snapshot
|
||||
emitReport("initial")
|
||||
// schedule periodic reports
|
||||
schedulePeriodicReports()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running) return
|
||||
running = false
|
||||
unregisterNetworkCallback()
|
||||
unregisterWifiReceiver()
|
||||
unregisterPackageReceiver()
|
||||
unregisterPhoneListener()
|
||||
cancelPeriodicReports()
|
||||
}
|
||||
|
||||
private fun schedulePeriodicReports() {
|
||||
// ensure previous runnable removed
|
||||
cancelPeriodicReports()
|
||||
val r = object : Runnable {
|
||||
override fun run() {
|
||||
try {
|
||||
if (!running) return
|
||||
emitReport("periodic")
|
||||
} finally {
|
||||
// re-post only if still running
|
||||
if (running) {
|
||||
reportHandler.postDelayed(this, reportIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reportRunnable = r
|
||||
reportHandler.postDelayed(r, reportIntervalMs)
|
||||
}
|
||||
|
||||
private fun cancelPeriodicReports() {
|
||||
reportRunnable?.let {
|
||||
reportHandler.removeCallbacks(it)
|
||||
}
|
||||
reportRunnable = null
|
||||
}
|
||||
|
||||
private fun registerNetworkCallback() {
|
||||
try {
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
emitReport("network_available")
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
emitReport("network_lost")
|
||||
}
|
||||
}
|
||||
val req = NetworkRequest.Builder().build()
|
||||
connectivity.registerNetworkCallback(req, cb)
|
||||
networkCallback = cb
|
||||
} catch (e: Exception) {
|
||||
Log.w("TelemetryDetector", "network callback failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
try {
|
||||
networkCallback?.let { connectivity.unregisterNetworkCallback(it) }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
networkCallback = null
|
||||
}
|
||||
|
||||
private fun registerWifiReceiver() {
|
||||
try {
|
||||
val rx = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
if (action == WifiManager.NETWORK_STATE_CHANGED_ACTION || action == WifiManager.WIFI_STATE_CHANGED_ACTION) {
|
||||
emitReport("wifi_change")
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
|
||||
addAction(WifiManager.WIFI_STATE_CHANGED_ACTION)
|
||||
}
|
||||
appContext.registerReceiver(rx, filter)
|
||||
wifiReceiver = rx
|
||||
} catch (e: Exception) {
|
||||
Log.w("TelemetryDetector", "wifi receiver failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterWifiReceiver() {
|
||||
try {
|
||||
wifiReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
wifiReceiver = null
|
||||
}
|
||||
|
||||
private fun registerPackageReceiver() {
|
||||
try {
|
||||
val rx = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
if (action == Intent.ACTION_PACKAGE_ADDED || action == Intent.ACTION_PACKAGE_REMOVED || action == Intent.ACTION_PACKAGE_CHANGED) {
|
||||
emitReport("package_change")
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(Intent.ACTION_PACKAGE_CHANGED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
appContext.registerReceiver(rx, filter)
|
||||
packageReceiver = rx
|
||||
} catch (e: Exception) {
|
||||
Log.w("TelemetryDetector", "package receiver failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterPackageReceiver() {
|
||||
try {
|
||||
packageReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
packageReceiver = null
|
||||
}
|
||||
|
||||
private fun registerPhoneListener() {
|
||||
try {
|
||||
val pl = object : PhoneStateListener() {
|
||||
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
|
||||
emitReport("phone_state_change")
|
||||
}
|
||||
}
|
||||
telephony.listen(pl, PhoneStateListener.LISTEN_CALL_STATE)
|
||||
phoneListener = pl
|
||||
} catch (e: Exception) {
|
||||
Log.w("TelemetryDetector", "phone listener failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterPhoneListener() {
|
||||
try {
|
||||
phoneListener?.let { telephony.listen(it, PhoneStateListener.LISTEN_NONE) }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
phoneListener = null
|
||||
}
|
||||
|
||||
private fun emitReport(trigger: String) {
|
||||
try {
|
||||
val pretty = buildReport(trigger)
|
||||
Log.d("TelemetryDetector", pretty) // pretty printed JSON
|
||||
listener.onReport(pretty)
|
||||
} catch (e: Exception) {
|
||||
Log.w("TelemetryDetector", "emit failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildReport(trigger: String): String {
|
||||
val report = JSONObject()
|
||||
|
||||
report.put("trigger", trigger)
|
||||
report.put("timestamp", System.currentTimeMillis())
|
||||
report.put("uptime_millis", SystemClock.elapsedRealtime())
|
||||
report.put("sdk_int", Build.VERSION.SDK_INT)
|
||||
report.put("package_name", appContext.packageName)
|
||||
|
||||
// app / package info
|
||||
try {
|
||||
val pm = appContext.packageManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val pkgInfo: PackageInfo? = try {
|
||||
pm.getPackageInfo(appContext.packageName, PackageManager.GET_PERMISSIONS)
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
pm.getPackageInfo(appContext.packageName, 0)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
pkgInfo?.let {
|
||||
val appJson = JSONObject()
|
||||
val versionName = try {
|
||||
it.versionName
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val versionCode = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) it.longVersionCode else it.versionCode.toLong()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
appJson.put("version_name", versionName ?: JSONObject.NULL)
|
||||
appJson.put("version_code", versionCode ?: JSONObject.NULL)
|
||||
appJson.put("first_install_time", it.firstInstallTime)
|
||||
appJson.put("last_update_time", it.lastUpdateTime)
|
||||
// requested permissions + grant state
|
||||
try {
|
||||
val permsArr = JSONArray()
|
||||
val requested = it.requestedPermissions
|
||||
if (requested != null) {
|
||||
for (p in requested) {
|
||||
val pJson = JSONObject()
|
||||
pJson.put("name", p)
|
||||
val granted = try {
|
||||
ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
p
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
pJson.put("granted", granted)
|
||||
permsArr.put(pJson)
|
||||
}
|
||||
}
|
||||
appJson.put("requested_permissions", permsArr)
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
report.put("app", appJson)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
report.put("app_info_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// permissions of interest (explicit list)
|
||||
try {
|
||||
val permissionList = listOf(
|
||||
Manifest.permission.ACCESS_NETWORK_STATE,
|
||||
Manifest.permission.INTERNET,
|
||||
Manifest.permission.WAKE_LOCK,
|
||||
"android.permission.QUERY_ALL_PACKAGES",
|
||||
Manifest.permission.READ_PHONE_STATE,
|
||||
"android.permission.ACTIVITY_RECOGNITION",
|
||||
Manifest.permission.RECEIVE_BOOT_COMPLETED,
|
||||
Manifest.permission.ACCESS_WIFI_STATE,
|
||||
"android.permission.PACKAGE_USAGE_STATS",
|
||||
"android.permission.FOREGROUND_SERVICE_DATA_SYNC"
|
||||
)
|
||||
val permsJson = JSONObject()
|
||||
for (perm in permissionList) {
|
||||
val granted = try {
|
||||
ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
perm
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
permsJson.put(perm, granted)
|
||||
}
|
||||
report.put("permissions", permsJson)
|
||||
} catch (e: Exception) {
|
||||
report.put("permissions_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// usage access check
|
||||
try {
|
||||
val appOps = appContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||
val hasUsageAccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
appOps.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||
Binder.getCallingUid(),
|
||||
appContext.packageName
|
||||
) == AppOpsManager.MODE_ALLOWED
|
||||
} else {
|
||||
false
|
||||
}
|
||||
report.put("usage_stats_allowed", hasUsageAccess)
|
||||
} catch (e: Exception) {
|
||||
report.put("usage_stats_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// network basic
|
||||
try {
|
||||
val networkJson = JSONObject()
|
||||
val activeNetwork = connectivity.activeNetwork
|
||||
if (activeNetwork != null) {
|
||||
val caps = try {
|
||||
connectivity.getNetworkCapabilities(activeNetwork)
|
||||
} catch (_: kotlin.Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val isConnected = caps != null && (
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)
|
||||
)
|
||||
networkJson.put("isConnected", isConnected)
|
||||
|
||||
val type = when {
|
||||
caps == null -> JSONObject.NULL
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WIFI"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "CELLULAR"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ETHERNET"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> "BLUETOOTH"
|
||||
else -> "OTHER"
|
||||
}
|
||||
networkJson.put("type", type)
|
||||
|
||||
// no direct type_code with new API; keep numeric fields null for compatibility
|
||||
networkJson.put("type_code", JSONObject.NULL)
|
||||
|
||||
// bandwidth (kbps) if available
|
||||
networkJson.put("down_kbps", caps?.linkDownstreamBandwidthKbps ?: JSONObject.NULL)
|
||||
networkJson.put("up_kbps", caps?.linkUpstreamBandwidthKbps ?: JSONObject.NULL)
|
||||
|
||||
// attempt to include telephony subtype info when on cellular (numeric values)
|
||||
val subtypeName: Any = try {
|
||||
if (caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) {
|
||||
// numeric network type; caller can map if needed
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) telephony.dataNetworkType else telephony.networkType
|
||||
} else JSONObject.NULL
|
||||
} catch (_: kotlin.Exception) {
|
||||
JSONObject.NULL
|
||||
}
|
||||
|
||||
networkJson.put("subtype_name", subtypeName)
|
||||
networkJson.put("subtype", JSONObject.NULL) // keep for compatibility
|
||||
} else {
|
||||
networkJson.put("isConnected", false)
|
||||
networkJson.put("type", JSONObject.NULL)
|
||||
networkJson.put("type_code", JSONObject.NULL)
|
||||
networkJson.put("subtype_name", JSONObject.NULL)
|
||||
networkJson.put("subtype", JSONObject.NULL)
|
||||
networkJson.put("down_kbps", JSONObject.NULL)
|
||||
networkJson.put("up_kbps", JSONObject.NULL)
|
||||
}
|
||||
report.put("network", networkJson)
|
||||
} catch (e: Exception) {
|
||||
report.put("network_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// wifi details
|
||||
try {
|
||||
val wifiJson = JSONObject()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val info = wifiManager.connectionInfo
|
||||
if (info != null) {
|
||||
val ssid = try {
|
||||
info.ssid
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val bssid = try {
|
||||
info.bssid
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val rssi = try {
|
||||
info.rssi
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val linkSpeed = try {
|
||||
info.linkSpeed
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val ipInt = try {
|
||||
info.ipAddress
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
val ip = try {
|
||||
if (ipInt != 0) Formatter.formatIpAddress(ipInt) else JSONObject.NULL
|
||||
} catch (_: Exception) {
|
||||
JSONObject.NULL
|
||||
}
|
||||
|
||||
wifiJson.put("ssid", ssid ?: JSONObject.NULL)
|
||||
wifiJson.put("bssid", bssid ?: JSONObject.NULL)
|
||||
wifiJson.put("rssi", rssi ?: JSONObject.NULL)
|
||||
wifiJson.put("link_speed_mbps", linkSpeed ?: JSONObject.NULL)
|
||||
wifiJson.put("local_ip", ip)
|
||||
} else {
|
||||
wifiJson.put("connected", false)
|
||||
}
|
||||
report.put("wifi", wifiJson)
|
||||
report.put("wifi_ssid", (info?.ssid ?: JSONObject.NULL))
|
||||
} catch (e: Exception) {
|
||||
report.put("wifi_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// device id only if permission granted
|
||||
try {
|
||||
var deviceId: Any = JSONObject.NULL
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.READ_PHONE_STATE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
deviceId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
telephony.imei ?: JSONObject.NULL
|
||||
} catch (_: Throwable) {
|
||||
JSONObject.NULL
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
telephony.deviceId ?: JSONObject.NULL
|
||||
}
|
||||
}
|
||||
report.put("device_id", deviceId)
|
||||
} catch (e: Exception) {
|
||||
report.put("device_id_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// battery info
|
||||
try {
|
||||
val battIntent =
|
||||
appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val battJson = JSONObject()
|
||||
if (battIntent != null) {
|
||||
val level = battIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = battIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
val pct = if (level >= 0 && scale > 0) (level * 100) / scale else JSONObject.NULL
|
||||
val status = battIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
val statusStr = when (status) {
|
||||
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
|
||||
BatteryManager.BATTERY_STATUS_FULL -> "full"
|
||||
BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging"
|
||||
BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging"
|
||||
BatteryManager.BATTERY_STATUS_UNKNOWN -> "unknown"
|
||||
else -> "unknown"
|
||||
}
|
||||
battJson.put("level_percent", pct)
|
||||
battJson.put("status", statusStr)
|
||||
}
|
||||
report.put("battery", battJson)
|
||||
} catch (e: Exception) {
|
||||
report.put("battery_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// memory info
|
||||
try {
|
||||
val am = appContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val mi = MemoryInfo()
|
||||
am.getMemoryInfo(mi)
|
||||
val memJson = JSONObject()
|
||||
memJson.put("avail_mem", mi.availMem)
|
||||
memJson.put(
|
||||
"total_mem",
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) mi.totalMem else JSONObject.NULL
|
||||
)
|
||||
memJson.put("low_memory", mi.lowMemory)
|
||||
report.put("memory", memJson)
|
||||
} catch (e: Exception) {
|
||||
report.put("memory_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// storage info (app data dir)
|
||||
try {
|
||||
val dataDir = appContext.filesDir
|
||||
val st = StatFs(dataDir.absolutePath)
|
||||
val blockSize = st.blockSizeLong
|
||||
val total = st.blockCountLong * blockSize
|
||||
val free = st.availableBlocksLong * blockSize
|
||||
val storageJson = JSONObject()
|
||||
storageJson.put("total_bytes", total)
|
||||
storageJson.put("free_bytes", free)
|
||||
report.put("storage", storageJson)
|
||||
} catch (e: Exception) {
|
||||
report.put("storage_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// developer options enabled
|
||||
try {
|
||||
val devEnabled = isDeveloperOptionsEnabled()
|
||||
report.put("developer_options_enabled", devEnabled)
|
||||
} catch (e: Exception) {
|
||||
report.put("developer_options_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
// root detection
|
||||
try {
|
||||
val rootChecks = JSONArray()
|
||||
val isRooted = isDeviceRooted(rootChecks)
|
||||
report.put("is_rooted", isRooted)
|
||||
report.put("root_checks", rootChecks)
|
||||
} catch (e: Exception) {
|
||||
report.put("root_detection_error", e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
val emuChecks = JSONArray()
|
||||
val isEmu = detectEmulator(emuChecks)
|
||||
report.put("is_emulator", isEmu)
|
||||
report.put("device_type", if (isEmu) "EMULATOR" else "PHYSICAL")
|
||||
if (emuChecks.length() > 0) report.put("emulator_checks", emuChecks)
|
||||
} catch (e: Exception) {
|
||||
report.put("emulator_detection_error", e.javaClass.simpleName)
|
||||
}
|
||||
// final pretty string (indent = 2)
|
||||
return try {
|
||||
report.toString(2)
|
||||
} catch (e: Exception) {
|
||||
// fallback to compact
|
||||
report.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDeveloperOptionsEnabled(): Boolean {
|
||||
return try {
|
||||
val resolver = appContext.contentResolver
|
||||
val enabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
Settings.Global.getInt(resolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Settings.Secure.getInt(resolver, Settings.Secure.DEVELOPMENT_SETTINGS_ENABLED, 0)
|
||||
}
|
||||
enabled == 1
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDeviceRooted(outReasons: JSONArray): Boolean {
|
||||
try {
|
||||
// 1) Build tags
|
||||
val tags = Build.TAGS
|
||||
if (!tags.isNullOrEmpty() && (tags.contains("test-keys") || tags.contains("dev-keys"))) {
|
||||
outReasons.put("build_tags_contains_test_or_dev_keys")
|
||||
}
|
||||
|
||||
// 2) Common su locations
|
||||
val suPaths = arrayOf(
|
||||
"/system/app/Superuser.apk",
|
||||
"/sbin/su",
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
"/data/local/xbin/su",
|
||||
"/data/local/bin/su",
|
||||
"/system/sd/xbin/su",
|
||||
"/system/bin/failsafe/su",
|
||||
"/data/local/su"
|
||||
)
|
||||
for (p in suPaths) {
|
||||
try {
|
||||
if (File(p).exists()) {
|
||||
outReasons.put("su_binary_found:$p")
|
||||
}
|
||||
} catch (_: Exception) { /* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Presence of su in PATH via `which su`
|
||||
try {
|
||||
val proc = Runtime.getRuntime().exec(arrayOf("/system/bin/sh", "-c", "which su"))
|
||||
BufferedReader(InputStreamReader(proc.inputStream)).use { br ->
|
||||
val line = br.readLine()
|
||||
if (!line.isNullOrEmpty()) {
|
||||
outReasons.put("which_su_found:$line")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore failures
|
||||
}
|
||||
|
||||
// 4) Superuser.apk in package manager (loosely)
|
||||
try {
|
||||
val pm = appContext.packageManager
|
||||
val packages = pm.getInstalledPackages(0)
|
||||
for (pkg in packages) {
|
||||
val name = pkg.packageName
|
||||
if (name.equals("com.noshufou.android.su", ignoreCase = true) ||
|
||||
name.equals("eu.chainfire.supersu", ignoreCase = true) ||
|
||||
name.equals("com.koushikdutta.superuser", ignoreCase = true) ||
|
||||
name.contains("super", ignoreCase = true) && name.contains(
|
||||
"user",
|
||||
ignoreCase = true
|
||||
)
|
||||
) {
|
||||
outReasons.put("suspicious_superuser_package:$name")
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return outReasons.length() > 0
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectEmulator(outReasons: JSONArray): Boolean {
|
||||
try {
|
||||
val fingerprint = Build.FINGERPRINT ?: ""
|
||||
val model = Build.MODEL ?: ""
|
||||
val manufacturer = Build.MANUFACTURER ?: ""
|
||||
val brand = Build.BRAND ?: ""
|
||||
val device = Build.DEVICE ?: ""
|
||||
val product = Build.PRODUCT ?: ""
|
||||
val hardware = Build.HARDWARE ?: ""
|
||||
val board = Build.BOARD ?: ""
|
||||
|
||||
val reasons = mutableListOf<String>()
|
||||
|
||||
if (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown")) {
|
||||
reasons.add("fingerprint_generic_or_unknown")
|
||||
}
|
||||
if (model.contains("Emulator", ignoreCase = true) ||
|
||||
model.contains("Android SDK built for", ignoreCase = true) ||
|
||||
model.contains("google_sdk", ignoreCase = true)
|
||||
) {
|
||||
reasons.add("model_indicates_emulator")
|
||||
}
|
||||
if (manufacturer.contains("Genymotion", ignoreCase = true)) {
|
||||
reasons.add("manufacturer_genymotion")
|
||||
}
|
||||
if (brand.startsWith("generic") && device.startsWith("generic")) {
|
||||
reasons.add("brand_and_device_generic")
|
||||
}
|
||||
if (product.contains("sdk", ignoreCase = true) ||
|
||||
product.contains("vbox86p", ignoreCase = true) ||
|
||||
product.contains("emulator", ignoreCase = true)
|
||||
) {
|
||||
reasons.add("product_indicates_emulator")
|
||||
}
|
||||
if (hardware.contains("goldfish", ignoreCase = true) ||
|
||||
hardware.contains("ranchu", ignoreCase = true) ||
|
||||
hardware.contains("qemu", ignoreCase = true)
|
||||
) {
|
||||
reasons.add("hardware_indicates_emulator")
|
||||
}
|
||||
if (board.equals("unknown", ignoreCase = true)) {
|
||||
reasons.add("board_unknown")
|
||||
}
|
||||
|
||||
// common emulator files / sockets
|
||||
try {
|
||||
if (File("/dev/socket/qemu_pipe").exists()) reasons.add("qemu_pipe_present")
|
||||
if (File("/system/lib/libc_malloc_debug_qemu.so").exists()) reasons.add("qemu_so_present")
|
||||
if (File("/system/bin/qemu-props").exists()) reasons.add("qemu_props_present")
|
||||
} catch (_: kotlin.Exception) { /* ignore filesystem access errors */
|
||||
}
|
||||
|
||||
if (reasons.isNotEmpty()) {
|
||||
for (r in reasons) outReasons.put(r)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (_: kotlin.Exception) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.lynkedup.icamera.sdk.utils
|
||||
|
||||
class iCameraSdkConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lynkedup.icamera.sdk.utils
|
||||
|
||||
|
||||
/**
|
||||
* Represents a set of possible errors that can occur within the iCameraSDK.
|
||||
*/
|
||||
public enum class ICameraSDKError {
|
||||
/**
|
||||
* The user has denied access to the camera.
|
||||
*/
|
||||
CAMERA_ACCESS_DENIED,
|
||||
|
||||
/**
|
||||
* The configuration provided to the SDK is invalid.
|
||||
*/
|
||||
INVALID_CONFIGURATION,
|
||||
|
||||
/**
|
||||
* The file upload process failed.
|
||||
*/
|
||||
UPLOAD_FAILED,
|
||||
|
||||
/**
|
||||
* An unknown or unspecified error occurred.
|
||||
*/
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lynkedup.icamera.sdk
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ plugins { id("com.facebook.react.settings") }
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
|
||||
rootProject.name = 'LynkedUpPro'
|
||||
include ':app'
|
||||
include ':sdk'
|
||||
includeBuild('../node_modules/@react-native/gradle-plugin')
|
||||
|
||||
Reference in New Issue
Block a user