diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index dcda84b..9818a9c 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,18 +4,29 @@ - - + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index d4b7acc..c22b6fa 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.idea/workspace (SFConflict ispatel 2025-03-10-10-03-26).xml b/.idea/workspace (SFConflict ispatel 2025-03-10-10-03-26).xml new file mode 100644 index 0000000..34de4d8 --- /dev/null +++ b/.idea/workspace (SFConflict ispatel 2025-03-10-10-03-26).xml @@ -0,0 +1,254 @@ + + + + + + + + + + @android:style/Theme.DeviceDefault + + + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 0 +} + + + + { + "keyToString": { + "Android App.mobile.executor": "Run", + "Android App.wear.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.cidr.known.project.marker": "true", + "RunOnceActivity.readMode.enableVisualFormatting": "true", + "cf.first.check.clang-format": "false", + "cidr.known.project.marker": "true", + "com.google.services.firebase.aqiPopupShown": "true", + "git-widget-placeholder": "master", + "ignore.virus.scanning.warn.message": "true", + "kotlin-language-version-configured": "true", + "last_opened_file_path": "C:/Users/isp/Seafile/Designs/Android/bird_sound_identify_wearos", + "project.structure.last.edited": "Modules", + "project.structure.proportion": "0.17", + "project.structure.side.proportion": "0.2" + } +} + + + + + + + + + + + + + + + + + + + + + 1741017174616 + + + + + + + \ No newline at end of file diff --git a/.idea/workspace (SFConflict ispatel@live.com 2025-03-08-01-11-41).xml b/.idea/workspace (SFConflict ispatel@live.com 2025-03-08-01-11-41).xml new file mode 100644 index 0000000..34de4d8 --- /dev/null +++ b/.idea/workspace (SFConflict ispatel@live.com 2025-03-08-01-11-41).xml @@ -0,0 +1,254 @@ + + + + + + + + + + @android:style/Theme.DeviceDefault + + + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 0 +} + + + + { + "keyToString": { + "Android App.mobile.executor": "Run", + "Android App.wear.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.cidr.known.project.marker": "true", + "RunOnceActivity.readMode.enableVisualFormatting": "true", + "cf.first.check.clang-format": "false", + "cidr.known.project.marker": "true", + "com.google.services.firebase.aqiPopupShown": "true", + "git-widget-placeholder": "master", + "ignore.virus.scanning.warn.message": "true", + "kotlin-language-version-configured": "true", + "last_opened_file_path": "C:/Users/isp/Seafile/Designs/Android/bird_sound_identify_wearos", + "project.structure.last.edited": "Modules", + "project.structure.proportion": "0.17", + "project.structure.side.proportion": "0.2" + } +} + + + + + + + + + + + + + + + + + + + + + 1741017174616 + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b25f53..0162e2d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,23 @@ [versions] +accompanistFlowlayout = "0.28.0" agp = "8.8.1" +composeNavigationVersion = "1.3.1" +horologistComposeTools = "0.6.18" +horologistAudio = "0.6.18" +horologistMediaData = "0.6.8" +horologistMediaUi = "0.6.8" kotlin = "2.0.0" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" appcompat = "1.6.1" +kotlinxCoroutinesPlayServices = "1.8.1" +lifecycleViewmodelCompose = "2.8.4" +materialIconsCore = "1.6.8" +materialIconsExtended = "1.7.8" +media3Exoplayer = "1.4.0" +navigationCompose = "2.8.0-rc01" playServicesWearable = "18.2.0" material = "1.10.0" activity = "1.9.1" @@ -13,6 +25,9 @@ constraintlayout = "2.1.4" composeBom = "2024.04.01" composeMaterial = "1.2.1" composeFoundation = "1.2.1" +statelyConcurrentCollections = "2.0.0" +uiTooling = "1.3.1" +wearOngoing = "1.0.0" wearToolingPreview = "1.0.0" activityCompose = "1.9.1" coreSplashscreen = "1.0.1" @@ -22,14 +37,32 @@ composeMaterial3 = "1.0.0-alpha23" workRuntimeKtx = "2.9.1" lifecycleRuntimeKtx = "2.6.1" runtimeAndroid = "1.6.6" +datastoreCoreAndroid = "1.1.3" +coreKtxVersion = "1.13.0" +animationCoreAndroid = "1.6.6" #litert = "1.0.1" [libraries] +accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanistFlowlayout" } +androidx-compose-navigation-v131 = { module = "androidx.wear.compose:compose-navigation", version.ref = "composeNavigationVersion" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-wear-ongoing = { module = "androidx.wear:wear-ongoing", version.ref = "wearOngoing" } +horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologistComposeTools" } +horologist-audio = { module = "com.google.android.horologist:horologist-audio", version.ref = "horologistAudio" } +horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologistMediaData" } +horologist-compose-tools = { module = "com.google.android.horologist:horologist-compose-tools", version.ref = "horologistComposeTools" } +horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologistMediaUi" } +horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologistMediaData" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" } play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } @@ -53,10 +86,15 @@ androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx" androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-runtime-android = { group = "androidx.compose.runtime", name = "runtime-android", version.ref = "runtimeAndroid" } +androidx-datastore-core-android = { group = "androidx.datastore", name = "datastore-core-android", version.ref = "datastoreCoreAndroid" } +core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtxVersion" } +stately-concurrent-collections = { module = "co.touchlab:stately-concurrent-collections", version.ref = "statelyConcurrentCollections" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" } +androidx-animation-core-android = { group = "androidx.compose.animation", name = "animation-core-android", version.ref = "animationCoreAndroid" } #litert = { group = "com.google.ai.edge.litert", name = "litert", version.ref = "litert" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.0.20" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.1.10" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index bf5e01d..d8049b4 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -55,12 +55,16 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) + + implementation("androidx.datastore:datastore-preferences:1.1.3") implementation("org.tensorflow:tensorflow-lite:2.6.0") // implementation("com.google.android.gms:play-services-tflite:20.0.0") implementation("uk.me.berndporr:iirj:1.7") implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.datastore.core.android) + implementation(libs.core.ktx) // implementation(libs.litert) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/mobile/src/main/java/com/birdsounds/identify/phone_Downloader.kt b/mobile/src/main/java/com/birdsounds/identify/phone_Downloader.kt index 1840e7f..e0ab221 100644 --- a/mobile/src/main/java/com/birdsounds/identify/phone_Downloader.kt +++ b/mobile/src/main/java/com/birdsounds/identify/phone_Downloader.kt @@ -6,7 +6,7 @@ import java.io.IOException class Downloader(mainActivity: MainActivity) { - private val settings = Settings(); + private val settings = Settings; private var activity: MainActivity = mainActivity; private var context: Context = activity.applicationContext; diff --git a/mobile/src/main/java/com/birdsounds/identify/phone_Location.kt b/mobile/src/main/java/com/birdsounds/identify/phone_Location.kt index e1d44f6..cd63625 100644 --- a/mobile/src/main/java/com/birdsounds/identify/phone_Location.kt +++ b/mobile/src/main/java/com/birdsounds/identify/phone_Location.kt @@ -1,15 +1,24 @@ +@file:Suppress("DEPRECATION") + package com.birdsounds.identify import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.pm.PackageManager +import android.location.Address +import android.location.Geocoder import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Bundle import android.util.Log +import android.widget.TextView import android.widget.Toast import androidx.core.app.ActivityCompat +import java.util.Locale + object Location { private var locationListenerGPS: LocationListener? = null @@ -30,7 +39,10 @@ object Location { ) { val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + if (locationListenerGPS == null) locationListenerGPS = object : LocationListener { + @SuppressLint("SetTextI18n") override fun onLocationChanged(location: Location) { Log.w(TAG, "Got location changed"); while (!soundClassifier.is_model_ready()) { @@ -39,7 +51,35 @@ object Location { Log.w(TAG, "Sound classifier is ready"); soundClassifier.runMetaInterpreter(location) - soundClassifier.runMetaInterpreter(location) + val activity = context as? Activity; + activity?.let { + + val text_species: TextView = it.findViewById(R.id.local_species) + text_species.text = local_species; + + val loc_lon: TextView = it.findViewById(R.id.location_long) + loc_lon.text = + "Longitude: ${location.longitude}" + + val loc_lat: TextView = it.findViewById(R.id.location_lat) + loc_lat.text = + "Latitude: ${location.latitude}" + + val loc_string: TextView = it.findViewById(R.id.location_string); + val geocoder = Geocoder(context, Locale.getDefault()) + val addresses: MutableList
? = + geocoder.getFromLocation(location.latitude, location.longitude, 1) + if (addresses?.isNotEmpty() == true) { + val address: Address = addresses[0] + +// loc_string.text = address.locality.toString() + ", " + address.adminArea.toString() + " " + address.countryName.toString() + loc_string.text = address.getAddressLine(0).toString() +// Log.w(TAG, address.toString()) + + + } + } + } @Deprecated("") @@ -53,7 +93,7 @@ object Location { } } locationManager.requestLocationUpdates( - LocationManager.GPS_PROVIDER, 60000, 0f, + LocationManager.PASSIVE_PROVIDER, 60000, 0f, locationListenerGPS!! ) } diff --git a/mobile/src/main/java/com/birdsounds/identify/phone_MainActivity.kt b/mobile/src/main/java/com/birdsounds/identify/phone_MainActivity.kt index 8def748..b91c767 100644 --- a/mobile/src/main/java/com/birdsounds/identify/phone_MainActivity.kt +++ b/mobile/src/main/java/com/birdsounds/identify/phone_MainActivity.kt @@ -1,16 +1,42 @@ package com.birdsounds.identify +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context import android.content.pm.PackageManager import android.os.Bundle -import android.Manifest +import android.preference.PreferenceManager import android.util.Log +import android.widget.SeekBar +import android.widget.TextView import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.lifecycleScope import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.time.Instant + +private var updateJob: Job? = null +private var updateCounter = 0 +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + + val Any.TAG: String get() { @@ -19,11 +45,39 @@ val Any.TAG: String } +class SynchronousDataStore(private val context: Context) { + + // Define keys + companion object { + val THRESHOLD_KEY = intPreferencesKey("user_age") + } + + // Synchronous write operations + fun saveThreshold(value: Int) { + runBlocking { + context.dataStore.edit { preferences -> + preferences[THRESHOLD_KEY] = value; + } + } + } + + fun getThreshold(): Int { + + return runBlocking { + context.dataStore.data.first()[THRESHOLD_KEY] ?: 50 + } + } +} + + + class MainActivity : AppCompatActivity() { // private lateinit var soundClassifier: SoundClassifier val REQUEST_PERMISSIONS = 1337 + private lateinit var dataStore: SynchronousDataStore + private lateinit var last_message_delay: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -49,6 +103,34 @@ class MainActivity : AppCompatActivity() { insets } + + val thresholdText = findViewById(R.id.threshold_value_text) + val seekBar = findViewById(R.id.threshold_set_scale_bar) + last_message_delay = findViewById(R.id.last_message_delay) + dataStore = SynchronousDataStore(applicationContext) + + + + + + Settings.threshold = dataStore.getThreshold(); + seekBar.progress = Settings.threshold + thresholdText.text = String.format("%.2f", Settings.threshold/100f) + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + @SuppressLint("DefaultLocale") + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + + var actualValue = progress + dataStore.saveThreshold(actualValue); + Settings.threshold = actualValue; + thresholdText.text = String.format("%.2f", actualValue/100f) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + } companion object { var soundClassifier: SoundClassifier? = null @@ -57,6 +139,34 @@ class MainActivity : AppCompatActivity() { // } } + + @SuppressLint("SetTextI18n") + override fun onResume() { + super.onResume() + // Start periodic updates using coroutines + updateJob = lifecycleScope.launch { + while (isActive) { // isActive is a property of the coroutine scope + updateCounter++ + if (last_message_time == 0.toLong()) + { + last_message_delay.text = "No messages received" + } else + { + last_message_delay.text = "Last message: ${(Instant.now().toEpochMilli() - last_message_time)/1000F.toInt()} seconds ago" + } + +// last_message_delay.text = "Update count: $updateCounter" + delay(500) // Update every 1 second + } + } + } + + override fun onPause() { + super.onPause() + // Cancel the coroutine when activity is not visible + updateJob?.cancel() + } + private fun requestPermissions() { val perms = mutableListOf() diff --git a/mobile/src/main/java/com/birdsounds/identify/phone_MessageListenerService.kt b/mobile/src/main/java/com/birdsounds/identify/phone_MessageListenerService.kt index 2bae8b1..6de6020 100644 --- a/mobile/src/main/java/com/birdsounds/identify/phone_MessageListenerService.kt +++ b/mobile/src/main/java/com/birdsounds/identify/phone_MessageListenerService.kt @@ -10,6 +10,19 @@ import decodeAACToPCM import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder +import java.time.Instant + + +var last_message_time = 0L; +fun ByteArray.toLong(): Long { + require(size <= 8) { "ByteArray too large to fit in Long" } + + var result = 0L + for (byte in this) { + result = (result shl 8) or (byte.toLong() and 0xFF) + } + return result +} class MessageListenerService : WearableListenerService() { @@ -30,6 +43,10 @@ class MessageListenerService : WearableListenerService() { } var tstamp_bytes = p0.data.copyOfRange(0, Long.SIZE_BYTES) + + last_message_time = tstamp_bytes.toLong() + + var audio_bytes_og = p0.data.copyOfRange(Long.SIZE_BYTES, p0.data.size) @@ -63,8 +80,12 @@ class MessageListenerService : WearableListenerService() { var sorted_list = soundclassifier.executeScoring(short_array) Log.w(TAG, "FINISHED SCORING"); Log.w(TAG, "") - for (i in 0 until 5) { + val threshold = Settings.threshold/100f + for (i in 0 until 10) { val score = sorted_list[i].value + if (score < threshold) { + continue + } val index = sorted_list[i].index val species_name = soundclassifier.labelList[index] Log.w(TAG, species_name + ", " + score.toString()) diff --git a/mobile/src/main/java/com/birdsounds/identify/phone_Settings.kt b/mobile/src/main/java/com/birdsounds/identify/phone_Settings.kt index 2334192..2fd398e 100644 --- a/mobile/src/main/java/com/birdsounds/identify/phone_Settings.kt +++ b/mobile/src/main/java/com/birdsounds/identify/phone_Settings.kt @@ -1,9 +1,11 @@ package com.birdsounds.identify -class Settings { +object Settings { var local_model_file: String = "2024_08_16_audio_model.tflite" var pkg_model_file: String = "2024_08_16/audio-model.tflite" var local_meta_model_file: String = "2024_08_16_meta_model.tflite" var pkg_meta_model_file: String = "2024_08_16/meta-model.tflite" + + var threshold: Int = 50; } diff --git a/mobile/src/main/java/com/birdsounds/identify/phone_SoundClassifier.kt b/mobile/src/main/java/com/birdsounds/identify/phone_SoundClassifier.kt index a850bb6..e3c6207 100644 --- a/mobile/src/main/java/com/birdsounds/identify/phone_SoundClassifier.kt +++ b/mobile/src/main/java/com/birdsounds/identify/phone_SoundClassifier.kt @@ -26,6 +26,8 @@ import uk.me.berndporr.iirj.Butterworth import kotlin.math.sin + +var local_species = "" class SoundClassifier( context: Context, private val options: Options = Options() @@ -81,7 +83,7 @@ class SoundClassifier( /** Number of output classes of the TFLite model. */ private var modelNumClasses = 0 private var metaModelNumClasses = 0 - private var settings: Settings = Settings(); + private var settings = Settings; /** Used to hold the real-time probabilities predicted by the model for the output classes. */ private lateinit var predictionProbs: FloatArray @@ -274,6 +276,17 @@ class SoundClassifier( metaOutputBuffer.rewind() metaOutputBuffer.get(metaPredictionProbs) // Copy data to metaPredictionProbs. + var cloned = metaPredictionProbs.clone() + + val sorted_indices = cloned.withIndex().sortedByDescending { it.value }.map{it.index} + + local_species = "Most likely:\n" + for (i in 0..5) { + local_species += " " +labelList[sorted_indices[i]].split("_")[1] + local_species += "\n"; + } + + for (i in metaPredictionProbs.indices) { metaPredictionProbs[i] = diff --git a/mobile/src/main/res/layout/activity_main.xml b/mobile/src/main/res/layout/activity_main.xml index 86a5d97..93eab34 100644 --- a/mobile/src/main/res/layout/activity_main.xml +++ b/mobile/src/main/res/layout/activity_main.xml @@ -7,13 +7,89 @@ android:layout_height="match_parent" tools:context=".MainActivity"> - + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index fea393f..16037a6 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -44,21 +44,20 @@ dependencies { include("*.jar") }) api(files("libs/opus.aar")) - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4") - implementation("androidx.compose.ui:ui-tooling:1.3.1") - implementation("androidx.navigation:navigation-compose:2.8.0-rc01") - implementation("androidx.wear.compose:compose-navigation:1.3.1") - implementation("com.google.android.horologist:horologist-audio-ui:0.6.18") - implementation("com.google.android.horologist:horologist-audio:0.6.18") - implementation("com.google.android.horologist:horologist-compose-tools:0.6.18") - implementation("com.google.android.horologist:horologist-compose-tools:0.6.18") - implementation("androidx.compose.material:material-icons-core:1.6.8") - implementation("androidx.compose.material:material-icons-extended:1.6.8") - implementation("com.google.android.horologist:horologist-compose-material:0.6.8") - implementation("com.google.android.horologist:horologist-media-ui:0.6.8") - implementation("com.google.android.horologist:horologist-media-data:0.6.8") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.8.1") - implementation("androidx.media3:media3-exoplayer:1.4.0") + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.ui.tooling) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.compose.navigation.v131) + implementation(libs.horologist.audio.ui) + implementation(libs.horologist.audio) + implementation(libs.horologist.compose.tools) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.icons.extended) + implementation(libs.horologist.compose.material) + implementation(libs.horologist.media.ui) + implementation(libs.horologist.media.data) + implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.androidx.media3.exoplayer) implementation(libs.play.services.wearable) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) @@ -66,15 +65,20 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.foundation) +// implementation("androidx.compose.foundation:foundation:1.8.0-rc01") implementation(libs.androidx.wear.tooling.preview) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.compose.navigation) implementation(libs.androidx.media3.common) - implementation("co.touchlab:stately-concurrent-collections:2.0.0") + implementation(libs.androidx.wear.ongoing) + implementation(libs.accompanist.flowlayout) + implementation(libs.stately.concurrent.collections) implementation(libs.androidx.compose.material3) implementation(libs.androidx.work.runtime.ktx) - implementation(libs.androidx.runtime.android) // androidTestImplementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.runtime.android) + implementation(libs.androidx.animation.core.android) +// androidTestImplementation(platform(libs.androidx.compose.bom)) // androidTestImplementation(libs.androidx.ui.test.junit4) // debugImplementation(libs.androidx.ui.tooling) // debugImplementation(libs.androidx.ui.test.manifest) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 8f403bd..d7283c1 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ + + @@ -26,6 +28,8 @@ diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_Composables.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_Composables.kt new file mode 100644 index 0000000..0d1a454 --- /dev/null +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_Composables.kt @@ -0,0 +1,133 @@ +package com.birdsounds.identify.presentation + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Text +import kotlinx.coroutines.delay + + +@Composable +fun FlashingText( + text: String, + color: Color, + textDecor: TextDecoration = TextDecoration.None, + modifier: Modifier = Modifier, + key: Long, + fontStyle: FontStyle = FontStyle.Normal +) { + // Set up the flashing state + var isFlashing by remember { mutableStateOf(false) } + + // Create animation for the background color + val backgroundColor by animateColorAsState( + targetValue = if (isFlashing) Color.DarkGray else Color.Transparent, + animationSpec = tween(durationMillis = 1000) + ) + + // Trigger the flash effect once + LaunchedEffect(key1 = key) { + isFlashing = true + delay(1500) + isFlashing = false + } + + // Display the text with animated background + AutoSizedText( + text = text, + color = color, + textAlign = TextAlign.Center, + modifier = modifier + .background(color = backgroundColor), + textDecor = textDecor, + fontStyle = fontStyle + ) +} + +@Composable +fun AutoSizedText( + text: String, + color: Color = Color.White, + textAlign: TextAlign = TextAlign.Center, + modifier: Modifier = Modifier, + textDecor: TextDecoration = TextDecoration.None, + minFontSize: TextUnit = 5.sp, + targetFontSize: TextUnit = 24.sp, + fontWeight: FontWeight = FontWeight.Normal, + fontStyle: FontStyle = FontStyle.Normal, +) { + var display_text = text; + val textMeasurer = rememberTextMeasurer() + BoxWithConstraints(modifier) { + val density = LocalDensity.current +// val minFontSizePx = with(density) { minFontSize.toPx() } +// val maxFontSizePx = with(density) { targetFontSize.toPx() } + val availableWidthPx = with(density) { maxWidth.toPx() } + + // Binary search to find the appropriate font size that fits the available width + var lowPx: TextUnit = minFontSize + var highPx: TextUnit = targetFontSize + var bestFontSizePx = targetFontSize; + val textStyle = TextStyle(fontWeight = fontWeight, color = color, fontSize=bestFontSizePx) + while (lowPx <= highPx) { + val midPx: TextUnit = TextUnit(value=lowPx.value/2 + highPx.value/2 , type= TextUnitType.Sp) + val style = textStyle.copy(fontSize = midPx) + + val textLayoutResult = textMeasurer.measure( + text = display_text, + style = style, + maxLines = 1, + softWrap = false, + density = density, + ) + + if (textLayoutResult.size.width <= availableWidthPx) { +// // This font size fits, try a larger oneb + bestFontSizePx = midPx; + break; + } else { +// // This font size is too large, try a smaller one +// highPx = (midPx - 1).toFloat() + highPx = TextUnit(value=midPx.value - 1, type=TextUnitType.Sp) + } + } + + // Convert the best font size back to sp and use it +// val bestFontSize = with(density) { bestFontSizePx.toSp() } + + + Text( + text = display_text, + color = color, + fontSize = bestFontSizePx, + fontStyle = fontStyle, + fontWeight = fontWeight, + maxLines = 1, + textDecoration = textDecor, + overflow = TextOverflow.Clip, + modifier = Modifier.fillMaxWidth(), + textAlign = textAlign + ) + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_ControlDashboard.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_ControlDashboard.kt deleted file mode 100644 index 93d3b93..0000000 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_ControlDashboard.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.birdsounds.identify.presentation - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import androidx.wear.compose.material.CompactChip -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.Text - -@Composable -fun ControlDashboard(controlDashboardUiState: ControlDashboardUiState, - onMicClicked: () -> Unit, - onNavClicked: () -> Unit, - modifier: Modifier = Modifier) { - Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ControlDashboardButton(buttonState = controlDashboardUiState.micState, - onClick = onMicClicked, - labelText = if (controlDashboardUiState.micState.expanded) { - "Stop" - } else { - "Start" - }) - } - - - } -} - - -@Composable -private fun ControlDashboardButton(buttonState: ControlDashboardButtonUiState, - onClick: () -> Unit, - labelText: String, - modifier: Modifier = Modifier) { - CompactChip(modifier = Modifier, - onClick = onClick, - enabled = true, - contentPadding = PaddingValues(5.dp, 1.dp, 5.dp, 1.dp), - shape = MaterialTheme.shapes.small, - label = { - Text(text = labelText) - }) -} - - -// Button(modifier = modifier, enabled = buttonState.enabled && buttonState.visible, onClick = onClick) { -// Text(contentDescription); -//// Icon(imageVector = imageVector, contentDescription = contentDescription) -// } -//} - - -data class ControlDashboardButtonUiState(val expanded: Boolean, val enabled: Boolean, val visible: Boolean) - - -data class ControlDashboardUiState(val micState: ControlDashboardButtonUiState) { - init { // Check that at most one of the buttons is expanded - - } -} diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainActivity.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainActivity.kt index 06c6ea2..4f58cbb 100644 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainActivity.kt +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainActivity.kt @@ -6,56 +6,62 @@ package com.birdsounds.identify.presentation import android.Manifest + +import android.annotation.SuppressLint import android.app.Activity +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service import android.content.Context import android.content.ContextWrapper +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat.startForeground import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController +import androidx.wear.ambient.AmbientLifecycleObserver import androidx.wear.compose.material.CompactChip import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import androidx.wear.compose.navigation.SwipeDismissableNavHost import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavController -import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices -import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import androidx.wear.ongoing.OngoingActivity +import com.birdsounds.identify.R import com.birdsounds.identify.presentation.theme.IdentifyTheme import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.audio.ui.VolumeViewModel -import com.google.android.horologist.compose.layout.ScalingLazyColumn -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.Chip -import com.google.android.horologist.compose.material.ListHeaderDefaults.firstItemPadding -import com.google.android.horologist.compose.material.ResponsiveListHeader -import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch + val flow_stream = MutableStateFlow("") val Any.TAG: String @@ -64,26 +70,30 @@ val Any.TAG: String return if (tag.length <= 23) tag else tag.substring(0, 23) } - class MainActivity : ComponentActivity() { + lateinit var ambientObserver:AmbientLifecycleObserver + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() - + ambientObserver= AmbientLifecycleObserver(this.findActivity(), ambientCallback) super.onCreate(savedInstanceState) + + lifecycle.addObserver(ambientObserver) setTheme(android.R.style.Theme_DeviceDefault) setContent { WearApp() } + } } +@SuppressLint("ForegroundServiceType") @OptIn(ExperimentalHorologistApi::class) @Composable @Preview fun WearApp() { - IdentifyTheme { lateinit var requestPermissionLauncher: ManagedActivityResultLauncher val context = LocalContext.current @@ -91,6 +101,48 @@ fun WearApp() { val scope = rememberCoroutineScope() + val touchIntent = + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + + val notificationBuilder = + NotificationCompat.Builder(context, "Identify Watch App") + .setOngoing(true) + .setSmallIcon(com.birdsounds.identify.R.mipmap.ic_launcher) + + + val ongoingActivity = + OngoingActivity.Builder( + context, 4, notificationBuilder + ).setTouchIntent(touchIntent) + .setStaticIcon( + com.birdsounds.identify.R.mipmap.ic_launcher) + .build() + + + ongoingActivity.apply(context) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify( + 4, + notificationBuilder.build() + ) + + val notification: Notification = NotificationCompat.Builder( + context, "Identify Watch App" + ) + .setContentTitle("Always On App") + .setContentText("Running in foreground") + .setSmallIcon(com.birdsounds.identify.R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + + val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory) val mainState = remember(activity) { @@ -99,7 +151,6 @@ fun WearApp() { }) } - requestPermissionLauncher = rememberLauncherForActivityResult(RequestPermission()) { scope.launch { mainState.permissionResultReturned() @@ -108,42 +159,111 @@ fun WearApp() { val navController: NavHostController = rememberSwipeDismissableNavController() mainState.setNavController(navController); - SwipeDismissableNavHost(navController = navController, userSwipeEnabled = true, startDestination = "speaker") { - + SwipeDismissableNavHost( + navController = navController, + userSwipeEnabled = true, + startDestination = "speaker" + ) { composable("species_list") { ScreenScaffold { - SpeciesListView(context = context) + SpeciesListView(context = context, appState = mainState.appState) } } composable("speaker") { ScreenScaffold { - CompactChip(modifier = Modifier, - onClick = { navController.navigate("species_list") }, - enabled = true, - contentPadding = PaddingValues(5.dp, 1.dp, 5.dp, 1.dp), - shape = MaterialTheme.shapes.small, - label = { - Text(text = "View>") - }) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + ) { + Row() { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // Define the animation for the scale + val scale by animateFloatAsState( + targetValue = if (isPressed) 2.0f else 1.0f + ) + + CompactChip( + onClick = { + vibrate( + context, + 250 + );navController.navigate("species_list") + }, + enabled = true, + modifier = Modifier.scale(scale), + interactionSource = interactionSource, + contentPadding = PaddingValues(5.dp, 1.dp, 5.dp, 1.dp), + shape = MaterialTheme.shapes.small, + label = { + Text(text = "Show List") + }, + + ) + - StartRecordingScreen( - appState = mainState.appState, - onMicClicked = { - scope.launch { - mainState.onMicClicked() - } - }, - onNavClicked = { - navController.navigate("species_list") } + Row() { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() - ) + // Define the animation for the scale + val scale by animateFloatAsState( + targetValue = if (isPressed) 2.0f else 1.0f + ) + CompactChip( + onClick = { + vibrate(context, 250); + scope.launch { + mainState.onMicClicked() + } + }, + modifier = Modifier.scale(scale), + interactionSource = interactionSource, + enabled = true, + contentPadding = PaddingValues(5.dp, 1.dp, 5.dp, 1.dp), + shape = MaterialTheme.shapes.small, + label = { + Text(text = if (mainState.appState == AppState.Recording) "Stop Recording" else "Start Recording") + }) + } + Row() { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // Define the animation for the scale + val scale by animateFloatAsState( + targetValue = if (isPressed) 2.0f else 1.0f + ) + CompactChip( + onClick = { + vibrate(context, 250); + species_list_show.clear(); + internal_list.clear(); + }, + enabled = true, + modifier = Modifier.scale(scale), + interactionSource = interactionSource, + contentPadding = PaddingValues(5.dp, 1.dp, 5.dp, 1.dp), + shape = MaterialTheme.shapes.small, + label = { + Text(text = "Clear List") + }, + + ) + + + } + } } } - } } diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainState.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainState.kt index f9c1253..ab54e6d 100644 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainState.kt +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_MainState.kt @@ -31,6 +31,9 @@ class MainState(private val activity: Activity, private val requestPermission: ( public fun setNavController(_navController: NavHostController) { navController = _navController } + + + suspend fun onMicClicked() { playbackStateMutatorMutex.mutate { when (appState) { diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_MessageListenerService.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_MessageListenerService.kt index 9bfd65e..fc72cf8 100644 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_MessageListenerService.kt +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_MessageListenerService.kt @@ -1,28 +1,58 @@ package com.birdsounds.identify.presentation +import android.content.Context +import androidx.compose.ui.platform.LocalContext import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.WearableListenerService +import kotlinx.coroutines.delay import java.nio.ByteBuffer +var last_message_recv_tstamp: Long = 0L; class MessageListenerService : WearableListenerService() { private val tag = "MessageListenerService" - + lateinit var this_context: Context; + fun set_context(context: Context) + { + this_context = context; + } override fun onMessageReceived(p0: MessageEvent) { super.onMessageReceived(p0) val t_scored = ByteBuffer.wrap(p0.data).getLong() + last_message_recv_tstamp = t_scored; + var byte_strings: ByteArray = p0.data.copyOfRange(8, p0.data.size) var score_species_string = byte_strings.decodeToString() var list_strings: List = score_species_string.split(';') + var do_vibrate = false; + var did_add = false; + var new_entries = 0; + SpeciesList.clear_new_flags(); list_strings.map({ var split_str = it.split(',') if (split_str.size == 2) { - var out = AScore(split_str[0], split_str[1].toFloat(), t_scored) - if (out.score > 0.005) { - SpeciesList.add_observation(out) + new_entries+=1; + var out = AScore(split_str[0], split_str[1].toFloat(), t_scored); + out.new_entry = true; + + if (SpeciesList.add_observation(out)) + { + did_add = true; } + do_vibrate = true; } }) +// SpeciesList.set_num_new_entries(new_entries); +// SpeciesList.insert_new_entry_spacer(new_entries); + if (did_add) { + vibrateDouble(this, 100, 250, 100); + } else if (do_vibrate) { + vibrate(this,100) + } +// if (new_entries > 0) { + SpeciesList.update_list_on_ui_thread(); +// } + MessageSender.messageLog.add(t_scored) } } \ No newline at end of file diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_Other.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_Other.kt new file mode 100644 index 0000000..811c7cb --- /dev/null +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_Other.kt @@ -0,0 +1,19 @@ +package com.birdsounds.identify.presentation + +import androidx.wear.ambient.AmbientLifecycleObserver + +val ambientCallback = object : AmbientLifecycleObserver.AmbientLifecycleCallback { + override fun onEnterAmbient(ambientDetails: AmbientLifecycleObserver.AmbientDetails) { + // ... Called when moving from interactive mode into ambient mode. + } + + override fun onExitAmbient() { + // ... Called when leaving ambient mode, back into interactive mode. + } + + override fun onUpdateAmbient() { + // ... Called by the system in order to allow the app to periodically + // update the display while in ambient mode. Typically the system will + // call this every 60 seconds. + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_SoundRecorder.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_SoundRecorder.kt index 2a91f33..e9d81fb 100644 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_SoundRecorder.kt +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_SoundRecorder.kt @@ -5,11 +5,13 @@ import com.theeasiestway.opus.Opus import android.Manifest import android.content.Context + import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.media.audiofx.AutomaticGainControl import android.media.audiofx.NoiseSuppressor +import android.os.Build import android.util.Log import androidx.annotation.RequiresPermission import encodePcmToAac @@ -21,6 +23,66 @@ import java.time.Instant /** * A helper class to provide methods to record audio input from the MIC to the internal storage. */ + +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +fun vibrateDouble( + context: Context, + firstDuration: Long = 100, + pauseDuration: Long = 200, + secondDuration: Long = 100 +) { + // Get the vibrator service + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + // Create a pattern for double vibration: 0ms delay, firstDuration vibrate, pauseDuration pause, secondDuration vibrate + val vibrationPattern = longArrayOf(0, firstDuration, pauseDuration, secondDuration) + + // The -1 parameter means don't repeat the pattern + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val vibrationEffect = VibrationEffect.createWaveform(vibrationPattern, -1) + vibrator.vibrate(vibrationEffect) + } else { + // Deprecated method for older API levels + @Suppress("DEPRECATION") + vibrator.vibrate(vibrationPattern, -1) + } +} + + +fun vibrate(context: Context, duration: Long = 500) { + val vibrator = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + val vibrationEffect = VibrationEffect.createOneShot( + duration, // duration in milliseconds + VibrationEffect.DEFAULT_AMPLITUDE // strength of vibration + ) + vibrator.vibrate(vibrationEffect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(duration) // Vibration for 500ms + } +} + @Suppress("DEPRECATION") class SoundRecorder( context_in: Context, @@ -44,7 +106,7 @@ class SoundRecorder( var noiseSuppressor: NoiseSuppressor? = null var automaticGainControl: AutomaticGainControl? = null var chunk_index: Int = 0 - val audioSource = MediaRecorder.AudioSource.DEFAULT + val audioSource = MediaRecorder.AudioSource.VOICE_RECOGNITION val sampleRateInHz = 48000 val channelConfig = AudioFormat.CHANNEL_IN_MONO val audioFormat = AudioFormat.ENCODING_PCM_16BIT diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesList.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesList.kt index c19bb97..646dfea 100644 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesList.kt +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesList.kt @@ -2,8 +2,11 @@ package com.birdsounds.identify.presentation import android.util.Log import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import java.time.Instant @@ -12,16 +15,24 @@ class AScore( _species: String, _score: Float, _timestamp: Long, - _trigger: Boolean = false - ) { - + _trigger: Boolean = false, + _new_entry: Boolean = false, + _redraw_number: Long = 0, +) { + var redraw_number = _redraw_number; val split_stuff: List = _species.split("_"); val species = split_stuff[0]; val score = _score; var trigger = _trigger; + var new_entry = _new_entry; + // var common_name = split_stuff[1]; val common_name = if (split_stuff.size > 1) split_stuff[1] else ""; val timestamp = _timestamp + fun set_redraw_number(redraw_num: Long) + { + redraw_number = redraw_num + } fun age(): Long { var tstamp: Long = Instant.now().toEpochMilli() return (tstamp - timestamp) @@ -34,20 +45,28 @@ class AScore( } } +var internal_list = mutableListOf() object SpeciesList { - var internal_list = mutableListOf() + var trigger_redraw = 0; + var num_new_entries = 0; var do_add_observation = false var _list_on_ui: SnapshotStateList>? = null fun setSpeciecsShow_list(list_in: SnapshotStateList>) { _list_on_ui = list_in; } + fun clear_new_flags() { + for (i in internal_list) { + i.new_entry = false; + } + } - fun add_observation(species_in: AScore) { + fun add_observation(species_in: AScore): Boolean { Log.w(TAG, "In add observation") var idx = 0 var idx_replace = -1 + var added_new = false; for (i in internal_list) { if (i.species == species_in.species) { idx_replace = idx @@ -60,22 +79,31 @@ object SpeciesList { internal_list[idx_replace].trigger = true; } else { internal_list.add(species_in) + added_new = true; } // internal_list = internal_list.sortedBy({ (it.age()) }).toMutableList() - internal_list = internal_list.sortedWith(compareBy({ it.age() }, { it.score})).toMutableList() + internal_list = + internal_list.sortedWith(compareBy({ it.age() }, { it.score })).toMutableList() + + return added_new + + } + fun update_list_on_ui_thread( + ) { while (_list_on_ui!!.size < internal_list.size) { - _list_on_ui!!.add(mutableStateOf(AScore("",0.0F,0L))) + _list_on_ui!!.add(mutableStateOf(AScore("", 0.0F, 0L))) } + val time_stamp = System.currentTimeMillis() for ((index, value) in internal_list.withIndex()) { _list_on_ui?.get(index)?.value = value; } - Log.w(TAG, internal_list.size.toString()) - Log.w(TAG, _list_on_ui?.size.toString()) // internal_list.add(species_in) + } + } \ No newline at end of file diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesListView.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesListView.kt index f67a0e4..75587c4 100644 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesListView.kt +++ b/wear/src/main/java/com/birdsounds/identify/presentation/watch_SpeciesListView.kt @@ -2,23 +2,22 @@ package com.birdsounds.identify.presentation import android.annotation.SuppressLint import android.content.Context +import android.text.format.DateFormat import android.util.Log -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.Easing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.MicOff import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,124 +26,228 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.lerp -import androidx.wear.compose.foundation.lazy.ScalingLazyColumnDefaults.scalingParams -import androidx.wear.compose.foundation.lazy.ScalingParams +import androidx.wear.compose.foundation.curvedComposable import androidx.wear.compose.foundation.lazy.items -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.itemsIndexed +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.TimeTextDefaults +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.curvedText import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScalingLazyColumnState import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.compose.layout.rememberResponsiveColumnState import kotlinx.coroutines.delay +import java.time.Instant +import java.util.Locale fun interpolateColor(value: Float): Color { - // Ensure that the input value is clamped between 0 and 1 - val clampedValue = value.coerceIn(0f, 1f) + // Ensure that the input value is clamped between 0 and 1 + val clampedValue = value.coerceIn(0f, 1f) - // Red component: starts at 255 (white) and decreases to 0 - val red = (255 * (1 - clampedValue)).toInt() + // Red component: starts at 255 (white) and decreases to 0 + val red = (255 * (1 - clampedValue)).toInt() - // Green component: stays at 255 (since both white and green have full green) - val green = 255 + // Green component: stays at 255 (since both white and green have full green) + val green = 255 - // Blue component: starts at 255 (white) and decreases to 0 - val blue = (255 * (1 - clampedValue)).toInt() + // Blue component: starts at 255 (white) and decreases to 0 + val blue = (255 * (1 - clampedValue)).toInt() - // Construct the color - return Color(red.toInt(), green.toInt(), blue.toInt()) - } - -@Composable -fun FlashingText( - text: String, - color: Color, - modifier: Modifier = Modifier -) { - // Set up the flashing state - var isFlashing by remember { mutableStateOf(false) } - - // Create animation for the background color - val backgroundColor by animateColorAsState( - targetValue = if (isFlashing) Color.Cyan else Color.Transparent, - animationSpec = tween(durationMillis = 500) - ) - - // Trigger the flash effect once - LaunchedEffect(key1 = true) { - isFlashing = true - delay(300) - isFlashing = false - } - - // Display the text with animated background - Text( - text = text, - color = color, - modifier = modifier - .background(color = backgroundColor) - ) + // Construct the color + return Color(red.toInt(), green.toInt(), blue.toInt()) } - +val species_list_show = mutableStateListOf>() @SuppressLint("UnrememberedMutableState") @OptIn(ExperimentalHorologistApi::class) @Composable -fun SpeciesListView(context: Context, +fun SpeciesListView( + context: Context, appState: AppState, ) { - val species_list_show = mutableStateListOf>() - for (i in 1..3) - { - val hi = mutableStateOf(AScore("",0.0F,0L)) + + for (i in 1..3) { + val hi = mutableStateOf(AScore("", 0.0F, 0L)) species_list_show.add(hi); } SpeciesList.setSpeciecsShow_list(species_list_show) + val species_show: SnapshotStateList> = remember { species_list_show } // var sP = scalingParams( maxTransitionArea= 0.25f, minTransitionArea = 0.05f) // val columnState = ScalingLazyColumnState(scalingParams = sP); - var columnState = rememberResponsiveColumnState(contentPadding = ScalingLazyColumnDefaults.padding(first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip), verticalArrangement= Arrangement.spacedBy( - space = 1.dp, - alignment = Alignment.Top, - ),) + var columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ), + verticalArrangement = Arrangement.spacedBy( + space = 1.dp, + alignment = Alignment.Top, + ), + ) + val text_counter = remember { mutableStateOf("Initial text") } + LaunchedEffect(key1 = true) { + while (true) { + // Update the text - you can put your custom logic here + // For example, showing current time: + var lag = ((Instant.now().toEpochMilli() - last_message_recv_tstamp) / 1000F).toInt(); + if (last_message_recv_tstamp == 0L) { + text_counter.value = "No msg recv" + } else { + text_counter.value = "${lag}s" + } - - - - ScreenScaffold(scrollState = columnState) { - ScalingLazyColumn( - columnState = columnState, - modifier = Modifier.fillMaxWidth() - ) { - items(species_show) { aSpec -> - if (aSpec.value.trigger) - { - Log.w(TAG,"Trigger "+aSpec.toString()) - FlashingText(text = aSpec.value.common_name, color = interpolateColor(aSpec.value.score)) - aSpec.value.trigger = false; - } else - { - Text(text = aSpec.value.common_name, color = interpolateColor(aSpec.value.score)) - } - } - } // Dynamically display the chips - + // Delay for 1 second + delay(1000) } } + + + + ScreenScaffold( + scrollState = columnState + ) { + TimeText( + timeSource = + TimeTextDefaults.timeSource( + DateFormat.getBestDateTimePattern(Locale.getDefault(), "hh:mm") + ), + startCurvedContent = { + curvedComposable { + Icon( + if (appState == AppState.Recording) Icons.Rounded.Mic else Icons.Rounded.MicOff, + contentDescription = "", + modifier = Modifier.size(16.dp) +// tint = androidx.compose.ui.graphics.Color.Red + ) + } + +// curvedText( +// text = if (appState == AppState.Recording) "" else "Inactive0", +//// textAlign = TextAlign.Center, +// ) + }, + endCurvedContent = { + + curvedText(text = text_counter.value)//, textAlign = TextAlign.Center) + + } + + + ) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + ) { + + ScalingLazyColumn( + columnState = columnState, + modifier = Modifier.fillMaxWidth() + ) { + itemsIndexed( + species_show, + ) { index, aSpec -> + var text_decor = TextDecoration.None + var text_style = FontStyle.Normal; + + val currentName = aSpec.value.common_name + val currentScore = aSpec.value.score + val isNewEntry = aSpec.value.new_entry + val shouldTrigger = aSpec.value.trigger + + var add_sep = false; + if (aSpec.value.new_entry) { +// add_sep = true; + text_style = FontStyle.Italic; +// aSpec.value.new_entry = false + } + Log.w(TAG, "Species " + currentName + " " + aSpec.value.new_entry.toString()) + LaunchedEffect(Unit) { + delay(100); + aSpec.value.new_entry = false + } +// if (index == (SpeciesList.num_new_entries-1)) { +// add_sep = true;" +// } + Log.w( + TAG, + "Adding Separator: " + index.toString() + " " + SpeciesList.num_new_entries + " " + add_sep.toString() + ) + aSpec.value.new_entry = false; + +// var composables: List<@Composable () -> Unit> = listOf(); + + key(currentName, isNewEntry, shouldTrigger) { + if (shouldTrigger) { + Log.w(TAG, "Trigger " + aSpec.toString()) + FlashingText( + text = aSpec.value.common_name, + fontStyle = text_style, + color = interpolateColor(aSpec.value.score), + key = Instant.now().toEpochMilli(), + textDecor = text_decor, + modifier = Modifier.fillMaxSize() + ) + LaunchedEffect(Unit) { + delay(100); + aSpec.value.trigger = false + } + Log.e("TAG", "BLINKING") + } else { + + AutoSizedText( + text = aSpec.value.common_name, + color = interpolateColor(aSpec.value.score), + modifier = Modifier.fillMaxSize(), + textAlign = TextAlign.Center, + textDecor = text_decor, + fontStyle = text_style, + ) + + Log.e("TAG", "Not blinking") + } + } + + if (add_sep) { +// composables += { + AutoSizedText( + text = "-------------------", + modifier = Modifier.fillMaxSize(), + textAlign = TextAlign.Center, + ) +// } + } + +// Column { +// composables.forEach { composable -> +// Row { +// composable() +// } +// } +// } + + + }// Dynamically display the chips + } + } + + } +} // ScreenScaffold(scrollState = columnState) { // ScalingLazyColumn(columnState = columnState, modifier = Modifier.fillMaxSize()) { // items(species_show) { aSpec -> Text(text = aSpec.value.common_name) diff --git a/wear/src/main/java/com/birdsounds/identify/presentation/watch_StartRecordingScreen.kt b/wear/src/main/java/com/birdsounds/identify/presentation/watch_StartRecordingScreen.kt deleted file mode 100644 index 9b01bff..0000000 --- a/wear/src/main/java/com/birdsounds/identify/presentation/watch_StartRecordingScreen.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.birdsounds.identify.presentation - - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.navigation.NavHostController -import com.google.android.horologist.compose.layout.ScreenScaffold - -@Composable -fun StartRecordingScreen( - appState: AppState, - onMicClicked: () -> Unit, - onNavClicked: () -> Unit -) { - ScreenScaffold { - val controlDashboardUiState = computeControlDashboardUiState( - appState = appState, - ) - ControlDashboard( - controlDashboardUiState = controlDashboardUiState, - onMicClicked = onMicClicked, - onNavClicked = onNavClicked - ) - } -} - -private fun computeControlDashboardUiState( - appState: AppState, -): ControlDashboardUiState = - when (appState) { - AppState.Ready -> ControlDashboardUiState( - micState = ControlDashboardButtonUiState(expanded = false, visible = true, enabled = true), - ) - AppState.Recording -> ControlDashboardUiState( - micState = ControlDashboardButtonUiState(expanded = true, visible = true, enabled = true), - ) - } - -private class PlaybackStatePreviewProvider : CollectionPreviewParameterProvider( - listOf( - AppState.Recording, - AppState.Ready - ) -) diff --git a/wear/src/main/res/values/styles.xml b/wear/src/main/res/values/styles.xml index dbf9c83..eda7b00 100644 --- a/wear/src/main/res/values/styles.xml +++ b/wear/src/main/res/values/styles.xml @@ -2,7 +2,7 @@ \ No newline at end of file