commit 189fd63962bee9f864e0ab615c562b297b06a4b3 Author: yenon Date: Tue Jan 9 18:20:19 2024 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52cc979 --- /dev/null +++ b/.gitignore @@ -0,0 +1,223 @@ +# Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,kotlin,java,gradle +# Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,kotlin,java,gradle + +### Android ### +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### Java ### +# Compiled class file +*.class + +# Log file + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files + +# Generated files +bin/ +gen/ +out/ + +# Gradle files + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*~ +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,kotlin,java,gradle diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1bff2cb --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(libs.plugins.com.android.application) + alias(libs.plugins.org.jetbrains.kotlin.android) +} + +android { + namespace = "yenon.setcil" + compileSdk = 34 + + defaultConfig { + applicationId = "yenon.setcil" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.3.2" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.accompanist.permissions) + implementation(libs.core.ktx) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.activity.compose) + implementation(platform(libs.compose.bom)) + implementation(libs.ui) + implementation(libs.ui.graphics) + implementation(libs.ui.tooling.preview) + implementation(libs.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.ui.test.junit4) + debugImplementation(libs.ui.tooling) + debugImplementation(libs.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/yenon/setcil/ExampleInstrumentedTest.kt b/app/src/androidTest/java/yenon/setcil/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..3e690f1 --- /dev/null +++ b/app/src/androidTest/java/yenon/setcil/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package yenon.setcil + +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("yenon.setcil", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1df0b0c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/yenon/setcil/MainActivity.kt b/app/src/main/java/yenon/setcil/MainActivity.kt new file mode 100644 index 0000000..9875701 --- /dev/null +++ b/app/src/main/java/yenon/setcil/MainActivity.kt @@ -0,0 +1,454 @@ +package yenon.setcil + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanResult +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat.startActivityForResult +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import yenon.setcil.ui.theme.SetCilTheme +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + + +class MainActivity : ComponentActivity() { + + var bluetoothState by mutableIntStateOf(BluetoothAdapter.STATE_OFF) + + private val bluetoothReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (action == BluetoothAdapter.ACTION_STATE_CHANGED) { + val state = intent.getIntExtra( + BluetoothAdapter.EXTRA_STATE, + BluetoothAdapter.ERROR + ) + bluetoothState = state + } + } + } + + @OptIn(ExperimentalPermissionsApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + + SetCilTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + + + val currentDevice = remember { mutableStateOf(null) } + + val reqPermissionState = + rememberMultiplePermissionsState( + getRequiredPermissionsForSdk() + ) + + if (!reqPermissionState.allPermissionsGranted || bluetoothState != BluetoothAdapter.STATE_ON) { + SetupPage(reqPermissionState,bluetoothState) + } else { + if (currentDevice.value == null) { + Select(currentDevice) + } else { + Configure(currentDevice) + } + } + } + } + } + } + + override fun onResume() { + bluetoothState = (baseContext.getSystemService(BLUETOOTH_SERVICE) as BluetoothManager).adapter.state + registerReceiver(bluetoothReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) + super.onResume() + } + + override fun onPause() { + unregisterReceiver(bluetoothReceiver) + super.onPause() + } +} + +fun getRequiredPermissionsForSdk(): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADMIN, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } else { + listOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun SetupPage(reqPermissionState: MultiplePermissionsState, bluetoothState: Int, modifier: Modifier = Modifier) { + Column(modifier) { + val textToShow = if (reqPermissionState.shouldShowRationale) { + "Fine location access is required for bluetooth to work, Google thinks developers could connect to a bluetooth GPS unit. This is no joke. I don't make the rules." + } else { + "Please grant fine location access and bluetooth permissions to use this app." + } + Text(textToShow) + Button(onClick = { reqPermissionState.launchMultiplePermissionRequest() }, enabled = !reqPermissionState.allPermissionsGranted) { + Text("Request permission") + } + val activity = LocalContext.current as Activity + Button(onClick = { + val enableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + startActivityForResult(activity,enableIntent, 1,null) + }) { + Text("Enable Bluetooth") + } + if (bluetoothState == BluetoothAdapter.STATE_TURNING_ON){ + Text(text = "Bluetooth is turning on...") + } + } +} + +class PinecilData(){ + val temperature = mutableStateOf(minTemp.toUInt()) + val setPoint = mutableStateOf(0u) + val wattage = mutableFloatStateOf(0f) + val voltage = mutableFloatStateOf(0f) +} + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("MissingPermission") +@Composable +fun Configure(device: MutableState, modifier: Modifier = Modifier) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val setPointApp = remember { mutableStateOf(0u) } + val setPointTransmit = remember { mutableStateOf(null) } + + val pinecilData by remember { mutableStateOf(PinecilData()) } + + var gatt by remember { mutableStateOf(null) } + + LaunchedEffect(device) { + scope.launch(Dispatchers.IO) { + gatt = device.value!!.device.connectGatt( + context, + true, + object : BluetoothGattCallback() { + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + super.onCharacteristicRead(gatt, characteristic, value, status) + + val buffer = ByteBuffer.wrap(value) + buffer.order(ByteOrder.LITTLE_ENDIAN) + + val pairs = 14 + val data = Array(pairs) { 0u } + + for (i in 0 until pairs) { + data[i] = buffer.getInt().toUInt() + } + + pinecilData.temperature.value = data[0] + pinecilData.setPoint.value = data[1] + + pinecilData.voltage.floatValue = data[2].toFloat() / 10f + pinecilData.wattage.floatValue = data[13].toFloat() / 10f + + if (setPointApp.value == 0u) { + setPointApp.value = pinecilData.setPoint.value + } + } + + override fun onConnectionStateChange( + gatt: BluetoothGatt?, + status: Int, + newState: Int + ) { + super.onConnectionStateChange(gatt, status, newState) + if (newState == BluetoothGatt.STATE_CONNECTED) { + gatt!!.discoverServices() + } else if (newState == BluetoothGatt.STATE_DISCONNECTING || newState == BluetoothGatt.STATE_DISCONNECTED) { + gatt!!.close() + device.value = null + } + } + + override fun onServicesDiscovered( + gatt: BluetoothGatt?, + status: Int + ) { + super.onServicesDiscovered(gatt, status) + + gatt!!.services.forEach { service -> + println(service.uuid) + service.characteristics.forEach { + println("-${it.uuid}") + } + } + val bulkCharacteristic = + gatt.getService(UUID.fromString("9eae1000-9d0d-48c5-aa55-33e27f9bc533")) + .getCharacteristic(UUID.fromString("9eae1001-9d0d-48c5-aa55-33e27f9bc533")) + + scope.launch { + while (this.isActive) { + gatt.readCharacteristic(bulkCharacteristic) + delay(200) + + setPointTransmit.value?.let { setpNew -> + val buffer = ByteBuffer.allocate(2) + buffer.order(ByteOrder.LITTLE_ENDIAN) + buffer.putShort(setpNew.toShort()) + buffer.flip() + + val setPointCharacteristic = + gatt.getService(UUID.fromString("f6d80000-5a10-4eba-AA55-33e27f9bc533")) + .getCharacteristic(UUID.fromString("f6d70000-5a10-4eba-AA55-33e27f9bc533")) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + + val result = gatt.writeCharacteristic( + setPointCharacteristic, + buffer.array(), + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + ) + if (result == BluetoothGatt.GATT_SUCCESS) { + setPointTransmit.value = null + } + + } else { + // This is not deprecated in those API's, and is actually the only option... + @Suppress("DEPRECATION") + setPointCharacteristic.setValue(buffer.array()) + @Suppress("DEPRECATION") + if (gatt.writeCharacteristic(setPointCharacteristic)) { + setPointTransmit.value = null + } + } + } + } + } + } + }) + } + } + + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(id = R.string.app_name)) }) + }, + floatingActionButton = { + if (setPointApp.value != pinecilData.setPoint.value) { + ExtendedFloatingActionButton( + onClick = { + setPointTransmit.value = setPointApp.value + }, + icon = { + Icon(Icons.Filled.Check, "Apply settings") + }, + text = { + Text("Apply settings") + } + ) + } + }, + content = { padding -> + Column(modifier.padding(padding)) { + TempCircle(pinecilData, setPointApp) + } + }) +} + +val spacing = 20.dp +val gaugeThickness = 10.dp +val sliderRadius = 25.dp + +const val minTemp = 10 +const val maxTemp = 450 +const val range = maxTemp - minTemp +const val usedArc = 0.75f +const val offset = (PI * 5 / 4).toFloat() + +fun DrawScope.drawTemp( + gaugeSize: Size, + gaugeThicknessPx: Float, + sliderRadiusPx: Float, + temp: MutableState, + color: Color +) { + val setPF = temp.value.toFloat() - minTemp + val piF = PI.toFloat() + + val sliderUnitOffset = Offset( + sin(setPF / range * piF * usedArc * 2 + offset), + -cos(setPF / range * piF * usedArc * 2 + offset) + ) + val sliderOffset = + sliderUnitOffset * ((gaugeSize.minDimension - gaugeThicknessPx) / 2) + center + + drawCircle( + color, sliderRadiusPx, sliderOffset, 0.8f, + Stroke(2.dp.toPx(), 0f) + ) +} + +@Composable +fun TempCircle( + pinecilData: PinecilData, setpApp: MutableState +) { + val centerOffset = remember { mutableStateOf(Offset.Unspecified) } + + Box(modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .pointerInput(Unit) { + detectDragGestures { change, _ -> + val angle = ((atan2( + change.position.y - centerOffset.value.y, + change.position.x - centerOffset.value.x + ) + offset) * (range / (usedArc * 2 * PI))) + + val residual = (1 - usedArc) / 2 * range + setpApp.value = (minTemp + ((angle + residual).mod(range / usedArc) + - residual)) + .toUInt() + .coerceIn(minTemp.toUInt(), maxTemp.toUInt()) + } + } + .drawBehind { + centerOffset.value = center + + val spacingPx = spacing.toPx() + val gaugeThicknessPx = gaugeThickness.toPx() + val sliderRadiusPx = sliderRadius.toPx() + + val gaugeSize = Size( + size.minDimension - ((spacingPx + sliderRadiusPx) * 2), + size.minDimension - ((spacingPx + sliderRadiusPx) * 2) + ) + drawArc( + Color.Yellow, + 135f, + (((pinecilData.temperature.value.toFloat() - minTemp) / range) * 360 * usedArc), + true, + topLeft = Offset((spacingPx + sliderRadiusPx), (spacingPx + sliderRadiusPx)), + size = gaugeSize + ) + drawCircle( + Color.Black, + (size.minDimension) / 2 - (sliderRadiusPx + spacingPx + gaugeThicknessPx), + center + ) + + drawTemp( + gaugeSize, + gaugeThicknessPx, + sliderRadiusPx, + pinecilData.setPoint, + Color.LightGray + ) + drawTemp(gaugeSize, gaugeThicknessPx, sliderRadiusPx, setpApp, Color.Green) + }) { + Column(modifier = Modifier.align(Alignment.Center)) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = "${pinecilData.temperature.value}°C", + color = Color.Cyan, + fontSize = TextUnit(48f, TextUnitType.Sp) + ) + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text( + text = "${pinecilData.voltage.floatValue}V ", + color = Color.Red, + fontSize = TextUnit(24f, TextUnitType.Sp) + ) + Text( + text = "${pinecilData.wattage.floatValue}W", + color = Color.Yellow, + fontSize = TextUnit(24f, TextUnitType.Sp) + ) + } + } + } +} diff --git a/app/src/main/java/yenon/setcil/SelectPage.kt b/app/src/main/java/yenon/setcil/SelectPage.kt new file mode 100644 index 0000000..28997a3 --- /dev/null +++ b/app/src/main/java/yenon/setcil/SelectPage.kt @@ -0,0 +1,129 @@ +package yenon.setcil + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import android.os.ParcelUuid +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.util.UUID +import java.util.concurrent.CancellationException + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Select(device: MutableState, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + val foundDevices = remember { mutableStateListOf() } + + val context = LocalContext.current + var scanJob: Job? = null + + LocalLifecycleOwner.current.lifecycle.addObserver( + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + scanJob = startDeviceScan(scope, context, foundDevices) + } + + Lifecycle.Event.ON_PAUSE -> { + scanJob?.cancel(CancellationException()) + } + + else -> {} + } + } + ) + + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(id = R.string.app_name)) }) + }, + content = { padding -> + Box(modifier.padding(padding)) { + foundDevices.forEach { + Button(onClick = { + println("Clicked") + device.value = it + }) { + Text(text = it.scanRecord?.deviceName ?: it.device.address) + } + } + if(foundDevices.isEmpty()){ + Text(text = "No pinecils found.") + } + } + }) +} + +fun startDeviceScan( + scope: CoroutineScope, + context: Context, + resultList: SnapshotStateList +): Job { + return scope.launch(Dispatchers.IO) { + val bluetoothAdapter: BluetoothAdapter? = + ContextCompat.getSystemService(context, BluetoothManager::class.java)?.adapter + if (bluetoothAdapter == null) { + println("No adapter") + return@launch + } + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_SCAN + ) != PackageManager.PERMISSION_GRANTED + ) { + println("No permission") + return@launch + } + println("Scanning") + val pinecilFilter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(UUID.fromString("9eae1000-9d0d-48c5-aa55-33e27f9bc533"))) + .build() + val scanSettings = ScanSettings.Builder().build() + bluetoothAdapter.bluetoothLeScanner.startScan( + listOf(pinecilFilter), + scanSettings, + object : ScanCallback() { + @SuppressLint("MissingPermission") + override fun onScanResult(callbackType: Int, result: ScanResult?) { + if (result == null) { + return + } + + if (resultList.none { it.device.address == result.device.address }) { + resultList.add(result) + } + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/yenon/setcil/ui/theme/Theme.kt b/app/src/main/java/yenon/setcil/ui/theme/Theme.kt new file mode 100644 index 0000000..6edf259 --- /dev/null +++ b/app/src/main/java/yenon/setcil/ui/theme/Theme.kt @@ -0,0 +1,71 @@ +package yenon.setcil.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFFD0BCFF), + secondary = Color(0xFFCCC2DC), + tertiary = Color(0xFFEFB8C8) +) + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF6650a4), + secondary = Color(0xFF625b71), + tertiary = Color(0xFF7D5260) + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun SetCilTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/yenon/setcil/ui/theme/Type.kt b/app/src/main/java/yenon/setcil/ui/theme/Type.kt new file mode 100644 index 0000000..2afe9fc --- /dev/null +++ b/app/src/main/java/yenon/setcil/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package yenon.setcil.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/setcil.xml b/app/src/main/res/drawable/setcil.xml new file mode 100644 index 0000000..134d4a6 --- /dev/null +++ b/app/src/main/res/drawable/setcil.xml @@ -0,0 +1,41 @@ + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..caa5b82 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..caa5b82 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ed010fb --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SetCil + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..33ccaa6 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +