├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── runConfigurations.xml ├── studiobot.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── eu │ │ └── davidmayr │ │ └── pcremote │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── eu │ │ │ └── davidmayr │ │ │ └── pcremote │ │ │ ├── BarcodeAnalyzer.kt │ │ │ ├── MainActivity.kt │ │ │ ├── PermissionRequestDialog.kt │ │ │ ├── QRCodeScannerScreen.kt │ │ │ ├── SPenViewModel.kt │ │ │ ├── WebSocketViewModel.kt │ │ │ └── ui │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-de │ │ └── strings.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── eu │ └── davidmayr │ └── pcremote │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── desktop-app ├── .run │ └── desktop.run.xml ├── build.gradle.kts ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src │ └── main │ ├── kotlin │ ├── LaserPointer.kt │ ├── Main.kt │ └── WebServer.kt │ └── resources │ ├── icon.ico │ └── icon.svg ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | 17 | desktop-app/build -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 30 | 31 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/studiobot.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S Pen PC Remote 2 | 3 | Connect to your PC using your Galaxy Ultra phone and use the S Pen for presentations. 4 | 5 | S Pen button single click = Next slide 6 | S Pen button double click = Previous slide 7 | S Pen button pressed = Laser Pointer 8 | 9 | Note: This is a proof of concept and the code is quite messy. This is also my first native Android app ever. -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.kotlin.compose) 5 | id("kotlin-kapt") 6 | } 7 | 8 | android { 9 | namespace = "eu.davidmayr.pcremote" 10 | compileSdk = 34 11 | 12 | defaultConfig { 13 | applicationId = "eu.davidmayr.pcremote" 14 | minSdk = 34 15 | targetSdk = 34 16 | versionCode = 1 17 | versionName = "1.0" 18 | 19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 20 | } 21 | 22 | buildTypes { 23 | release { 24 | isMinifyEnabled = false 25 | proguardFiles( 26 | getDefaultProguardFile("proguard-android-optimize.txt"), 27 | "proguard-rules.pro" 28 | ) 29 | } 30 | } 31 | compileOptions { 32 | sourceCompatibility = JavaVersion.VERSION_11 33 | targetCompatibility = JavaVersion.VERSION_11 34 | } 35 | kotlinOptions { 36 | jvmTarget = "11" 37 | } 38 | buildFeatures { 39 | compose = true 40 | } 41 | } 42 | 43 | dependencies { 44 | implementation(fileTree(mapOf("include" to "*.jar", "dir" to "libs"))) 45 | 46 | implementation(libs.androidx.core.ktx) 47 | implementation(libs.androidx.lifecycle.runtime.ktx) 48 | implementation(libs.androidx.activity.compose) 49 | implementation(platform(libs.androidx.compose.bom)) 50 | implementation(libs.androidx.ui) 51 | implementation(libs.androidx.ui.graphics) 52 | implementation(libs.androidx.ui.tooling.preview) 53 | implementation(libs.androidx.material3) 54 | testImplementation(libs.junit) 55 | androidTestImplementation(libs.androidx.junit) 56 | androidTestImplementation(libs.androidx.espresso.core) 57 | androidTestImplementation(platform(libs.androidx.compose.bom)) 58 | androidTestImplementation(libs.androidx.ui.test.junit4) 59 | debugImplementation(libs.androidx.ui.tooling) 60 | debugImplementation(libs.androidx.ui.test.manifest) 61 | implementation(libs.androidx.camera.core) 62 | implementation(libs.androidx.camera.camera2) 63 | implementation(libs.androidx.camera.lifecycle) 64 | implementation(libs.androidx.camera.view) 65 | implementation(libs.barcode.scanning) 66 | implementation(libs.hilt.navigation.compose) 67 | // https://mvnrepository.com/artifact/com.google.code.gson/gson 68 | implementation("com.google.code.gson:gson:2.11.0") 69 | implementation("com.squareup.okhttp3:okhttp:4.12.0") 70 | 71 | implementation(libs.androidx.navigation.compose) 72 | 73 | } 74 | 75 | kapt { 76 | correctErrorTypes = true 77 | } 78 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/eu/davidmayr/pcremote/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("eu.davidmayr.pcremote", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 19 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/BarcodeAnalyzer.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.widget.Toast 6 | import androidx.camera.core.ImageAnalysis 7 | import androidx.camera.core.ImageProxy 8 | import com.google.gson.JsonObject 9 | import com.google.gson.JsonParser 10 | import com.google.mlkit.vision.barcode.BarcodeScannerOptions 11 | import com.google.mlkit.vision.barcode.BarcodeScanning 12 | import com.google.mlkit.vision.barcode.common.Barcode 13 | import com.google.mlkit.vision.common.InputImage 14 | 15 | class BarcodeAnalyzer(private val context: Context, val response: (JsonObject) -> Unit) : ImageAnalysis.Analyzer { 16 | 17 | private val options = BarcodeScannerOptions.Builder() 18 | .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) 19 | .build() 20 | 21 | private val scanner = BarcodeScanning.getClient(options) 22 | 23 | @SuppressLint("UnsafeOptInUsageError") 24 | override fun analyze(imageProxy: ImageProxy) { 25 | imageProxy.image?.let { image -> 26 | scanner.process( 27 | InputImage.fromMediaImage( 28 | image, imageProxy.imageInfo.rotationDegrees 29 | ) 30 | ).addOnSuccessListener { barcode -> 31 | barcode?.takeIf { it.isNotEmpty() } 32 | ?.mapNotNull { it.rawValue } 33 | ?.joinToString(",") 34 | ?.let { 35 | try { 36 | val json = JsonParser.parseString(it).asJsonObject 37 | if(!json.has("type") || json.get("type").asString != "pcmanager/remotconfig") 38 | return@let 39 | 40 | response(json) 41 | } catch (_: Exception) {} 42 | } 43 | }.addOnCompleteListener { 44 | imageProxy.close() 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote 2 | 3 | import android.graphics.Paint 4 | import android.os.Bundle 5 | import android.view.WindowManager 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.activity.enableEdgeToEdge 9 | import androidx.activity.viewModels 10 | import androidx.compose.foundation.Image 11 | import androidx.compose.foundation.layout.Arrangement 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.Column 14 | import androidx.compose.foundation.layout.Row 15 | import androidx.compose.foundation.layout.fillMaxHeight 16 | import androidx.compose.foundation.layout.fillMaxSize 17 | import androidx.compose.foundation.layout.fillMaxWidth 18 | import androidx.compose.foundation.layout.padding 19 | import androidx.compose.material.icons.Icons 20 | import androidx.compose.material.icons.filled.Check 21 | import androidx.compose.material.icons.filled.CheckCircle 22 | import androidx.compose.material.icons.filled.Clear 23 | import androidx.compose.material3.Button 24 | import androidx.compose.material3.ExperimentalMaterial3Api 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.Scaffold 27 | import androidx.compose.material3.Switch 28 | import androidx.compose.material3.Text 29 | import androidx.compose.material3.TopAppBar 30 | import androidx.compose.material3.TopAppBarDefaults 31 | import androidx.compose.runtime.Composable 32 | import androidx.compose.ui.Alignment 33 | import androidx.compose.ui.Modifier 34 | import androidx.compose.ui.graphics.Color 35 | import androidx.compose.ui.graphics.ColorFilter 36 | import androidx.compose.ui.platform.LocalContext 37 | import androidx.compose.ui.text.font.FontWeight 38 | import androidx.compose.ui.tooling.preview.Preview 39 | import androidx.compose.ui.unit.dp 40 | import androidx.navigation.NavController 41 | import androidx.navigation.compose.NavHost 42 | import androidx.navigation.compose.composable 43 | import androidx.navigation.compose.rememberNavController 44 | import eu.davidmayr.pcremote.ui.theme.PCRemoteTheme 45 | 46 | class MainActivity : ComponentActivity() { 47 | 48 | 49 | private val webSocketViewModel by viewModels() 50 | 51 | //Needs to be stored here so we can disconnect with context 52 | private val sPenViewModel by viewModels(factoryProducer = { 53 | SPenViewModel.SPenViewModelFactory(webSocketViewModel) 54 | }) 55 | 56 | 57 | override fun onCreate(savedInstanceState: Bundle?) { 58 | super.onCreate(savedInstanceState) 59 | 60 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 61 | 62 | sPenViewModel.connect(this) 63 | enableEdgeToEdge() 64 | setContent { 65 | PCRemoteTheme { 66 | Navigation(sPenViewModel, webSocketViewModel) 67 | } 68 | } 69 | } 70 | 71 | override fun onDestroy() { 72 | super.onDestroy() 73 | sPenViewModel.disconnect(this) 74 | } 75 | 76 | } 77 | 78 | @Composable 79 | fun Navigation( 80 | sPenViewModel: SPenViewModel, 81 | webSocketViewModel: WebSocketViewModel, 82 | modifier: Modifier = Modifier 83 | ) { 84 | val navController = rememberNavController() 85 | 86 | NavHost(navController, "home") { 87 | composable("home") { 88 | PCRemote(navController, sPenViewModel, webSocketViewModel) 89 | } 90 | composable("scan") { 91 | QRCodeScannerScreen(navController, webSocketViewModel) 92 | } 93 | } 94 | } 95 | 96 | 97 | @OptIn(ExperimentalMaterial3Api::class) 98 | @Composable 99 | fun PCRemote( 100 | navController: NavController, 101 | sPenViewModel: SPenViewModel, 102 | webSocketViewModel: WebSocketViewModel 103 | ) { 104 | val context = LocalContext.current 105 | 106 | Scaffold( 107 | topBar = { 108 | TopAppBar( 109 | colors = TopAppBarDefaults.topAppBarColors(), 110 | title = { 111 | Text(context.getString(R.string.app_name)) 112 | }, 113 | ) 114 | }, 115 | modifier = Modifier.fillMaxSize(), 116 | content = { padding -> 117 | Column( 118 | modifier = Modifier.padding(padding).fillMaxWidth().fillMaxHeight(), 119 | verticalArrangement = Arrangement.SpaceBetween, 120 | horizontalAlignment = Alignment.CenterHorizontally 121 | ) { 122 | Column( 123 | horizontalAlignment = Alignment.CenterHorizontally, 124 | modifier = Modifier.padding(start = 16.dp, end = 16.dp) 125 | ) { 126 | 127 | Row( 128 | verticalAlignment = Alignment.CenterVertically, 129 | modifier = Modifier.padding(top = 5.dp, bottom = 20.dp) 130 | ) { 131 | if (!sPenViewModel.isSpenSupported || sPenViewModel.errorState.isNotEmpty()) { 132 | Icon(Icons.Default.Clear, "Failed", modifier = Modifier.padding(end = 10.dp)) 133 | Text(context.getString(R.string.spen_unsupported)) 134 | } else { 135 | Icon(Icons.Default.CheckCircle, "Success", modifier = Modifier.padding(end = 10.dp)) 136 | Text(context.getString(R.string.spen_supported)) 137 | } 138 | } 139 | 140 | 141 | 142 | if(!webSocketViewModel.connected) { 143 | Text(context.getString(R.string.same_network)) 144 | Button(onClick = { 145 | navController.navigate("scan") 146 | }, modifier = Modifier.padding(top = 10.dp)) { 147 | Text(context.getString(R.string.scan_device)) 148 | } 149 | } else { 150 | Row( 151 | verticalAlignment = Alignment.CenterVertically, 152 | modifier = Modifier.padding(top = 5.dp, bottom = 20.dp) 153 | ) { 154 | Icon(Icons.Default.Check, "Connected", modifier = Modifier.padding(end = 10.dp)) 155 | Text(context.getString(R.string.connected)) 156 | } 157 | 158 | Button(onClick = { 159 | webSocketViewModel.closeConnection() 160 | }) { 161 | Text(context.getString(R.string.disconnect)) 162 | } 163 | } 164 | } 165 | 166 | Text("Copyright 2024 - David Mayr") 167 | 168 | } 169 | } 170 | ) 171 | 172 | } 173 | 174 | @Preview(showSystemUi = true) 175 | @Composable 176 | fun DefaultPreview() { 177 | val nav = rememberNavController() 178 | PCRemoteTheme { 179 | PCRemote(nav, SPenViewModel(WebSocketViewModel()), WebSocketViewModel()) 180 | } 181 | } -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/PermissionRequestDialog.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import androidx.activity.compose.rememberLauncherForActivityResult 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.core.content.ContextCompat 10 | import kotlinx.coroutines.delay 11 | 12 | @Composable 13 | fun PermissionRequestDialog( 14 | permission: String, 15 | onResult: (Boolean) -> Unit, 16 | ) { 17 | val context = LocalContext.current 18 | 19 | if(isPermissionGranted(context, permission)) { 20 | onResult(true) 21 | return 22 | } 23 | 24 | var launchEffectKey by remember { 25 | mutableStateOf(false) 26 | } 27 | 28 | var requestPermissionDelay by remember { 29 | mutableStateOf(0L) 30 | } 31 | 32 | val managedActivityResultLauncher = rememberLauncherForActivityResult( 33 | contract = ActivityResultContracts.RequestPermission(), 34 | onResult = { isGranted -> 35 | 36 | onResult(isGranted) 37 | 38 | if(!isGranted) { 39 | requestPermissionDelay = 1000 40 | launchEffectKey = !launchEffectKey 41 | } 42 | 43 | } 44 | ) 45 | 46 | LaunchedEffect(launchEffectKey) { 47 | delay(requestPermissionDelay) 48 | managedActivityResultLauncher.launch(permission) 49 | } 50 | } 51 | 52 | fun isPermissionGranted(context: Context, permission: String) : Boolean { 53 | return ContextCompat.checkSelfPermission( 54 | context, 55 | permission 56 | ) == PackageManager.PERMISSION_GRANTED 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/QRCodeScannerScreen.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote 2 | 3 | import android.Manifest 4 | import androidx.camera.core.CameraSelector 5 | import androidx.camera.core.ImageAnalysis 6 | import androidx.camera.core.Preview 7 | import androidx.camera.lifecycle.ProcessCameraProvider 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.material3.ExperimentalMaterial3Api 13 | import androidx.compose.material3.Scaffold 14 | import androidx.camera.view.PreviewView 15 | import androidx.compose.foundation.layout.Arrangement 16 | import androidx.compose.foundation.layout.fillMaxHeight 17 | import androidx.compose.foundation.layout.width 18 | import androidx.compose.material3.CircularProgressIndicator 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Text 21 | import androidx.compose.material3.TopAppBar 22 | import androidx.compose.material3.TopAppBarDefaults 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.mutableStateOf 25 | import androidx.compose.runtime.* 26 | import androidx.compose.ui.Alignment 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.platform.LocalContext 29 | import androidx.compose.ui.unit.dp 30 | import androidx.compose.ui.viewinterop.AndroidView 31 | import androidx.core.content.ContextCompat 32 | import androidx.lifecycle.compose.LocalLifecycleOwner 33 | import androidx.navigation.NavController 34 | 35 | 36 | @OptIn(ExperimentalMaterial3Api::class) 37 | @Composable 38 | fun QRCodeScannerScreen(navController: NavController, webSocketViewModel: WebSocketViewModel) { 39 | val context = LocalContext.current 40 | val lifeCycleOwner = LocalLifecycleOwner.current 41 | 42 | var permissionError by remember { mutableStateOf(false) } 43 | var loading by remember { mutableStateOf(false) } 44 | 45 | PermissionRequestDialog( 46 | permission = Manifest.permission.CAMERA, 47 | onResult = { isGranted -> 48 | permissionError = !isGranted 49 | }, 50 | ) 51 | 52 | Scaffold( 53 | topBar = { 54 | TopAppBar( 55 | colors = TopAppBarDefaults.topAppBarColors(), 56 | title = { 57 | Text(context.getString(R.string.qr_code_title)) 58 | }, 59 | ) 60 | }, 61 | modifier = Modifier.fillMaxSize(), 62 | content = { padding -> 63 | Column( 64 | modifier = Modifier.padding(padding), 65 | verticalArrangement = Arrangement.spacedBy(16.dp), 66 | ) { 67 | 68 | if (!loading) { 69 | if (permissionError) { 70 | Text(context.getString(R.string.perm_missing)) 71 | } else { 72 | Text(context.getString(R.string.qr_code_desc)) 73 | 74 | AndroidView( 75 | factory = { context -> 76 | val previewView = PreviewView(context) 77 | val preview = Preview.Builder().build() 78 | val cameraSelector = CameraSelector.Builder().build() 79 | 80 | preview.surfaceProvider = previewView.surfaceProvider 81 | 82 | val imageAnalysis = ImageAnalysis.Builder().build() 83 | 84 | imageAnalysis.setAnalyzer( 85 | ContextCompat.getMainExecutor(context), 86 | BarcodeAnalyzer(context, response = { 87 | if(loading) return@BarcodeAnalyzer 88 | 89 | loading = true 90 | webSocketViewModel.connect(it.get("ip").asString, it.get("port").asInt, it.get("pw").asString) 91 | navController.navigate("home") 92 | loading = false 93 | }) 94 | ) 95 | 96 | ProcessCameraProvider.getInstance(context).get().bindToLifecycle( 97 | lifeCycleOwner, 98 | cameraSelector, 99 | preview, 100 | imageAnalysis 101 | ) 102 | 103 | previewView 104 | } 105 | ) 106 | } 107 | } else { 108 | Column( 109 | horizontalAlignment = Alignment.CenterHorizontally, 110 | verticalArrangement = Arrangement.Center, 111 | modifier = Modifier.fillMaxWidth().fillMaxHeight() 112 | ) { 113 | CircularProgressIndicator( 114 | modifier = Modifier.width(64.dp), 115 | color = MaterialTheme.colorScheme.secondary, 116 | trackColor = MaterialTheme.colorScheme.surfaceVariant, 117 | ) 118 | Text(context.getString(R.string.loading), modifier = Modifier.padding(top = 30.dp)) 119 | } 120 | 121 | } 122 | } 123 | } 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/SPenViewModel.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableLongStateOf 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.ViewModelProvider 10 | import androidx.lifecycle.viewModelScope 11 | import com.samsung.android.sdk.penremote.* 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.launch 14 | 15 | const val doubleClickTime: Long = 250 16 | 17 | 18 | class SPenViewModel( 19 | val webSocketViewModel: WebSocketViewModel 20 | ): ViewModel() { 21 | 22 | private var manager: SpenUnitManager? = null 23 | 24 | var isConnecting by mutableStateOf(false) 25 | var connected by mutableStateOf(false) 26 | var errorState by mutableStateOf("") 27 | 28 | private var buttonPressedSince: Long by mutableLongStateOf(-1) 29 | private var buttonUpLastTime: Long by mutableLongStateOf(-1) 30 | 31 | var buttonPressed: Boolean by mutableStateOf(false) 32 | 33 | val isSpenSupported = SpenRemote.getInstance().isFeatureEnabled(SpenRemote.FEATURE_TYPE_BUTTON) 34 | || SpenRemote.getInstance().isFeatureEnabled(SpenRemote.FEATURE_TYPE_AIR_MOTION) 35 | 36 | 37 | private val isLaserPointerActive: Boolean 38 | get() { 39 | return buttonPressed && System.currentTimeMillis() - buttonPressedSince > doubleClickTime*2 40 | } 41 | 42 | 43 | 44 | fun connect(context: Context) { 45 | isConnecting = true 46 | try { 47 | 48 | SpenRemote.getInstance().connect(context, object : SpenRemote.ConnectionResultCallback { 49 | 50 | override fun onSuccess(spenUnitManager: SpenUnitManager?) { 51 | isConnecting = false 52 | manager = spenUnitManager 53 | connected = true 54 | errorState = "" 55 | listenEvents() 56 | } 57 | 58 | override fun onFailure(error: Int) { 59 | connected = false 60 | isConnecting = false 61 | errorState = when (error) { 62 | SpenRemote.Error.UNSUPPORTED_DEVICE -> "Unsupported" 63 | SpenRemote.Error.CONNECTION_FAILED -> "Failed" 64 | else -> "Error" 65 | } 66 | } 67 | 68 | }) 69 | 70 | } catch (e: NoClassDefFoundError) { 71 | isConnecting = false 72 | errorState = "Unsupported" 73 | } 74 | } 75 | 76 | fun disconnect(context: Context) { 77 | 78 | manager?.getUnit(SpenUnit.TYPE_BUTTON)?.let { button -> 79 | manager?.unregisterSpenEventListener(button) 80 | } 81 | manager?.getUnit(SpenUnit.TYPE_AIR_MOTION)?.let { airMotion -> 82 | manager?.unregisterSpenEventListener(airMotion) 83 | } 84 | 85 | SpenRemote.getInstance().disconnect(context) 86 | 87 | connected = false 88 | errorState = "" 89 | } 90 | 91 | private fun listenEvents() { 92 | 93 | val sPen = SpenRemote.getInstance() 94 | 95 | // Button 96 | if (sPen.isFeatureEnabled(SpenRemote.FEATURE_TYPE_BUTTON)) { 97 | manager?.registerSpenEventListener({ event -> 98 | 99 | when (ButtonEvent(event).action) { 100 | ButtonEvent.ACTION_DOWN ->{ 101 | buttonPressed = true 102 | buttonPressedSince = System.currentTimeMillis() 103 | } 104 | ButtonEvent.ACTION_UP -> { 105 | val time = System.currentTimeMillis() 106 | 107 | if(!isLaserPointerActive) { 108 | if(System.currentTimeMillis()-buttonUpLastTime > doubleClickTime) { 109 | 110 | viewModelScope.launch { 111 | delay(doubleClickTime) 112 | if (buttonUpLastTime != time) { 113 | webSocketViewModel.sendButtonClick(true) 114 | } else { 115 | webSocketViewModel.sendButtonClick(false) 116 | } 117 | } 118 | } 119 | } else { 120 | webSocketViewModel.sendReleased() 121 | } 122 | 123 | buttonUpLastTime = time 124 | buttonPressedSince = -1 125 | buttonPressed = false 126 | } 127 | } 128 | 129 | }, manager?.getUnit(SpenUnit.TYPE_BUTTON)) 130 | } 131 | 132 | // Air motion 133 | if (sPen.isFeatureEnabled(SpenRemote.FEATURE_TYPE_AIR_MOTION)) { 134 | manager?.registerSpenEventListener({ event -> 135 | 136 | if (isLaserPointerActive) { 137 | 138 | val airMotionEvent = AirMotionEvent(event) 139 | val deltaX = airMotionEvent.deltaX 140 | val deltaY = airMotionEvent.deltaY 141 | 142 | if((deltaX < 0.0000000000001 && deltaX > -0.0000000000001) || (deltaY < 0.0000000000001 && deltaY 143 | > -0.0000000000001)) 144 | return@registerSpenEventListener 145 | 146 | webSocketViewModel.sendMotion(deltaX, deltaY) 147 | } 148 | 149 | }, manager?.getUnit(SpenUnit.TYPE_AIR_MOTION)) 150 | } 151 | } 152 | 153 | class SPenViewModelFactory( 154 | private val webSocketViewModel: WebSocketViewModel 155 | ) : ViewModelProvider.Factory { 156 | 157 | @Suppress("UNCHECKED_CAST") 158 | override fun create(modelClass: Class): T { 159 | if (modelClass.isAssignableFrom(SPenViewModel::class.java)) { 160 | return SPenViewModel(webSocketViewModel) as T 161 | } 162 | throw IllegalArgumentException("Unknown ViewModel class") 163 | } 164 | } 165 | 166 | 167 | } -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/WebSocketViewModel.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import com.google.gson.JsonObject 8 | import okhttp3.* 9 | 10 | class WebSocketViewModel : ViewModel() { 11 | 12 | var connected by mutableStateOf(false) 13 | 14 | private var webSocket: WebSocket? = null 15 | private val client = OkHttpClient() 16 | 17 | private val listener = object : WebSocketListener() { 18 | override fun onOpen(webSocket: WebSocket, response: Response) { 19 | super.onOpen(webSocket, response) 20 | println("WebSocket Connected") 21 | 22 | if(webSocket == this@WebSocketViewModel.webSocket) { 23 | connected = true 24 | } 25 | } 26 | 27 | override fun onMessage(webSocket: WebSocket, text: String) { 28 | super.onMessage(webSocket, text) 29 | println("Message received: $text") 30 | } 31 | 32 | override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { 33 | super.onFailure(webSocket, t, response) 34 | println("Error: ${t.message}") 35 | 36 | if(webSocket == this@WebSocketViewModel.webSocket) { 37 | this@WebSocketViewModel.webSocket = null 38 | connected = false 39 | } 40 | } 41 | 42 | override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { 43 | super.onClosing(webSocket, code, reason) 44 | println("Closing WebSocket: $reason") 45 | } 46 | 47 | override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { 48 | super.onClosed(webSocket, code, reason) 49 | println("WebSocket Closed: $reason") 50 | if(webSocket == this@WebSocketViewModel.webSocket) { 51 | this@WebSocketViewModel.webSocket = null 52 | connected = false 53 | } 54 | 55 | } 56 | } 57 | 58 | fun connect(ip: String, port: Int, pw: String) { 59 | closeConnection() 60 | 61 | val request = Request.Builder().url("ws://$ip:$port").build() 62 | webSocket = client.newWebSocket(request, listener) 63 | webSocket?.send(pw) 64 | println("connected to ws://$ip:$port") 65 | } 66 | 67 | fun sendButtonClick(double: Boolean) { 68 | webSocket?.send(JsonObject().also { 69 | it.addProperty("type", "b") 70 | it.addProperty("d", double) 71 | }.toString()) 72 | } 73 | 74 | fun sendMotion(deltaX: Float, deltaY: Float) { 75 | webSocket?.send(JsonObject().also { 76 | it.addProperty("type", "m") 77 | it.addProperty("x", deltaX) 78 | it.addProperty("y", deltaY) 79 | }.toString()) 80 | } 81 | 82 | fun closeConnection() { 83 | webSocket?.close(1000, "Closing connection") 84 | connected = false 85 | } 86 | 87 | override fun onCleared() { 88 | super.onCleared() 89 | closeConnection() 90 | } 91 | 92 | fun sendReleased() { 93 | webSocket?.send(JsonObject().also { 94 | it.addProperty("type", "mr") 95 | }.toString()) 96 | } 97 | } -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | private val DarkColorScheme = darkColorScheme( 15 | primary = Purple80, 16 | secondary = PurpleGrey80, 17 | tertiary = Pink80 18 | ) 19 | 20 | private val LightColorScheme = lightColorScheme( 21 | primary = Purple40, 22 | secondary = PurpleGrey40, 23 | tertiary = Pink40 24 | 25 | /* Other default colors to override 26 | background = Color(0xFFFFFBFE), 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White, 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F), 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun PCRemoteTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, 41 | content: @Composable () -> Unit 42 | ) { 43 | val colorScheme = when { 44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 45 | val context = LocalContext.current 46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 47 | } 48 | 49 | darkTheme -> DarkColorScheme 50 | else -> LightColorScheme 51 | } 52 | 53 | MaterialTheme( 54 | colorScheme = colorScheme, 55 | typography = Typography, 56 | content = content 57 | ) 58 | } -------------------------------------------------------------------------------- /app/src/main/java/eu/davidmayr/pcremote/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package eu.davidmayr.pcremote.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidmayr/SPen-PCRemote/d6db99a38af7cca524fa24722a13475cfa359f8b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values-de/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | S Pen PCRemote 4 | Samsung S Pen wird auf diesem Gerät unterstützt. 5 | Samsung S Pen wird auf diesem Gerät nicht unterstützt oder ist fehlgeschlagen. 6 | Geräte QR Code scannen 7 | Trennen 8 | Du bist verbunden! 9 | Bitte verbinde dich mit dem selben Netzwerk wie der Computer und starte den Verbindungsprozess. 10 | QR Code scannen 11 | Scanne den QR Code auf der PCRemote desktop app und stelle sicher, dass du im selben Netzerk bist. 12 | Versuche zu verbinden... 13 | Konnte keine Kamera Berechtigung erhalten. 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #034B9D 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | S Pen PCRemote 3 | Samsung S Pen is supported on this device. 4 | Samsung S Pen is not supported on this device or failed. 5 | Scan Device QR Code 6 | Disconnect 7 | You are connected! 8 | 9 | Please connect to the same network as the computer and start the paring process. 10 | Scan QR Code 11 | Scan the QR Code of the PCRemote desktop application and make sure to you are in the same network! 12 | Trying to connect… 13 | Could not get camera permission! 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |