From f3b2a45d8bf1f5f9582e2fabdc1f98e0374dbbe7 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Sat, 29 Mar 2025 19:20:43 +0200 Subject: [PATCH] Migration of user interface to Jetpack Compose --- app/build.gradle | 79 - app/build.gradle.kts | 116 + app/src/main/AndroidManifest.xml | 21 +- app/src/main/cpp/baresip.c | 28 +- .../com/tutpro/baresip/AboutActivity.kt | 128 +- .../main/kotlin/com/tutpro/baresip/Account.kt | 27 +- .../com/tutpro/baresip/AccountActivity.kt | 2260 +++++++++----- .../com/tutpro/baresip/AccountListAdapter.kt | 80 - .../kotlin/com/tutpro/baresip/AccountRow.kt | 3 - .../com/tutpro/baresip/AccountsActivity.kt | 533 +++- .../tutpro/baresip/AndroidContactActivity.kt | 312 +- .../tutpro/baresip/AndroidUriListAdapter.kt | 76 - app/src/main/kotlin/com/tutpro/baresip/Api.kt | 4 +- .../kotlin/com/tutpro/baresip/AppTheme.kt | 67 + .../com/tutpro/baresip/AudioActivity.kt | 950 +++--- .../tutpro/baresip/BaresipContactActivity.kt | 704 +++++ .../com/tutpro/baresip/BaresipService.kt | 232 +- .../com/tutpro/baresip/CallDetailsActivity.kt | 351 ++- .../com/tutpro/baresip/CallDetailsAdapter.kt | 166 - .../com/tutpro/baresip/CallListAdapter.kt | 85 - .../com/tutpro/baresip/CallsActivity.kt | 601 +++- .../kotlin/com/tutpro/baresip/ChatActivity.kt | 709 ++++- .../com/tutpro/baresip/ChatListAdapter.kt | 83 - .../com/tutpro/baresip/ChatsActivity.kt | 778 +++-- .../main/kotlin/com/tutpro/baresip/Codec.kt | 4 +- .../com/tutpro/baresip/CodecsActivity.kt | 418 ++- .../com/tutpro/baresip/CodecsAdapter.kt | 59 - .../main/kotlin/com/tutpro/baresip/Config.kt | 7 +- .../com/tutpro/baresip/ConfigActivity.kt | 1846 +++++++---- .../kotlin/com/tutpro/baresip/Constants.kt | 2 - .../main/kotlin/com/tutpro/baresip/Contact.kt | 164 +- .../com/tutpro/baresip/ContactActivity.kt | 524 ---- .../com/tutpro/baresip/ContactListAdapter.kt | 218 -- .../com/tutpro/baresip/ContactsActivity.kt | 477 ++- .../kotlin/com/tutpro/baresip/CustomColors.kt | 177 ++ .../com/tutpro/baresip/CustomElements.kt | 317 ++ .../com/tutpro/baresip/DraggableLazyList.kt | 358 +++ .../kotlin/com/tutpro/baresip/MainActivity.kt | 2760 ++++++++++------- .../main/kotlin/com/tutpro/baresip/Message.kt | 64 +- .../com/tutpro/baresip/MessageListAdapter.kt | 141 - .../tutpro/baresip/OnSwipeTouchListener.kt | 79 - .../com/tutpro/baresip/UaSpinnerAdapter.kt | 57 - .../kotlin/com/tutpro/baresip/UserAgent.kt | 37 +- .../main/kotlin/com/tutpro/baresip/Utils.kt | 169 +- .../kotlin/com/tutpro/baresip/ViewModel.kt | 17 + .../main/res/drawable-night/message_in_bg.xml | 11 - .../res/drawable-night/message_out_bg.xml | 11 - .../main/res/drawable-night/spinner_bg.xml | 17 - app/src/main/res/drawable/arrow_drop_down.xml | 5 + app/src/main/res/drawable/call_hold.xml | 2 +- app/src/main/res/drawable/call_transfer.xml | 2 +- app/src/main/res/drawable/calls_missed.xml | 3 +- app/src/main/res/drawable/visibility.xml | 5 + app/src/main/res/drawable/visibility_off.xml | 5 + app/src/main/res/layout/account_row.xml | 34 - app/src/main/res/layout/account_spinner.xml | 27 - app/src/main/res/layout/activity_about.xml | 27 - app/src/main/res/layout/activity_account.xml | 510 --- app/src/main/res/layout/activity_accounts.xml | 55 - .../res/layout/activity_android_contact.xml | 66 - app/src/main/res/layout/activity_audio.xml | 264 -- .../main/res/layout/activity_call_details.xml | 66 - app/src/main/res/layout/activity_calls.xml | 33 - app/src/main/res/layout/activity_chat.xml | 60 - app/src/main/res/layout/activity_chats.xml | 58 - app/src/main/res/layout/activity_codecs.xml | 28 - app/src/main/res/layout/activity_config.xml | 384 --- app/src/main/res/layout/activity_contact.xml | 139 - app/src/main/res/layout/activity_contacts.xml | 36 - app/src/main/res/layout/activity_main.xml | 403 --- app/src/main/res/layout/alert_title.xml | 9 - app/src/main/res/layout/android_uri_row.xml | 49 - app/src/main/res/layout/call_detail_row.xml | 31 - app/src/main/res/layout/call_row.xml | 96 - .../main/res/layout/call_transfer_dialog.xml | 97 - app/src/main/res/layout/chat_row.xml | 93 - app/src/main/res/layout/codec.xml | 36 - app/src/main/res/layout/contact_row.xml | 66 - app/src/main/res/layout/message.xml | 39 - app/src/main/res/values-bg/strings.xml | 9 +- app/src/main/res/values-cs/strings.xml | 3 - app/src/main/res/values-de/strings.xml | 3 - app/src/main/res/values-es/strings.xml | 12 +- app/src/main/res/values-fi/strings.xml | 28 +- app/src/main/res/values-ja-rJP/strings.xml | 12 +- app/src/main/res/values-nb-rNO/strings.xml | 3 +- app/src/main/res/values-pt-rBR/strings.xml | 4 - app/src/main/res/values-pt/strings.xml | 5 +- app/src/main/res/values-ro/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 5 +- app/src/main/res/values-sv/strings.xml | 5 +- app/src/main/res/values-ta/strings.xml | 2 - app/src/main/res/values/strings.xml | 34 +- app/src/main/res/values/styles.xml | 61 +- build.gradle | 2 +- gradle/libs.versions.toml | 68 + gradle/wrapper/gradle-wrapper.properties | 2 +- 97 files changed, 10597 insertions(+), 8717 deletions(-) delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/AccountListAdapter.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/AccountRow.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/AndroidUriListAdapter.kt create mode 100644 app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt create mode 100644 app/src/main/kotlin/com/tutpro/baresip/BaresipContactActivity.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/CallDetailsAdapter.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/CallListAdapter.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/ChatListAdapter.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/CodecsAdapter.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/ContactActivity.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/ContactListAdapter.kt create mode 100644 app/src/main/kotlin/com/tutpro/baresip/CustomColors.kt create mode 100644 app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt create mode 100644 app/src/main/kotlin/com/tutpro/baresip/DraggableLazyList.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/MessageListAdapter.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/OnSwipeTouchListener.kt delete mode 100644 app/src/main/kotlin/com/tutpro/baresip/UaSpinnerAdapter.kt create mode 100644 app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt delete mode 100644 app/src/main/res/drawable-night/message_in_bg.xml delete mode 100644 app/src/main/res/drawable-night/message_out_bg.xml delete mode 100644 app/src/main/res/drawable-night/spinner_bg.xml create mode 100644 app/src/main/res/drawable/arrow_drop_down.xml create mode 100644 app/src/main/res/drawable/visibility.xml create mode 100644 app/src/main/res/drawable/visibility_off.xml delete mode 100644 app/src/main/res/layout/account_row.xml delete mode 100644 app/src/main/res/layout/account_spinner.xml delete mode 100644 app/src/main/res/layout/activity_about.xml delete mode 100644 app/src/main/res/layout/activity_account.xml delete mode 100644 app/src/main/res/layout/activity_accounts.xml delete mode 100644 app/src/main/res/layout/activity_android_contact.xml delete mode 100644 app/src/main/res/layout/activity_audio.xml delete mode 100644 app/src/main/res/layout/activity_call_details.xml delete mode 100644 app/src/main/res/layout/activity_calls.xml delete mode 100644 app/src/main/res/layout/activity_chat.xml delete mode 100644 app/src/main/res/layout/activity_chats.xml delete mode 100644 app/src/main/res/layout/activity_codecs.xml delete mode 100644 app/src/main/res/layout/activity_config.xml delete mode 100644 app/src/main/res/layout/activity_contact.xml delete mode 100644 app/src/main/res/layout/activity_contacts.xml delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/main/res/layout/alert_title.xml delete mode 100644 app/src/main/res/layout/android_uri_row.xml delete mode 100644 app/src/main/res/layout/call_detail_row.xml delete mode 100644 app/src/main/res/layout/call_row.xml delete mode 100644 app/src/main/res/layout/call_transfer_dialog.xml delete mode 100644 app/src/main/res/layout/chat_row.xml delete mode 100644 app/src/main/res/layout/codec.xml delete mode 100644 app/src/main/res/layout/contact_row.xml delete mode 100644 app/src/main/res/layout/message.xml create mode 100644 gradle/libs.versions.toml diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 2a00b46d..00000000 --- a/app/build.gradle +++ /dev/null @@ -1,79 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' - -android { - compileSdk 35 - ndkVersion '27.2.12479018' - defaultConfig { - applicationId = 'com.tutpro.baresip' - minSdkVersion 28 - targetSdkVersion 35 - versionCode = 416 - versionName = '63.3.0' - externalNativeBuild { - cmake { - cFlags '-DHAVE_INTTYPES_H -lstdc++' - arguments '-DANDROID_STL=c++_shared' - version '3.25.1' - } - } - ndk { - // noinspection ChromeOsAbiSupport - abiFilters 'armeabi-v7a', 'arm64-v8a' //, 'x86_64' - } - vectorDrawables.useSupportLibrary = true - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = '17' - } - splits { - abi { - reset() - } - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), - 'proguard-rules.pro' - } - } - buildFeatures { - viewBinding true - dataBinding true - buildConfig true - } - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - externalNativeBuild { - cmake { - path 'src/main/cpp/CMakeLists.txt' - } - } - namespace 'com.tutpro.baresip' -} - -dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.0' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - implementation "androidx.preference:preference-ktx:1.2.1" - implementation "androidx.exifinterface:exifinterface:1.3.7" - implementation "androidx.core:core-ktx:1.15.0" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' - implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" - implementation "androidx.activity:activity-ktx:1.10.0" - implementation "androidx.fragment:fragment-ktx:1.8.6" - implementation 'androidx.media:media:1.7.0' -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..6c66cf29 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,116 @@ +plugins { + alias(libs.plugins.compose.compiler) + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + compileSdk = 35 + ndkVersion = "27.2.12479018" + defaultConfig { + applicationId = "com.tutpro.baresip" + minSdk = 28 + targetSdk = 35 + versionCode = 416 + versionName = "63.3.0" + externalNativeBuild { + cmake { + cFlags += "-DHAVE_INTTYPES_H -lstdc++" + arguments.addAll(listOf("-DANDROID_STL=c++_shared")) + version = "3.25.1" + } + } + ndk { + // noinspection ChromeOsAbiSupport + abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86_64")) + } + vectorDrawables.useSupportLibrary = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.7" + } + splits { + abi { + reset() + } + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { + viewBinding = true + //noinspection DataBindingWithoutKapt + dataBinding = true + buildConfig = true + compose = true + } + sourceSets { + getByName("main").java.srcDirs("src/main/kotlin") + } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } + namespace = "com.tutpro.baresip" +} + +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + // stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf") +} + +dependencies { + + implementation(libs.androidx.foundation.android) + implementation(libs.androidx.tools.core) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.ui.test.android) + implementation(libs.androidx.foundation.android) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.material3) + val composeBom = libs.androidx.compose.bom + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.foundation) + implementation(libs.androidx.ui) + + implementation(fileTree(mapOf("include" to listOf("*.jar"), "dir" to "libs"))) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) + implementation(libs.kotlin.stdlib.jdk8) + implementation(libs.material) + implementation(libs.androidx.cardview) + implementation(libs.androidx.localbroadcastmanager) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.lifecycle.extensions) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.media) + implementation(libs.coil.compose) + + implementation(libs.ui.tooling.preview) + debugImplementation(libs.ui.tooling) + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5d209e7e..fd790d51 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,7 +45,7 @@ android:label="@string/app_name" android:supportsRtl="true" android:largeHeap="true" - android:theme="@style/AppTheme" + android:theme="@style/Theme.Material3.DayNight.NoActionBar" tools:remove="android:appComponentFactory" > @@ -78,7 +77,7 @@ @@ -86,7 +85,7 @@ @@ -94,6 +93,7 @@ android:name=".CodecsActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/codecs" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".AccountActivity" > @@ -101,12 +101,14 @@ android:name=".ContactsActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/contacts" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".MainActivity" > @@ -115,6 +117,7 @@ android:name=".AndroidContactActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/contact" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".ContactsActivity" > @@ -122,6 +125,7 @@ android:name=".ConfigActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/configuration" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".MainActivity" > @@ -129,6 +133,7 @@ android:name=".AudioActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/audio_settings" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".ConfigActivity" > @@ -136,6 +141,7 @@ android:name=".CallsActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/call_history" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".MainActivity" > @@ -143,19 +149,21 @@ android:name=".CallDetailsActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/call_details" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".CallsActivity" > @@ -164,6 +172,7 @@ android:name=".AboutActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:label="@string/about_title" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".MainActivity" > diff --git a/app/src/main/cpp/baresip.c b/app/src/main/cpp/baresip.c index b62ddd48..6661fe60 100644 --- a/app/src/main/cpp/baresip.c +++ b/app/src/main/cpp/baresip.c @@ -1799,10 +1799,11 @@ JNIEXPORT void JNICALL Java_com_tutpro_baresip_Api_module_1unload( (*env)->ReleaseStringUTFChars(env, javaModule, native_module); } +AAudioStream *AAudio_stream = NULL; + JNIEXPORT jint JNICALL -Java_com_tutpro_baresip_Api_create_1AAudio_1SessionId(JNIEnv *env, jobject obj) { +Java_com_tutpro_baresip_Api_AAudio_1open_1stream(JNIEnv *env, jobject obj) { AAudioStreamBuilder *builder = NULL; - AAudioStream *stream = NULL; jint sessionId = -1; if (AAudio_createStreamBuilder(&builder) != AAUDIO_OK) { @@ -1819,15 +1820,22 @@ Java_com_tutpro_baresip_Api_create_1AAudio_1SessionId(JNIEnv *env, jobject obj) AAudioStreamBuilder_setChannelCount(builder, 1); AAudioStreamBuilder_setSessionId(builder, AAUDIO_SESSION_ID_ALLOCATE); - if (AAudioStreamBuilder_openStream(builder, &stream) == AAUDIO_OK) { - sessionId = AAudioStream_getSessionId(stream); - struct timespec td = { - .tv_nsec = 100*1000*1000 /* 100ms */ - }; - nanosleep(&td, NULL); - AAudioStream_close(stream); + if (AAudioStreamBuilder_openStream(builder, &AAudio_stream) == AAUDIO_OK) + sessionId = AAudioStream_getSessionId(AAudio_stream); + else { + LOGE("Failed to open AAudio stream\n"); + AAudio_stream = NULL; } - AAudioStreamBuilder_delete(builder); + return sessionId; } + +JNIEXPORT void JNICALL +Java_com_tutpro_baresip_Api_AAudio_1close_1stream(JNIEnv *env, jobject obj) +{ + if (AAudio_stream != NULL) { + AAudioStream_close(AAudio_stream); + AAudio_stream = NULL; + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AboutActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AboutActivity.kt index dd599f60..6b12e34b 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/AboutActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/AboutActivity.kt @@ -1,25 +1,43 @@ package com.tutpro.baresip -import android.app.Activity import android.content.Intent -import android.os.Build import android.os.Bundle -import androidx.core.text.HtmlCompat -import androidx.appcompat.app.AppCompatActivity import android.text.method.LinkMovementMethod import android.view.MenuItem -import android.view.View +import android.widget.TextView +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding -import com.tutpro.baresip.databinding.ActivityAboutBinding +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat -class AboutActivity : AppCompatActivity() { - - private lateinit var binding: ActivityAboutBinding +class AboutActivity : ComponentActivity() { private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -30,31 +48,78 @@ class AboutActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityAboutBinding.inflate(layoutInflater) - setContentView(binding.root) - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.aboutText.updatePadding(top = 172) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true + enableEdgeToEdge() Utils.addActivity("about") - val text = String.format(getString(R.string.about_text), - BuildConfig.VERSION_NAME) - binding.aboutText.text = HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY) - binding.aboutText.movementMethod = LinkMovementMethod.getInstance() + val aboutTitle = String.format(getString(R.string.about_title)) + val aboutText = String.format(getString(R.string.about_text), + BuildConfig.VERSION_NAME) + + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AboutContent(aboutTitle, aboutText) { goBack() } + } + } + } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun AboutContent(title: String, text: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier.safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + TopAppBar( + title = { + Text(text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Localized description", + tint = LocalCustomColors.current.light + ) + } + }, + ) + + } + ) { contentPadding -> + Text( + text = AnnotatedString.Companion.fromHtml( + htmlString = text, + linkStyles = TextLinkStyles( + style = SpanStyle(color = LocalCustomColors.current.accent) + ) + ), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { @@ -67,9 +132,8 @@ class AboutActivity : AppCompatActivity() { private fun goBack() { BaresipService.activities.remove("about") - setResult(Activity.RESULT_CANCELED, Intent()) + setResult(RESULT_CANCELED, Intent()) finish() } } - diff --git a/app/src/main/kotlin/com/tutpro/baresip/Account.kt b/app/src/main/kotlin/com/tutpro/baresip/Account.kt index 496c1c9f..55f04e6a 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Account.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Account.kt @@ -1,6 +1,8 @@ package com.tutpro.baresip import android.content.Context +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import java.util.* import kotlin.collections.ArrayList import java.net.URLEncoder @@ -8,10 +10,10 @@ import java.net.URLDecoder class Account(val accp: Long) { - var nickName = "" + val nickName: MutableState = mutableStateOf("") var displayName = Api.account_display_name(accp) val aor = Api.account_aor(accp) - var luri = Api.account_luri(accp) + private var luri = Api.account_luri(accp) var authUser = Api.account_auth_user(accp) var authPass = Api.account_auth_pass(accp) var outbound = ArrayList() @@ -68,7 +70,7 @@ class Account(val accp: Long) { val extra = Api.account_extra(accp) if (Utils.paramExists(extra, "nickname")) - nickName = Utils.paramValue(extra,"nickname") + nickName.value = Utils.paramValue(extra,"nickname") if (Utils.paramExists(extra, "regint")) configuredRegInt = Utils.paramValue(extra,"regint").toInt() callHistory = Utils.paramValue(extra,"call_history") == "" @@ -151,8 +153,8 @@ class Account(val accp: Long) { var extra = "" - if (nickName != "") - extra += ";nickname=$nickName" + if (nickName.value != "") + extra += ";nickname=${nickName.value}" if (!callHistory) extra += ";call_history=no" @@ -207,6 +209,13 @@ class Account(val accp: Long) { return aor.split("@")[1] } + fun text(): String { + return if (nickName.value != "") + nickName.value + else + aor.split(":")[1].substringBefore(";") + } + private fun removeAudioCodecsStartingWith(prefix: String) { val newCodecs = ArrayList() for (acSpec in audioCodec) @@ -229,14 +238,14 @@ class Account(val accp: Long) { fun accounts(): ArrayList { val res = ArrayList() - for (ua in BaresipService.uas) { + for (ua in BaresipService.uas.value) { res.add(ua.account) } return res } fun ofAor(aor: String): Account? { - for (ua in BaresipService.uas) + for (ua in BaresipService.uas.value) if (ua.account.aor == aor) return ua.account return null } @@ -263,8 +272,8 @@ class Account(val accp: Long) { } fun uniqueNickName(nickName: String): Boolean { - for (ua in BaresipService.uas) - if (ua.account.nickName == nickName) + for (ua in BaresipService.uas.value) + if (ua.account.nickName.value == nickName) return false return true } diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountActivity.kt index cc0353be..62f25601 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/AccountActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/AccountActivity.kt @@ -1,77 +1,121 @@ package com.tutpro.baresip -import android.app.Activity +import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.widget.* +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding -import com.tutpro.baresip.databinding.ActivityAccountBinding -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserFactory -import java.io.StringReader -import java.net.URL -import java.util.* +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +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.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tutpro.baresip.BaresipService.Companion.uas +import com.tutpro.baresip.CustomElements.Checkbox +import com.tutpro.baresip.CustomElements.verticalScrollbar -class AccountActivity : AppCompatActivity() { +class AccountActivity : ComponentActivity() { - private lateinit var binding: ActivityAccountBinding + private lateinit var mediaEncMap: Map + private lateinit var mediaNatMap: Map + private lateinit var dtmfModeMap: Map + private lateinit var answerModeMap: Map + private lateinit var redirectModeMap: Map private lateinit var acc: Account private lateinit var ua: UserAgent - private lateinit var uri: TextView - private lateinit var nickName: EditText - private lateinit var displayName: EditText private lateinit var aor: String - private lateinit var authUser: EditText - private lateinit var authPass: EditText - private lateinit var outbound: Array - private lateinit var mediaNat: String - private lateinit var stun: LinearLayout - private lateinit var stunServer: EditText - private lateinit var stunUser: EditText - private lateinit var stunPass: EditText - private lateinit var regCheck: CheckBox - private lateinit var regInt: EditText - private lateinit var mediaNatSpinner: Spinner - private lateinit var mediaEnc: String - private lateinit var mediaEncSpinner: Spinner - private lateinit var rtcpCheck: CheckBox - private lateinit var rel100Check: CheckBox - private var dtmfMode = Api.DTMFMODE_RTP_EVENT - private lateinit var dtmfModeSpinner: Spinner - private var answerMode = Api.ANSWERMODE_MANUAL - private lateinit var answerModeSpinner: Spinner - private var autoRedirect = false - private lateinit var redirectModeSpinner: Spinner - private lateinit var vmUri: EditText - private lateinit var countryCode: EditText - private lateinit var telProvider: EditText - private lateinit var defaultCheck: CheckBox - - private val mediaEncKeys = arrayListOf("zrtp", "dtls_srtp", "srtp-mandf", "srtp-mand", "srtp", "") - private val mediaEncVals = arrayListOf("ZRTP", "DTLS-SRTPF", "SRTP-MANDF", "SRTP-MAND", "SRTP", "-") - private val mediaNatKeys = arrayListOf("stun", "turn", "ice", "") - private val mediaNatVals = arrayListOf("STUN", "TURN", "ICE", "-") private var reRegister = false - private var uaIndex= -1 - - private val scope = CoroutineScope(Job() + Dispatchers.Main) + private var oldNickname = "" + private var newNickname = "" + private var oldDisplayname = "" + private var newDisplayname = "" + private var oldAuthUser = "" + private var newAuthUser = "" + private var oldAuthPass = "" + private var newAuthPass = "" + private var oldOutbound1 = "" + private var newOutbound1 = "" + private var oldOutbound2 = "" + private var newOutbound2 = "" + private var oldRegister = false + private var newRegister = false + private var oldRegInt = "" + private var newRegInt = "" + private var oldMediaEnc = "" + private var newMediaEnc = "" + private var oldMediaNat = "" + private var newMediaNat = "" + private var oldStunServer = "" + private var newStunServer = "" + private var oldStunUser = "" + private var newStunUser = "" + private var oldStunPass = "" + private var newStunPass = "" + private var oldRtcpMux = false + private var newRtcpMux = false + private var old100Rel = false + private var new100Rel = false + private var oldDtmfMode = 0 + private var newDtmfMode = 0 + private var oldAnswerMode = 0 + private var newAnswerMode = 0 + private var oldAutoRedirect = false + private var newAutoRedirect = false + private var oldVmUri = "" + private var newVmUri = "" + private var oldCountryCode = "" + private var newCountryCode = "" + private var oldTelProvider = "" + private var newTelProvider = "" + private var oldDefaultAccount = false + private var newDefaultAccount = false + private var arrowTint = Color.Unspecified private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -83,64 +127,40 @@ class AccountActivity : AppCompatActivity() { super.onCreate(savedInstanceState) - binding = ActivityAccountBinding.inflate(layoutInflater) - setContentView(binding.root) - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.AccountView.updatePadding(top = 172) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - uri = binding.Uri - nickName = binding.NickName - displayName = binding.DisplayName - authUser = binding.AuthUser - authPass = binding.AuthPass - outbound = arrayOf(binding.Outbound1, binding.Outbound2) - regCheck = binding.Register - regInt = binding.RegInt - mediaNatSpinner = binding.mediaNatSpinner - stun = binding.Stun - stunServer = binding.StunServer - stunUser = binding.StunUser - stunPass = binding.StunPass - mediaEncSpinner = binding.mediaEncSpinner - rtcpCheck = binding.RtcpMux - rel100Check = binding.Rel100 - dtmfModeSpinner = binding.dtmfModeSpinner - answerModeSpinner = binding.answerModeSpinner - redirectModeSpinner = binding.redirectModeSpinner - vmUri = binding.voicemailUri - countryCode = binding.countryCode - telProvider = binding.telephonyProvider - defaultCheck = binding.Default + enableEdgeToEdge() aor = intent.getStringExtra("aor")!! - ua = UserAgent.ofAor(aor)!! - acc = ua.account - uaIndex = UserAgent.findAorIndex(aor)!! Utils.addActivity("account,$aor") - if (intent.getBooleanExtra("new", false)) - initAccountFromConfig(this) + ua = UserAgent.ofAor(aor)!! + acc = ua.account - title = if (acc.nickName != "") - acc.nickName - else - aor.split(":")[1] + mediaEncMap = mapOf("zrtp" to "ZRTP", "dtls_srtp" to "DTLS-SRTPF", + "srtp-mand" to "SRTP-MAND", "srtp" to "SRTP", "" to "--") - initLayoutFromAccount(acc) + mediaNatMap = mapOf("stun" to "STUN", "turn" to "TURN", "ice" to "ICE", "" to "--") - bindTitles() + dtmfModeMap = mapOf(Api.DTMFMODE_RTP_EVENT to getString(R.string.dtmf_inband), + Api.DTMFMODE_SIP_INFO to getString(R.string.dtmf_info), + Api.DTMFMODE_AUTO to getString(R.string.dtmf_auto)) + + answerModeMap = mapOf(Api.ANSWERMODE_MANUAL to getString(R.string.manual), + Api.ANSWERMODE_AUTO to getString(R.string.auto)) + + redirectModeMap = mapOf(false to getString(R.string.manual), + true to getString(R.string.auto)) + + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + AccountScreen { goBack() } + } + } + } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) @@ -148,610 +168,1329 @@ class AccountActivity : AppCompatActivity() { } - private fun initAccountFromConfig(ctx: AccountActivity) { - scope.launch(Dispatchers.IO) { - val url = "https://${Utils.uriHostPart(aor)}/baresip/account_config.xml" - val config = try { - URL(url).readText() - } catch (e: java.lang.Exception) { - Log.d(TAG, "Failed to get account configuration from network") - null - } - if (config != null && !ctx.isFinishing) { - Log.d(TAG, "Got account config '$config'") - val acc = Account(acc.accp) - val parserFactory: XmlPullParserFactory = XmlPullParserFactory.newInstance() - val parser: XmlPullParser = parserFactory.newPullParser() - parser.setInput(StringReader(config)) - var tag: String? - var text = "" - var event = parser.eventType - val audioCodecs = ArrayList(Api.audio_codecs().split(",")) - val videoCodecs = ArrayList(Api.video_codecs().split(",")) - while (event != XmlPullParser.END_DOCUMENT) { - tag = parser.name - when (event) { - XmlPullParser.TEXT -> - text = parser.text - XmlPullParser.START_TAG -> { - if (tag == "audio-codecs") - acc.audioCodec.clear() - if (tag == "video-codecs") - acc.videoCodec.clear() - } - XmlPullParser.END_TAG -> - when (tag) { - "outbound-proxy-1" -> - if (text.isNotEmpty()) - acc.outbound.add(text) - "outbound-proxy-2" -> - if (text.isNotEmpty()) - acc.outbound.add(text) - "registration-interval" -> - acc.configuredRegInt = text.toInt() - "register" -> - acc.regint = if (text == "yes") acc.configuredRegInt else 0 - "audio-codec" -> - if (text in audioCodecs) - acc.audioCodec.add(text) - "video-codec" -> - if (text in videoCodecs) - acc.videoCodec.add(text) - "media-encoding" -> { - val enc = text.lowercase(Locale.ROOT) - if (enc in mediaEncKeys && enc.isNotEmpty()) - acc.mediaEnc = enc - } - "media-nat" -> { - val nat = text.lowercase(Locale.ROOT) - if (nat in mediaNatKeys && nat.isNotEmpty()) - acc.mediaNat = nat - } - "stun-turn-server" -> - if (text.isNotEmpty()) - acc.stunServer = text - "rtcp-mux" -> - acc.rtcpMux = text == "yes" - "100rel-mode" -> - acc.rel100Mode = if (text == "yes") - Api.REL100_ENABLED - else - Api.REL100_DISABLED - "dtmf-mode" -> - if (text in arrayOf("rtp-event", "sip-info", "auto")) - acc.dtmfMode = when (text) { - "rtp-event" -> Api.DTMFMODE_RTP_EVENT - "sip-info" -> Api.DTMFMODE_SIP_INFO - else -> Api.DTMFMODE_AUTO - } - "answer-mode" -> - if (text in arrayOf("manual", "auto")) - acc.answerMode = if (text == "manual") - Api.ANSWERMODE_MANUAL - else - Api.ANSWERMODE_AUTO - "redirect-mode" -> - acc.autoRedirect = text == "yes" - "voicemail-uri" -> - if (text.isNotEmpty()) - acc.vmUri = text - "country-code" -> - acc.countryCode = text - "tel-provider" -> - acc.telProvider = text - } - } - event = parser.next() - } - runOnUiThread { - initLayoutFromAccount(acc) - } - } - } - } - - private fun initLayoutFromAccount(acc: Account) { - - uri.text = acc.luri - nickName.setText(acc.nickName) - displayName.setText(acc.displayName) - authUser.setText(acc.authUser) - - if (BaresipService.aorPasswords[aor] != null || acc.authPass == NO_AUTH_PASS) - authPass.setText("") + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun AccountScreen(navigateBack: () -> Unit) { + val title = if (acc.nickName.value != "") + acc.nickName.value else - authPass.setText(acc.authPass) - - if (acc.outbound.size > 0) { - outbound[0].setText(acc.outbound[0]) - if (acc.outbound.size > 1) - outbound[1].setText(acc.outbound[1]) - } - - regCheck.isChecked = acc.regint > 0 - regInt.setText(acc.configuredRegInt.toString()) - - this.acc.audioCodec = acc.audioCodec - - mediaNat = acc.mediaNat - var keyIx = mediaNatKeys.indexOf(acc.mediaNat) - var keyVal = mediaNatVals.elementAt(keyIx) - mediaNatKeys.removeAt(keyIx) - mediaNatVals.removeAt(keyIx) - mediaNatKeys.add(0, acc.mediaNat) - mediaNatVals.add(0, keyVal) - val mediaNatAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, - mediaNatVals) - mediaNatAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - mediaNatSpinner.adapter = mediaNatAdapter - mediaNatSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - mediaNat = mediaNatKeys[mediaNatVals.indexOf(parent.selectedItem.toString())] - if ((mediaNat == "turn") && stunServer.text.startsWith("stun")) - stunServer.setText("") - else if ((mediaNat == "stun") && - (stunServer.text.startsWith("turn") || (stunServer.text.toString() == ""))) - stunServer.setText(resources.getString(R.string.stun_server_default)) - else if ((mediaNat == "ice") && (stunServer.text.toString() == "")) - stunServer.setText(resources.getString(R.string.stun_server_default)) - if (mediaNat == "") { - stun.visibility = GONE - stunServer.setText("") - stunUser.setText("") - stunPass.setText("") - } else - stun.visibility = VISIBLE + acc.aor.substringAfter(":") + Scaffold( + modifier = Modifier.safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton(onClick = { + updateAccount() + }) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + } + ) + }, + content = { contentPadding -> + AccountContent(this, contentPadding) } - override fun onNothingSelected(parent: AdapterView<*>) { - } - } - - stunServer.setText(acc.stunServer) - stunUser.setText(acc.stunUser) - stunPass.setText(acc.stunPass) - - mediaEnc = acc.mediaEnc - keyIx = mediaEncKeys.indexOf(mediaEnc) - keyVal = mediaEncVals.elementAt(keyIx) - mediaEncKeys.removeAt(keyIx) - mediaEncVals.removeAt(keyIx) - mediaEncKeys.add(0, mediaEnc) - mediaEncVals.add(0, keyVal) - val mediaEncAdapter = ArrayAdapter(this,android.R.layout.simple_spinner_item, - mediaEncVals) - mediaEncAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - mediaEncSpinner.adapter = mediaEncAdapter - mediaEncSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - mediaEnc = mediaEncKeys[mediaEncVals.indexOf(parent.selectedItem.toString())] - } - override fun onNothingSelected(parent: AdapterView<*>) { - } - } - - rtcpCheck.isChecked = acc.rtcpMux - - rel100Check.isChecked = acc.rel100Mode == Api.REL100_ENABLED - - dtmfMode = acc.dtmfMode - val dtmfModeKeys = arrayListOf(Api.DTMFMODE_RTP_EVENT, Api.DTMFMODE_SIP_INFO, Api.DTMFMODE_AUTO) - val dtmfModeVals = arrayListOf(getString(R.string.dtmf_inband), getString(R.string.dtmf_info), getString(R.string.dtmf_auto)) - keyIx = dtmfModeKeys.indexOf(acc.dtmfMode) - keyVal = dtmfModeVals.elementAt(keyIx) - dtmfModeKeys.removeAt(keyIx) - dtmfModeVals.removeAt(keyIx) - dtmfModeKeys.add(0, acc.dtmfMode) - dtmfModeVals.add(0, keyVal) - val dtmfModeAdapter = ArrayAdapter(this,android.R.layout.simple_spinner_item, - dtmfModeVals) - dtmfModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - dtmfModeSpinner.adapter = dtmfModeAdapter - dtmfModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - dtmfMode = dtmfModeKeys[dtmfModeVals.indexOf(parent.selectedItem.toString())] - } - override fun onNothingSelected(parent: AdapterView<*>) { - } - } - - answerMode = acc.answerMode - val answerModeKeys = arrayListOf(Api.ANSWERMODE_MANUAL, Api.ANSWERMODE_AUTO) - val answerModeVals = arrayListOf(getString(R.string.manual), getString(R.string.auto)) - keyIx = answerModeKeys.indexOf(acc.answerMode) - keyVal = answerModeVals.elementAt(keyIx) - answerModeKeys.removeAt(keyIx) - answerModeVals.removeAt(keyIx) - answerModeKeys.add(0, acc.answerMode) - answerModeVals.add(0, keyVal) - val answerModeAdapter = ArrayAdapter(this,android.R.layout.simple_spinner_item, - answerModeVals) - answerModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - answerModeSpinner.adapter = answerModeAdapter - answerModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - answerMode = answerModeKeys[answerModeVals.indexOf(parent.selectedItem.toString())] - } - override fun onNothingSelected(parent: AdapterView<*>) { - } - } - - autoRedirect = acc.autoRedirect - val redirectModeKeys = arrayListOf(false, true) - val redirectModeVals = arrayListOf(getString(R.string.manual), getString(R.string.auto)) - keyIx = redirectModeKeys.indexOf(acc.autoRedirect) - keyVal = redirectModeVals.elementAt(keyIx) - redirectModeKeys.removeAt(keyIx) - redirectModeVals.removeAt(keyIx) - redirectModeKeys.add(0, acc.autoRedirect) - redirectModeVals.add(0, keyVal) - val redirectModeAdapter = ArrayAdapter(this,android.R.layout.simple_spinner_item, - redirectModeVals) - redirectModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - redirectModeSpinner.adapter = redirectModeAdapter - redirectModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - autoRedirect = redirectModeKeys[redirectModeVals.indexOf(parent.selectedItem.toString())] - } - override fun onNothingSelected(parent: AdapterView<*>) { - } - } - - if (acc.countryCode != "") - countryCode.setText(acc.countryCode) - - telProvider.setText(acc.telProvider) - - vmUri.setText(acc.vmUri) - - defaultCheck.isChecked = uaIndex == 0 + ) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - - super.onCreateOptionsMenu(menu) - val inflater = menuInflater - inflater.inflate(R.menu.check_icon, menu) - return true + @Composable + fun AccountContent(ctx: Context, contentPadding: PaddingValues) { + arrowTint = if (BaresipService.darkTheme.value) + LocalCustomColors.current.grayLight + else + LocalCustomColors.current.black + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .padding(contentPadding) + .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 4.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + oldNickname = acc.nickName.value + oldDisplayname = acc.displayName + oldAuthUser = acc.authUser + if (BaresipService.aorPasswords[aor] == null && // check if OK + acc.authPass != NO_AUTH_PASS) + oldAuthPass = acc.authPass + if (acc.outbound.size > 0) { + oldOutbound1 = acc.outbound[0] + if (acc.outbound.size > 1) + oldOutbound2 = acc.outbound[1] + } + oldRegister = acc.regint > 0 + oldRegInt = acc.configuredRegInt.toString() + oldMediaEnc = acc.mediaEnc + oldMediaNat = acc.mediaNat + oldStunServer = acc.stunServer + oldStunUser = acc.stunUser + oldStunPass = acc.stunPass + oldRtcpMux = acc.rtcpMux + old100Rel = acc.rel100Mode == Api.REL100_ENABLED + oldDtmfMode = acc.dtmfMode + oldAnswerMode = acc.answerMode + oldAutoRedirect = acc.autoRedirect + oldVmUri = acc.vmUri + oldCountryCode = acc.countryCode + oldTelProvider = acc.telProvider + oldDefaultAccount = UserAgent.findAorIndex(aor)!! == 0 + item { AoR() } + item { Nickname(ctx) } + item { Displayname(ctx) } + item { AuthUser(ctx) } + item { AuthPass(ctx) } + item { Outbound(ctx) } + item { Register(ctx) } + item { RegInt(ctx) } + item { AudioCodecs(ctx) } + item { MediaEnc(ctx) } + item { MediaNat(ctx) } + item { StunServer(ctx) } + item { StunUser(ctx) } + item { StunPass(ctx) } + item { RtcpMux() } + item { Rel100() } + item { Dtmf(ctx) } + item { Answer(ctx) } + item { Redirect(ctx) } + item { Voicemail(ctx) } + item { CountryCode(ctx) } + item { TelProvider(ctx) } + item { DefaultAccount() } + } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + @Composable + private fun AoR() { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + OutlinedTextField( + value = aor, + enabled = false, + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.itemText + ), + label = { Text(stringResource(R.string.sip_uri)) } + ) + } + } + + @Composable + private fun Nickname(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var nickName by remember { mutableStateOf(oldNickname) } + newNickname = nickName + OutlinedTextField( + value = nickName, + placeholder = { Text(stringResource(R.string.nickname)) }, + onValueChange = { + nickName = it + newNickname = nickName + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.nickname), + getString(R.string.account_nickname_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.nickname)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text), + ) + } + } + + @Composable + private fun Displayname(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var displayName by remember { mutableStateOf(oldDisplayname) } + newDisplayname = displayName + OutlinedTextField( + value = displayName, + placeholder = { Text(stringResource(R.string.display_name)) }, + onValueChange = { + displayName = it + newDisplayname = displayName + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.display_name), + getString(R.string.display_name_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.display_name)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text), + ) + } + } + + @Composable + private fun AuthUser(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var authUser by remember { mutableStateOf(oldAuthUser) } + newAuthUser = authUser + OutlinedTextField( + value = authUser, + placeholder = { Text(stringResource(R.string.authentication_username)) }, + onValueChange = { + authUser = it + newAuthUser = authUser + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.authentication_username), + getString(R.string.authentication_username_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.authentication_username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun AuthPass(ctx: Context) { + val showPassword = remember { mutableStateOf(false) } + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var authPass by remember { mutableStateOf(oldAuthPass) } + newAuthPass = authPass + OutlinedTextField( + value = authPass, + placeholder = { Text(stringResource(R.string.authentication_password)) }, + onValueChange = { + authPass = it + newAuthPass = authPass + }, + singleLine = true, + visualTransformation = if (showPassword.value) + VisualTransformation.None + else + PasswordVisualTransformation(), + trailingIcon = { + val (icon, iconColor) = if (showPassword.value) { + Pair( + ImageVector.vectorResource(R.drawable.visibility), + colorResource(id = R.color.colorAccent) + ) + } else { + Pair( + ImageVector.vectorResource(R.drawable.visibility_off), + colorResource(id = R.color.colorWhite)) + } + IconButton(onClick = { showPassword.value = !showPassword.value }) { + Icon( + icon, + contentDescription = "Visibility", + tint = iconColor + ) + } + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.authentication_password), + getString(R.string.authentication_password_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.authentication_password)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun Outbound(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.outbound_proxies), + color = LocalCustomColors.current.itemText, + modifier = Modifier.clickable { + Utils.alertView( + ctx, getString(R.string.outbound_proxies), + getString(R.string.outbound_proxies_help) + ) + }) + } + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var outbound1 by remember { mutableStateOf(oldOutbound1) } + newOutbound1 = outbound1 + OutlinedTextField( + value = outbound1, + placeholder = { Text(stringResource(R.string.sip_uri_of_proxy_server)) }, + onValueChange = { + outbound1 = it + newOutbound1 = outbound1 + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.sip_uri_of_proxy_server)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var outbound2 by remember { mutableStateOf(oldOutbound2) } + newOutbound2 = outbound2 + OutlinedTextField( + value = outbound2, + placeholder = { Text(stringResource(R.string.sip_uri_of_another_proxy_server)) }, + onValueChange = { + outbound2 = it + newOutbound2 = outbound2 + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.sip_uri_of_another_proxy_server)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + fun Register(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.register), + modifier = Modifier.weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.register), getString(R.string.register_help) + ) + }, + color = LocalCustomColors.current.itemText) + var register by remember { mutableStateOf(oldRegister) } + newRegister = register + Checkbox( + checked = register, + onCheckedChange = { + register = it + newRegister = register + } + ) + } + } + + @Composable + private fun RegInt(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var regInt by remember { mutableStateOf(oldRegInt) } + newRegInt = regInt + OutlinedTextField( + value = regInt, + placeholder = { Text(stringResource(R.string.reg_int)) }, + onValueChange = { + regInt = it + newRegInt = regInt + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.reg_int), + getString(R.string.reg_int_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.reg_int)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } + } + + @Composable + private fun AudioCodecs(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(top=12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.audio_codecs), + modifier = Modifier.weight(1f) + .clickable { + val i = Intent(ctx, CodecsActivity::class.java) + val b = Bundle() + b.putString("aor", aor) + b.putString("media", "audio") + i.putExtras(b) + startActivity(i) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + fontWeight = FontWeight. Bold + ) + } + } + + @Composable + private fun MediaEnc(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.media_encryption), + modifier = Modifier.weight(1f) + .clickable { + Utils.alertView(ctx, getString(R.string.media_encryption), + getString(R.string.media_encryption_help)) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { mutableStateOf(false) } + val mediaEnc = remember { mutableStateOf(oldMediaEnc) } + newMediaEnc = mediaEnc.value + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = mediaEncMap[mediaEnc.value]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + mediaEncMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value, + color = LocalCustomColors.current.itemText) + }, + onClick = { + isDropDownExpanded.value = false + mediaEnc.value = it.key + newMediaEnc = mediaEnc.value + }) + if (index < 4) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } + } + + @Composable + private fun MediaNat(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.media_nat), + modifier = Modifier.weight(1f) + .clickable { + Utils.alertView(ctx, getString(R.string.media_nat), + getString(R.string.media_nat_help)) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { mutableStateOf(false) } + val mediaNat = remember { mutableStateOf(oldMediaNat) } + newMediaNat = mediaNat.value + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = mediaNatMap[mediaNat.value]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + mediaNatMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value) + }, + onClick = { + isDropDownExpanded.value = false + mediaNat.value = it.key + newMediaNat = mediaNat.value + //if (newMediaNat == "") + // don't show stunserver + //else + // show stunserver + }) + if (index < 3) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } + } + + @Composable + private fun StunServer(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var stunServer by remember { mutableStateOf(oldStunServer) } + newStunServer = stunServer + OutlinedTextField( + value = stunServer, + placeholder = { Text(stringResource(R.string.stun_server)) }, + onValueChange = { + stunServer = it + newStunServer = stunServer + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.stun_server), + getString(R.string.stun_server_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.stun_server)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun StunUser(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var stunUser by remember { mutableStateOf(oldStunUser) } + newStunUser = stunUser + OutlinedTextField( + value = stunUser, + placeholder = { Text(stringResource(R.string.stun_username)) }, + onValueChange = { + stunUser = it + newStunUser = stunUser + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.stun_username), + getString(R.string.stun_username_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.stun_username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun StunPass(ctx: Context) { + val showPassword = remember { mutableStateOf(false) } + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var stunPass by remember { mutableStateOf(oldStunPass) } + newStunPass = stunPass + OutlinedTextField( + value = stunPass, + placeholder = { Text(stringResource(R.string.stun_password)) }, + onValueChange = { + stunPass = it + newStunPass = stunPass + }, + singleLine = true, + visualTransformation = if (showPassword.value) + VisualTransformation.None + else + PasswordVisualTransformation(), + trailingIcon = { + val (icon, iconColor) = if (showPassword.value) { + Pair( + ImageVector.vectorResource(R.drawable.visibility), + colorResource(id = R.color.colorAccent) + ) + } else { + Pair( + ImageVector.vectorResource(R.drawable.visibility_off), + colorResource(id = R.color.colorWhite)) + } + IconButton(onClick = { showPassword.value = !showPassword.value }) { + Icon( + icon, + contentDescription = "Visibility", + tint = iconColor + ) + } + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.stun_password), + getString(R.string.stun_password_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.stun_password)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + fun RtcpMux() { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.rtcp_mux), + modifier = Modifier.weight(1f), + color = LocalCustomColors.current.itemText) + var rtcpMux by remember { mutableStateOf(oldRtcpMux) } + newRtcpMux = rtcpMux + Checkbox( + checked = rtcpMux, + onCheckedChange = { + rtcpMux = it + newRtcpMux = rtcpMux + } + ) + } + } + + @Composable + fun Rel100() { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.rtcp_mux), + modifier = Modifier.weight(1f), + color = LocalCustomColors.current.itemText) + var rel100 by remember { mutableStateOf(old100Rel) } + new100Rel = rel100 + Checkbox( + checked = rel100, + onCheckedChange = { + rel100 = it + new100Rel = rel100 + } + ) + } + } + + @Composable + private fun Dtmf(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(top=12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.dtmf_mode), + modifier = Modifier.weight(1f) + .clickable { + Utils.alertView(ctx, getString(R.string.dtmf_mode), + getString(R.string.dtmf_mode_help)) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val dtmfMode = remember { mutableIntStateOf(oldDtmfMode) } + newDtmfMode = dtmfMode.intValue + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = dtmfModeMap[dtmfMode.intValue]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + dtmfModeMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value) + }, + onClick = { + isDropDownExpanded.value = false + dtmfMode.intValue = it.key + newDtmfMode = dtmfMode.intValue + }) + if (index < 2) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } + } + + @Composable + private fun Answer(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(top=12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.answer_mode), + modifier = Modifier.weight(1f) + .clickable { + Utils.alertView(ctx, getString(R.string.answer_mode), + getString(R.string.answer_mode_help)) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val answerMode = remember { mutableIntStateOf(oldAnswerMode) } + newAnswerMode = answerMode.intValue + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = answerModeMap[answerMode.intValue]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + answerModeMap.forEach { + DropdownMenuItem(text = { Text(text = it.value) }, + onClick = { + isDropDownExpanded.value = false + answerMode.intValue = it.key + newAnswerMode = answerMode.intValue + }) + if (index < 1) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } + } + + @Composable + private fun Redirect(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(top=12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.redirect_mode), + modifier = Modifier.weight(1f) + .clickable { + Utils.alertView(ctx, getString(R.string.redirect_mode), + getString(R.string.redirect_mode_help)) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val autoRedirect = remember { mutableStateOf(oldAutoRedirect) } + newAutoRedirect = autoRedirect.value + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = redirectModeMap[autoRedirect.value]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + redirectModeMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value) + }, + onClick = { + isDropDownExpanded.value = false + autoRedirect.value = it.key + newAutoRedirect = autoRedirect.value + }) + if (index < 1) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } + } + + @Composable + private fun Voicemail(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var vmUri by remember { mutableStateOf(oldVmUri) } + newVmUri = vmUri + OutlinedTextField( + value = vmUri, + placeholder = { Text(stringResource(R.string.voicemail_uri)) }, + onValueChange = { + vmUri = it + newVmUri = vmUri + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.voicemail_uri), + getString(R.string.voicemain_uri_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.voicemail_uri)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun CountryCode(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var countryCode by remember { mutableStateOf(oldCountryCode) } + newCountryCode = countryCode + OutlinedTextField( + value = countryCode, + placeholder = { Text(stringResource(R.string.country_code)) }, + onValueChange = { + countryCode = it + newCountryCode = countryCode + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.country_code), + getString(R.string.country_code_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.country_code)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun TelProvider(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var telProvider by remember { mutableStateOf(oldTelProvider) } + newTelProvider = telProvider + OutlinedTextField( + value = telProvider, + placeholder = { Text(stringResource(R.string.telephony_provider)) }, + onValueChange = { + telProvider = it + newTelProvider = telProvider + }, + modifier = Modifier.fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.telephony_provider), + getString(R.string.telephony_provider_help)) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.telephony_provider)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + fun DefaultAccount() { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.default_account), + modifier = Modifier.weight(1f), + color = LocalCustomColors.current.itemText) + var defaultAccount by remember { mutableStateOf(oldDefaultAccount) } + newDefaultAccount = defaultAccount + Checkbox( + checked = defaultAccount, + onCheckedChange = { + defaultAccount = it + newDefaultAccount = defaultAccount + } + ) + } + } + + private fun updateAccount() { if (BaresipService.activities.indexOf("account,$aor") == -1) - return true + return - when (item.itemId) { - - R.id.checkIcon -> { - - val nn = nickName.text.toString().trim() - if (nn != acc.nickName) { - if (Account.checkDisplayName(nn)) { - if (nn == "" || Account.uniqueNickName(nn)) { - acc.nickName = nn - Log.d(TAG, "New nickname is ${acc.nickName}") - } else { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.non_unique_account_nickname), nn)) - return false - } - } else { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_account_nickname), nn)) - return false - } - } - - val dn = displayName.text.toString().trim() - if (dn != acc.displayName) { - if (Account.checkDisplayName(dn)) { - if (Api.account_set_display_name(acc.accp, dn) == 0) { - acc.displayName = Api.account_display_name(acc.accp) - Log.d(TAG, "New display name is ${acc.displayName}") - } else { - Log.e(TAG, "Setting of display name failed") - } - } else { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_display_name), dn)) - return false - } - } - - val au = authUser.text.toString().trim() - val ap = authPass.text.toString().trim() - - if (au != acc.authUser) { - if (Account.checkAuthUser(au)) { - if (Api.account_set_auth_user(acc.accp, au) == 0) { - acc.authUser = Api.account_auth_user(acc.accp) - Log.d(TAG, "New auth user is ${acc.authUser}") - if (acc.regint > 0) - reRegister = true - } else { - Log.e(TAG, "Setting of auth user failed") - } - } else { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_authentication_username), au)) - return false - } - } - - if (ap != "") { - if (ap != acc.authPass) { - if (Account.checkAuthPass(ap)) { - if (Api.account_set_auth_pass(acc.accp, ap) == 0) { - acc.authPass = Api.account_auth_pass(acc.accp) - if (acc.regint > 0) - reRegister = true - } else { - Log.e(TAG, "Setting of auth pass failed") - } - BaresipService.aorPasswords.remove(acc.aor) - } else { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_authentication_password), ap)) - return false - } - } else { - BaresipService.aorPasswords.remove(acc.aor) - } - } else { // ap == "" - if (acc.authPass != NO_AUTH_PASS && - acc.authPass != BaresipService.aorPasswords[acc.aor]) - if (Api.account_set_auth_pass(acc.accp, "") == 0) { - acc.authPass = NO_AUTH_PASS - BaresipService.aorPasswords[aor] = NO_AUTH_PASS - } - } - - val ob = ArrayList() - for (i in 0..1) { - var uri: String - if (outbound[i].text.toString().trim() != "") { - uri = outbound[i].text.toString().trim().replace(" ", "") - if (!uri.startsWith("sip:")) - uri = "sip:$uri" - if (checkOutboundUri(uri)) { - ob.add(uri) - } else { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_proxy_server_uri), uri)) - return false - } - } - } - if (ob != acc.outbound) { - for (i in 0..1) { - val uri = if (ob.size > i) - ob[i] - else - "" - if (Api.account_set_outbound(acc.accp, uri, i) != 0) - Log.e(TAG, "Setting of outbound proxy $i uri '$uri' failed") - } - Log.d(TAG, "New outbound proxies are $ob") - acc.outbound = ob - if (ob.isEmpty()) - Api.account_set_sipnat(acc.accp, "") - else - Api.account_set_sipnat(acc.accp, "outbound") - if (acc.regint > 0) - reRegister = true - } - - val newConfiguredRegInt = regInt.text.toString().trim().toInt() - if (newConfiguredRegInt < 60 || newConfiguredRegInt > 3600) { - Utils.alertView( - this, getString(R.string.notice), - String.format(getString(R.string.invalid_reg_int), "$newConfiguredRegInt") - ) - return false - } - val reReg = (regCheck.isChecked != acc.regint > 0) || - (regCheck.isChecked && newConfiguredRegInt != acc.configuredRegInt) - if (reReg) { - if (Api.account_set_regint(acc.accp, - if (regCheck.isChecked) newConfiguredRegInt else 0) != 0) { - Log.e(TAG, "Setting of regint failed") - } else { - acc.regint = Api.account_regint(acc.accp) - acc.configuredRegInt = newConfiguredRegInt - Log.d(TAG, "New regint is ${acc.regint}") - reRegister = true - } + val nn = newNickname.trim() + if (nn != oldNickname) { + if (Account.checkDisplayName(nn)) { + if (nn == "" || Account.uniqueNickName(nn)) { + acc.nickName.value = nn + Log.d(TAG, "New nickname is ${acc.nickName.value}") } else { - if (newConfiguredRegInt != acc.configuredRegInt) { - acc.configuredRegInt = newConfiguredRegInt - } + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.non_unique_account_nickname), nn)) + return } - - if (mediaNat != acc.mediaNat) { - if (Api.account_set_medianat(acc.accp, mediaNat) == 0) { - acc.mediaNat = Api.account_medianat(acc.accp) - Log.d(TAG, "New medianat is ${acc.mediaNat}") - } else { - Log.e(TAG, "Setting of medianat failed") - } - } - - var newStunServer = stunServer.text.toString().trim() - if (mediaNat != "") { - if (((mediaNat == "stun") || (mediaNat == "ice")) && (newStunServer == "")) - newStunServer = resources.getString(R.string.stun_server_default) - if (!Utils.checkStunUri(newStunServer) || - (mediaNat == "turn" && - newStunServer.substringBefore(":") !in setOf("turn", "turns"))) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_stun_server), newStunServer)) - return false - } - } - - if (acc.stunServer != newStunServer) { - if (Api.account_set_stun_uri(acc.accp, newStunServer) == 0) { - acc.stunServer = Api.account_stun_uri(acc.accp) - Log.d(TAG, "New STUN/TURN server URI is '${acc.stunServer}'") - } else { - Log.e(TAG, "Setting of STUN/TURN URI server failed") - } - } - - val newStunUser = stunUser.text.toString().trim() - if (acc.stunUser != newStunUser) { - if (Account.checkAuthUser(newStunUser)) { - if (Api.account_set_stun_user(acc.accp, newStunUser) == 0) { - acc.stunUser = Api.account_stun_user(acc.accp) - Log.d(TAG, "New STUN/TURN user is ${acc.stunUser}") - } else { - Log.e(TAG, "Setting of STUN/TURN user failed") - } - } else { - Utils.alertView(this, getString(R.string.notice), String.format(getString(R.string.invalid_stun_username), - newStunUser)) - return false - } - } - - val newStunPass = stunPass.text.toString().trim() - if (acc.stunPass != newStunPass) { - if (newStunPass.isEmpty() || Account.checkAuthPass(newStunPass)) { - if (Api.account_set_stun_pass(acc.accp, newStunPass) == 0) { - acc.stunPass = Api.account_stun_pass(acc.accp) - } else { - Log.e(TAG, "Setting of stun pass failed") - } - } else { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_stun_password), newStunPass)) - return false - } - } - - if (mediaEnc != acc.mediaEnc) { - if (Api.account_set_mediaenc(acc.accp, mediaEnc) == 0) { - acc.mediaEnc = Api.account_mediaenc(acc.accp) - Log.d(TAG, "New mediaenc is ${acc.mediaEnc}") - } else { - Log.e(TAG, "Setting of mediaenc $mediaEnc failed") - } - } - - if (rtcpCheck.isChecked != acc.rtcpMux) - if (Api.account_set_rtcp_mux(acc.accp, rtcpCheck.isChecked) == 0) { - acc.rtcpMux = Api.account_rtcp_mux(acc.accp) - Log.d(TAG, "New rtcpMux is ${acc.rtcpMux}") - } else { - Log.e(TAG, "Setting of account_rtc_mux failed") - } - - if (rel100Check.isChecked != (acc.rel100Mode == Api.REL100_ENABLED)) { - val mode = if (rel100Check.isChecked) Api.REL100_ENABLED else Api.REL100_DISABLED - if (Api.account_set_rel100_mode(acc.accp, mode) == 0) { - acc.rel100Mode = Api.account_rel100_mode(acc.accp) - Api.ua_update_account(ua.uap) - Log.d(TAG, "New rel100Mode is ${acc.rel100Mode}") - } else { - Log.e(TAG, "Setting of account_rel100Mode failed") - } - } - - if (dtmfMode != acc.dtmfMode) { - if (Api.account_set_dtmfmode(acc.accp, dtmfMode) == 0) { - acc.dtmfMode = Api.account_dtmfmode(acc.accp) - Log.d(TAG, "New dtmfmode is ${acc.dtmfMode}") - } else { - Log.e(TAG, "Setting of dtmfmode $dtmfMode failed") - } - } - - if (answerMode != acc.answerMode) { - if (Api.account_set_answermode(acc.accp, answerMode) == 0) { - acc.answerMode = Api.account_answermode(acc.accp) - Log.d(TAG, "New answermode is ${acc.answerMode}") - } else { - Log.e(TAG, "Setting of answermode $answerMode failed") - } - } - - if (autoRedirect != acc.autoRedirect) { - Api.account_set_sip_autoredirect(acc.accp, autoRedirect) - acc.autoRedirect = autoRedirect - Log.d(TAG, "New autoRedirect is ${acc.autoRedirect}") - } - - var tVmUri = vmUri.text.toString().trim() - if (tVmUri != acc.vmUri) { - if (tVmUri != "") { - if (!tVmUri.startsWith("sip:")) tVmUri = "sip:$tVmUri" - if (!tVmUri.contains("@")) tVmUri = "$tVmUri@${acc.host()}" - if (!Utils.checkUri(tVmUri)) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_sip_or_tel_uri), tVmUri)) - return false - } - Api.account_set_mwi(acc.accp, true) - } else { - Api.account_set_mwi(acc.accp, false) - } - acc.vmUri = tVmUri - } - - val newCountryCode = countryCode.text.toString().trim() - if (newCountryCode != acc.countryCode) { - if (newCountryCode != "" && !Utils.checkCountryCode(newCountryCode)) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_country_code), newCountryCode)) - return false - } - acc.countryCode = newCountryCode - } - - val hostPart = telProvider.text.toString().trim() - if (hostPart != acc.telProvider) { - if (hostPart != "" && !Utils.checkHostPortParams(hostPart)) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_sip_uri_hostpart), hostPart)) - return false - } - acc.telProvider = hostPart - } - - if (defaultCheck.isChecked && (uaIndex > 0)) { - BaresipService.uas.add(0, BaresipService.uas[uaIndex]) - BaresipService.uas.removeAt(uaIndex + 1) - } - - AccountsActivity.saveAccounts() - - if (reRegister) { - ua.status = R.drawable.circle_yellow - if (acc.regint == 0) - Api.ua_unregister(ua.uap) - else - Api.ua_register(ua.uap) - } - - BaresipService.activities.remove("account,$aor") - returnResult(Activity.RESULT_OK) - return true + } else { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_account_nickname), nn)) + return } - - android.R.id.home -> { - goBack() - return true - } - } - return super.onOptionsItemSelected(item) + val dn = newDisplayname.trim() + if (dn != acc.displayName) { + if (Account.checkDisplayName(dn)) { + if (Api.account_set_display_name(acc.accp, dn) == 0) { + acc.displayName = Api.account_display_name(acc.accp) + Log.d(TAG, "New display name is ${acc.displayName}") + } else { + Log.e(TAG, "Setting of display name failed") + } + } else { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_display_name), dn)) + return + } + } + val au = newAuthUser.trim() + if (au != oldAuthUser) { + if (Account.checkAuthUser(au)) { + if (Api.account_set_auth_user(acc.accp, au) == 0) { + acc.authUser = Api.account_auth_user(acc.accp) + Log.d(TAG, "New auth user is ${acc.authUser}") + if (acc.regint > 0) + reRegister = true + } else { + Log.e(TAG, "Setting of auth user failed") + } + } else { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_authentication_username), au)) + return + } + } + + val ap = newAuthPass.trim() + if (ap != "") { + if (ap != oldAuthPass) { + if (Account.checkAuthPass(ap)) { + if (Api.account_set_auth_pass(acc.accp, ap) == 0) { + acc.authPass = Api.account_auth_pass(acc.accp) + if (acc.regint > 0) + reRegister = true + } else { + Log.e(TAG, "Setting of auth pass failed") + } + BaresipService.aorPasswords.remove(acc.aor) + } else { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_authentication_password), ap)) + return + } + } else { + BaresipService.aorPasswords.remove(acc.aor) + } + } else { // ap == "" + if (acc.authPass != NO_AUTH_PASS && + acc.authPass != BaresipService.aorPasswords[acc.aor]) + if (Api.account_set_auth_pass(acc.accp, "") == 0) { + acc.authPass = NO_AUTH_PASS + BaresipService.aorPasswords[aor] = NO_AUTH_PASS + } + } + + val ob = ArrayList() + var ob1 = newOutbound1.trim().replace(" ", "") + if (ob1 != "") { + if (!ob1.startsWith("sip:")) + ob1 = "sip:$ob1" + if (checkOutboundUri(ob1)) { + ob.add(ob1) + } else { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_proxy_server_uri), ob1)) + return + } + } + var ob2 = newOutbound2.trim().replace(" ", "") + if (ob2 != "") { + if (!ob2.startsWith("sip:")) + ob2 = "sip:$ob2" + if (checkOutboundUri(ob2)) { + ob.add(ob2) + } else { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_proxy_server_uri), ob2)) + return + } + } + if (ob != acc.outbound) { + for (i in 0..1) { + val uri = if (ob.size > i) + ob[i] + else + "" + if (Api.account_set_outbound(acc.accp, uri, i) != 0) + Log.e(TAG, "Setting of outbound proxy $i uri '$uri' failed") + } + Log.d(TAG, "New outbound proxies are $ob") + acc.outbound = ob + if (ob.isEmpty()) + Api.account_set_sipnat(acc.accp, "") + else + Api.account_set_sipnat(acc.accp, "outbound") + if (acc.regint > 0) + reRegister = true + } + + val regInt = newRegInt.trim().toInt() + if (regInt < 60 || regInt > 3600) { + Utils.alertView( + this, getString(R.string.notice), + String.format(getString(R.string.invalid_reg_int), "$regInt") + ) + return + } + val reReg = (newRegister != acc.regint > 0) || + (newRegister && regInt != acc.configuredRegInt) + if (reReg) { + if (Api.account_set_regint(acc.accp, + if (newRegister) regInt else 0) != 0) { + Log.e(TAG, "Setting of regint failed") + } else { + acc.regint = Api.account_regint(acc.accp) + acc.configuredRegInt = regInt + Log.d(TAG, "New regint is ${acc.regint}") + reRegister = true + } + } else { + if (regInt != acc.configuredRegInt) { + acc.configuredRegInt = regInt + } + } + + if (newMediaEnc != acc.mediaEnc) { + if (Api.account_set_mediaenc(acc.accp, newMediaEnc) == 0) { + acc.mediaEnc = Api.account_mediaenc(acc.accp) + Log.d(TAG, "New mediaenc is ${acc.mediaEnc}") + } else { + Log.e(TAG, "Setting of mediaenc $newMediaEnc failed") + } + } + + if (newMediaNat != acc.mediaNat) { + if (Api.account_set_medianat(acc.accp, newMediaNat) == 0) { + acc.mediaNat = Api.account_medianat(acc.accp) + Log.d(TAG, "New medianat is ${acc.mediaNat}") + } else { + Log.e(TAG, "Setting of medianat $newMediaNat failed") + } + } + + newStunServer = newStunServer.trim() + + if (newMediaNat != "") { + if (((newMediaNat == "stun") || (newMediaNat == "ice")) && (newStunServer == "")) + newStunServer = resources.getString(R.string.stun_server_default) + if (!Utils.checkStunUri(newStunServer) || + (newMediaNat == "turn" && + newStunServer.substringBefore(":") !in setOf("turn", "turns"))) { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_stun_server), newStunServer)) + return + } + } + + if (acc.stunServer != newStunServer) { + if (Api.account_set_stun_uri(acc.accp, newStunServer) == 0) { + acc.stunServer = Api.account_stun_uri(acc.accp) + Log.d(TAG, "New STUN/TURN server URI is '${acc.stunServer}'") + } else { + Log.e(TAG, "Setting of STUN/TURN URI server failed") + } + } + + newStunUser = newStunUser.trim() + if (acc.stunUser != newStunUser) { + if (Account.checkAuthUser(newStunUser)) { + if (Api.account_set_stun_user(acc.accp, newStunUser) == 0) { + acc.stunUser = Api.account_stun_user(acc.accp) + Log.d(TAG, "New STUN/TURN user is ${acc.stunUser}") + } else { + Log.e(TAG, "Setting of STUN/TURN user failed") + } + } else { + Utils.alertView(this, getString(R.string.notice), String.format(getString(R.string.invalid_stun_username), + newStunUser)) + return + } + } + + val newStunPass = newStunPass.trim() + if (acc.stunPass != newStunPass) { + if (newStunPass.isEmpty() || Account.checkAuthPass(newStunPass)) { + if (Api.account_set_stun_pass(acc.accp, newStunPass) == 0) { + acc.stunPass = Api.account_stun_pass(acc.accp) + } else { + Log.e(TAG, "Setting of stun pass failed") + } + } else { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_stun_password), newStunPass)) + return + } + } + + if (newRtcpMux != acc.rtcpMux) + if (Api.account_set_rtcp_mux(acc.accp, newRtcpMux) == 0) { + acc.rtcpMux = Api.account_rtcp_mux(acc.accp) + Log.d(TAG, "New rtcpMux is ${acc.rtcpMux}") + } else { + Log.e(TAG, "Setting of account_rtc_mux $newRtcpMux failed") + } + + if (new100Rel != (acc.rel100Mode == Api.REL100_ENABLED)) { + val mode = if (new100Rel) Api.REL100_ENABLED else Api.REL100_DISABLED + if (Api.account_set_rel100_mode(acc.accp, mode) == 0) { + acc.rel100Mode = Api.account_rel100_mode(acc.accp) + Api.ua_update_account(ua.uap) + Log.d(TAG, "New rel100Mode is ${acc.rel100Mode}") + } else { + Log.e(TAG, "Setting of account_rel100Mode failed") + } + } + + if (newDtmfMode != acc.dtmfMode) { + if (Api.account_set_dtmfmode(acc.accp, newDtmfMode) == 0) { + acc.dtmfMode = Api.account_dtmfmode(acc.accp) + Log.d(TAG, "New dtmfmode is ${acc.dtmfMode}") + } else { + Log.e(TAG, "Setting of dtmfmode $newDtmfMode failed") + } + } + + if (newAnswerMode != acc.answerMode) { + if (Api.account_set_answermode(acc.accp, newAnswerMode) == 0) { + acc.answerMode = Api.account_answermode(acc.accp) + Log.d(TAG, "New answermode is ${acc.answerMode}") + } else { + Log.e(TAG, "Setting of answermode $newAnswerMode failed") + } + } + + if (newAutoRedirect != acc.autoRedirect) { + Api.account_set_sip_autoredirect(acc.accp, newAutoRedirect) + acc.autoRedirect = newAutoRedirect + Log.d(TAG, "New autoRedirect is ${acc.autoRedirect}") + } + + newVmUri = newVmUri.trim() + if (newVmUri != acc.vmUri) { + if (newVmUri != "") { + if (!newVmUri.startsWith("sip:")) newVmUri = "sip:$newVmUri" + if (!newVmUri.contains("@")) newVmUri = "$newVmUri@${acc.host()}" + if (!Utils.checkUri(newVmUri)) { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_sip_or_tel_uri), newVmUri)) + return + } + Api.account_set_mwi(acc.accp, true) + } else { + Api.account_set_mwi(acc.accp, false) + } + acc.vmUri = newVmUri + Log.d(TAG, "New voicemail URI is ${acc.vmUri}") + } + + newCountryCode = newCountryCode.trim() + if (newCountryCode != acc.countryCode) { + if (newCountryCode != "" && !Utils.checkCountryCode(newCountryCode)) { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_country_code), newCountryCode)) + return + } + acc.countryCode = newCountryCode + Log.d(TAG, "New country code is ${acc.countryCode}") + } + + val hostPart = newTelProvider.trim() + if (hostPart != acc.telProvider) { + if (hostPart != "" && !Utils.checkHostPortParams(hostPart)) { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_sip_uri_hostpart), hostPart)) + return + } + acc.telProvider = hostPart + Log.d(TAG, "New tel provider is ${acc.telProvider}") + } + + val uaIndex = UserAgent.findAorIndex(aor)!! + if (newDefaultAccount && (uaIndex > 0)) { + val updatedUas = uas.value.toMutableList() + updatedUas.add(0, uas.value[uaIndex]) + updatedUas.removeAt(uaIndex + 1) + uas.value = updatedUas.toList() + } + + AccountsActivity.saveAccounts() + + if (reRegister) { + ua.status = R.drawable.circle_yellow + if (acc.regint == 0) + Api.ua_unregister(ua.uap) + else + Api.ua_register(ua.uap) + } + + BaresipService.activities.remove("account,$aor") + returnResult(RESULT_OK) } private fun goBack() { BaresipService.activities.remove("account,$aor") - returnResult(Activity.RESULT_CANCELED) + returnResult(RESULT_CANCELED) } override fun onPause() { @@ -759,136 +1498,9 @@ class AccountActivity : AppCompatActivity() { super.onPause() } - private fun bindTitles() { - binding.NickNameTitle.setOnClickListener{ - Utils.alertView(this, getString(R.string.nickname), - getString(R.string.account_nickname_help)) - } - binding.DisplayNameTitle.setOnClickListener{ - Utils.alertView(this, getString(R.string.display_name), - getString(R.string.display_name_help)) - } - binding.AuthUserTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.authentication_username), - getString(R.string.authentication_username_help) - ) - } - binding.AuthPassTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.authentication_password), - getString(R.string.authentication_password_help) - ) - } - binding.OutboundProxyTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.outbound_proxies), - getString(R.string.outbound_proxies_help) - ) - } - binding.RegTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.register), - getString(R.string.register_help) - ) - } - binding.RegIntTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.reg_int), - getString(R.string.reg_int_help) - ) - } - binding.AudioCodecsTitle.setOnClickListener { - val i = Intent(this, CodecsActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - b.putString("media", "audio") - i.putExtras(b) - startActivity(i) - } - binding.MediaNatTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.media_nat), - getString(R.string.media_nat_help) - ) - } - binding.StunServerTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.stun_server), - getString(R.string.stun_server_help) - ) - } - binding.StunUserTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.stun_username), - getString(R.string.stun_username_help) - ) - } - binding.StunPassTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.stun_password), - getString(R.string.stun_password_help) - ) - } - binding.MediaEncTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.media_encryption), - getString(R.string.media_encryption_help) - ) - } - binding.Rel100Title.setOnClickListener { - Utils.alertView( - this, getString(R.string.rel_100), - getString(R.string.rel_100_help) - ) - } - binding.DtmfModeTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.dtmf_mode), - getString(R.string.dtmf_mode_help) - ) - } - binding.AnswerModeTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.answer_mode), - getString(R.string.answer_mode_help) - ) - } - binding.RedirectModeTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.redirect_mode), - getString(R.string.redirect_mode_help) - ) - } - binding.VoicemailUriTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.voicemail_uri), - getString(R.string.voicemain_uri_help) - ) - } - binding.CountryCodeTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.country_code), - getString(R.string.country_code_help) - ) - } - binding.TelephonyProviderTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.telephony_provider), - getString(R.string.telephony_provider_help) - ) - } - binding.DefaultTitle.setOnClickListener { - Utils.alertView( - this, getString(R.string.default_account), - getString(R.string.default_account_help) - ) - } - } - private fun returnResult(code: Int) { val i = Intent() - if (code == Activity.RESULT_OK) + if (code == RESULT_OK) i.putExtra("aor", aor) setResult(code, i) finish() diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountListAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountListAdapter.kt deleted file mode 100644 index 306bd919..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/AccountListAdapter.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageButton -import android.widget.TextView -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -import java.util.* - -class AccountListAdapter(private val cxt: Context, private val rows: ArrayList) : - ArrayAdapter(cxt, R.layout.account_row, rows) { - - private val layoutInflater = LayoutInflater.from(context) - - private class ViewHolder(view: View?) { - val aorView: TextView = view?.findViewById(R.id.aor)!! - val actionView: ImageButton = view?.findViewById(R.id.action)!! - } - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - val ua = BaresipService.uas[position] - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.account_row, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val row = rows[position] - viewHolder.aorView.text = row.aor.split(":")[0] - viewHolder.aorView.textSize = 20f - viewHolder.aorView.setPadding(6, 6, 0, 6) - viewHolder.actionView.setImageResource(row.action) - - viewHolder.aorView.setOnClickListener { - val i = Intent(cxt, AccountActivity::class.java) - val b = Bundle() - b.putString("aor", BaresipService.uas[position].account.aor) - i.putExtras(b) - MainActivity.accountRequest!!.launch(i) - } - - viewHolder.actionView.setOnClickListener { - with (MaterialAlertDialogBuilder(cxt, R.style.AlertDialogTheme)) { - setTitle(R.string.confirmation) - setMessage(String.format(cxt.getString(R.string.delete_account), - viewHolder.aorView.text)) - setPositiveButton(cxt.getText(R.string.delete)) { dialog, _ -> - Api.ua_destroy(ua.uap) - CallHistoryNew.clear(ua.account.aor) - Message.clear(ua.account.aor) - ua.remove() - AccountsActivity.generateAccounts() - AccountsActivity.saveAccounts() - this@AccountListAdapter.notifyDataSetChanged() - dialog.dismiss() - } - setNeutralButton(cxt.getText(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - } - show() - } - } - - return rowView - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountRow.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountRow.kt deleted file mode 100644 index 3f306fd9..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/AccountRow.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.tutpro.baresip - -class AccountRow(val aor: String, val action: Int) diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountsActivity.kt index 904c1f4b..76bb407a 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/AccountsActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/AccountsActivity.kt @@ -1,27 +1,84 @@ package com.tutpro.baresip -import android.app.Activity +import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View +import android.os.SystemClock +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding -import com.tutpro.baresip.databinding.ActivityAccountsBinding +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.tutpro.baresip.CustomElements.verticalScrollbar +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader +import java.net.URL +import java.util.Locale -class AccountsActivity : AppCompatActivity() { +class AccountsActivity : ComponentActivity() { - private lateinit var binding: ActivityAccountsBinding - private lateinit var alAdapter: AccountListAdapter internal lateinit var aor: String + private lateinit var mediaEncMap: Map + private lateinit var mediaNatMap: Map + + private var lastClick: Long = 0 + private val scope = CoroutineScope(Job() + Dispatchers.Main) private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -32,106 +89,380 @@ class AccountsActivity : AppCompatActivity() { public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityAccountsBinding.inflate(layoutInflater) - setContentView(binding.root) - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.AccountsView.updatePadding(top = 172) - WindowInsetsCompat.CONSUMED - } + enableEdgeToEdge() - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true + val title = String.format(getString(R.string.accounts)) aor = intent.getStringExtra("aor")!! Utils.addActivity("accounts,$aor") - val listView = binding.accounts - generateAccounts() - alAdapter = AccountListAdapter(this, accounts) - listView.adapter = alAdapter + mediaEncMap = mapOf("zrtp" to "ZRTP", "dtls_srtp" to "DTLS-SRTPF", + "srtp-mand" to "SRTP-MAND", "srtp" to "SRTP", "" to "--") - val accountRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + mediaNatMap = mapOf("stun" to "STUN", "turn" to "TURN", "ice" to "ICE", "" to "--") - val addAccountButton = binding.addAccount - val newAorView = binding.newAor - addAccountButton.setOnClickListener { - val aor = newAorView.text.toString().trim() - if (!Utils.checkAor("sip:$aor")) { - Log.d(TAG, "Invalid Address of Record $aor") - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_aor), aor)) - return@setOnClickListener + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + AccountsScreen(this, title) { goBack() } + } } - if (Account.ofAor("sip:$aor") != null) { - Log.d(TAG, "Account $aor already exists") - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.account_exists), aor.split(":")[0])) - return@setOnClickListener - } - val laddr = "sip:$aor" - val ua = UserAgent.uaAlloc("<$laddr>;stunserver=\"stun:stun.l.google.com:19302\";regq=0.5;pubint=0;regint=0;mwi=no") - if (ua == null) { - Log.e(TAG, "Failed to allocate UA for $aor") - Utils.alertView(this, getString(R.string.notice), - getString(R.string.account_allocation_failure)) - return@setOnClickListener - } - // Api.account_debug(ua.account.accp) - Log.d(TAG, "Allocated UA ${ua.uap} for ${Api.account_luri(ua.account.accp)}") - newAorView.setText("") - newAorView.hint = getString(R.string.user_domain) - newAorView.clearFocus() - generateAccounts() - alAdapter.notifyDataSetChanged() - saveAccounts() - val i = Intent(this, AccountActivity::class.java) - val b = Bundle() - b.putString("aor", ua.account.aor) - b.putBoolean("new", true) - i.putExtras(b) - accountRequest.launch(i) } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (BaresipService.activities.indexOf("accounts,$aor") == -1) - return true - - when (item.itemId) { - - R.id.help -> { - Utils.alertView(this@AccountsActivity, getString(R.string.new_account), - getString(R.string.accounts_help)) - return true - } - - android.R.id.home -> { - goBack() - return true - } - } - - return super.onOptionsItemSelected(item) - + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun AccountsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier.fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + TopAppBar( + title = { + Text(text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + ) + }, + bottomBar = { NewAccount(ctx) }, + content = { contentPadding -> + AccountsContent(ctx, contentPadding) + }, + ) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.accounts_menu, menu) - return true + @Composable + fun AccountsContent(ctx: Context, contentPadding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(top = 8.dp), + verticalArrangement = Arrangement.Top + ) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier.fillMaxWidth() + .imePadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(BaresipService.uas.value, key = { it.account.aor }) { ua -> + val account = ua.account + val aor = account.aor + val text = if (account.nickName.value != "") + account.nickName.value + else + account.aor.substringAfter(":") + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + fontSize = 20.sp, + color = LocalCustomColors.current.itemText, + modifier = Modifier.weight(1f).padding(start = 10.dp) + .clickable { + val i = Intent(ctx, AccountActivity::class.java) + val b = Bundle() + b.putString("aor", aor) + i.putExtras(b) + startActivity(i) + } + ) + SmallFloatingActionButton( + onClick = { + if (SystemClock.elapsedRealtime() - lastClick > 1000) { + lastClick = SystemClock.elapsedRealtime() + with( + MaterialAlertDialogBuilder( + ctx, + R.style.AlertDialogTheme + ) + ) { + setTitle(R.string.confirmation) + setMessage( + String.format( + ctx.getString(R.string.delete_account), + text + ) + ) + setPositiveButton(R.string.delete) { dialog, _ -> + CallHistoryNew.clear(aor) + Message.clearMessagesOfAor(aor) + ua.remove() + Api.ua_destroy(ua.uap) + saveAccounts() + dialog.dismiss() + } + setNeutralButton(ctx.getText(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + show() + } + } + }, + containerColor = LocalCustomColors.current.background, + contentColor = LocalCustomColors.current.secondary + ) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete) + ) + } + } + } + } + } + } + + @Composable + fun NewAccount(ctx: Context) { + var newAor by remember { mutableStateOf("") } + val focusManager = LocalFocusManager.current + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = newAor, + placeholder = { CustomElements.Text(text = getString(R.string.new_account)) }, + onValueChange = { newAor = it }, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + .verticalScroll(rememberScrollState()) + .clickable { + Utils.alertView(ctx, getString(R.string.new_account), + getString(R.string.accounts_help)) }, + singleLine = false, + trailingIcon = { + if (newAor.isNotEmpty()) { + Icon( + Icons.Outlined.Clear, + contentDescription = "Clear", + modifier = Modifier.clickable { newAor = "" } + ) + } + }, + label = { + CustomElements.Text( + text = getString(R.string.new_account), + fontSize = 18.sp + ) + }, + textStyle = TextStyle(fontSize = 18.sp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + ) + ) + Image( + painter = painterResource(id = R.drawable.plus), + contentDescription = "Add", + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp) + .clickable( + onClick = { + val account = createNew(newAor.trim()) + if (account != null) { + val i = Intent(ctx, AccountActivity::class.java) + val b = Bundle() + b.putString("aor", account.aor) + i.putExtras(b) + startActivity(i) + newAor = "" + focusManager.clearFocus() + } + } + ), + ) + } + } + + private fun createNew(newAor: String): Account? { + + val aor = if (newAor.startsWith("sip:")) + newAor + else + "sip:$newAor" + + if (!Utils.checkAor(aor)) { + Log.d(TAG, "Invalid Address of Record $aor") + Utils.alertView( + this, getString(R.string.notice), + String.format(getString(R.string.invalid_aor), + aor.split(":")[1]) + ) + return null + } + + if (Account.ofAor(aor) != null) { + Log.d(TAG, "Account $aor already exists") + Utils.alertView( + this, getString(R.string.notice), + String.format(getString(R.string.account_exists), + aor.split(":")[1]) + ) + return null + } + + val ua = UserAgent.uaAlloc( + "<$aor>;stunserver=\"stun:stun.l.google.com:19302\";regq=0.5;pubint=0;regint=0;mwi=no" + ) + if (ua == null) { + Log.e(TAG, "Failed to allocate UA for $aor") + Utils.alertView( + this, getString(R.string.notice), + getString(R.string.account_allocation_failure) + ) + return null + } + + // Api.account_debug(ua.account.accp) + val acc = ua.account + Log.d(TAG, "Allocated UA ${ua.uap} for ${Api.account_luri(acc.accp)}") + initAccountFromConfig(this@AccountsActivity, acc) + saveAccounts() + + return acc + } + + private fun initAccountFromConfig(ctx: AccountsActivity, acc: Account) { + scope.launch(Dispatchers.IO) { + val url = "https://${Utils.uriHostPart(acc.aor)}/baresip/account_config.xml" + val config = try { + URL(url).readText() + } catch (e: java.lang.Exception) { + Log.d(TAG, "Failed to get account configuration from network") + null + } + if (config != null && !ctx.isFinishing) { + Log.d(TAG, "Got account config '$config'") + val parserFactory: XmlPullParserFactory = XmlPullParserFactory.newInstance() + val parser: XmlPullParser = parserFactory.newPullParser() + parser.setInput(StringReader(config)) + var tag: String? + var text = "" + var event = parser.eventType + val audioCodecs = ArrayList(Api.audio_codecs().split(",")) + val videoCodecs = ArrayList(Api.video_codecs().split(",")) + while (event != XmlPullParser.END_DOCUMENT) { + tag = parser.name + when (event) { + XmlPullParser.TEXT -> + text = parser.text + XmlPullParser.START_TAG -> { + if (tag == "audio-codecs") + acc.audioCodec.clear() + if (tag == "video-codecs") + acc.videoCodec.clear() + } + XmlPullParser.END_TAG -> + when (tag) { + "outbound-proxy-1" -> + if (text.isNotEmpty()) + acc.outbound.add(text) + "outbound-proxy-2" -> + if (text.isNotEmpty()) + acc.outbound.add(text) + "registration-interval" -> + acc.configuredRegInt = text.toInt() + "register" -> + acc.regint = if (text == "yes") acc.configuredRegInt else 0 + "audio-codec" -> + if (text in audioCodecs) + acc.audioCodec.add(text) + "video-codec" -> + if (text in videoCodecs) + acc.videoCodec.add(text) + "media-encoding" -> { + val enc = text.lowercase(Locale.ROOT) + if (enc in mediaEncMap.keys && enc.isNotEmpty()) + acc.mediaEnc = enc + } + "media-nat" -> { + val nat = text.lowercase(Locale.ROOT) + if (nat in mediaNatMap.keys && nat.isNotEmpty()) + acc.mediaNat = nat + } + "stun-turn-server" -> + if (text.isNotEmpty()) + acc.stunServer = text + "rtcp-mux" -> + acc.rtcpMux = text == "yes" + "100rel-mode" -> + acc.rel100Mode = if (text == "yes") + Api.REL100_ENABLED + else + Api.REL100_DISABLED + "dtmf-mode" -> + if (text in arrayOf("rtp-event", "sip-info", "auto")) + acc.dtmfMode = when (text) { + "rtp-event" -> Api.DTMFMODE_RTP_EVENT + "sip-info" -> Api.DTMFMODE_SIP_INFO + else -> Api.DTMFMODE_AUTO + } + "answer-mode" -> + if (text in arrayOf("manual", "auto")) + acc.answerMode = if (text == "manual") + Api.ANSWERMODE_MANUAL + else + Api.ANSWERMODE_AUTO + "redirect-mode" -> + acc.autoRedirect = text == "yes" + "voicemail-uri" -> + if (text.isNotEmpty()) + acc.vmUri = text + "country-code" -> + acc.countryCode = text + "tel-provider" -> + acc.telProvider = text + } + } + event = parser.next() + } + } + } } private fun goBack() { BaresipService.activities.remove("accounts,$aor") - setResult(Activity.RESULT_CANCELED, Intent()) + setResult(RESULT_CANCELED, Intent()) finish() } @@ -142,19 +473,11 @@ class AccountsActivity : AppCompatActivity() { companion object { - var accounts = ArrayList() - - fun generateAccounts() { - accounts.clear() - for (ua in BaresipService.uas) - accounts.add(AccountRow(ua.account.aor.replace("sip:", ""), - R.drawable.delete)) - } - fun saveAccounts() { var accounts = "" for (a in Account.accounts()) accounts = accounts + a.print() + "\n" - Utils.putFileContents(BaresipService.filesPath + "/accounts", accounts.toByteArray(Charsets.UTF_8)) + Utils.putFileContents(BaresipService.filesPath + "/accounts", + accounts.toByteArray(Charsets.UTF_8)) // Log.d(TAG, "Saved accounts '${accounts}' to '${BaresipService.filesPath}/accounts'") } diff --git a/app/src/main/kotlin/com/tutpro/baresip/AndroidContactActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AndroidContactActivity.kt index 9574e477..dd46e042 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/AndroidContactActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/AndroidContactActivity.kt @@ -1,31 +1,64 @@ package com.tutpro.baresip import android.app.Activity +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle -import android.view.MenuItem -import android.view.View -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.cardview.widget.CardView -import com.tutpro.baresip.databinding.ActivityAndroidContactBinding +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage -class AndroidContactActivity : AppCompatActivity() { +class AndroidContactActivity : ComponentActivity() { - private lateinit var binding: ActivityAndroidContactBinding - private lateinit var layout: LinearLayout - private lateinit var textAvatarView: TextView - private lateinit var cardAvatarView: CardView - private lateinit var cardImageAvatarView: ImageView - private lateinit var nameView: TextView - private lateinit var ulAdapter: AndroidUriListAdapter private lateinit var aor: String + private lateinit var name: String - private var index = 0 private var color = 0 private val onBackPressedCallback = object : OnBackPressedCallback(true) { @@ -37,70 +70,207 @@ class AndroidContactActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityAndroidContactBinding.inflate(layoutInflater) - setContentView(binding.root) - layout = binding.ContactView + + enableEdgeToEdge() aor = intent.getStringExtra("aor")!! - index = intent.getIntExtra("index", 0) + name = intent.getStringExtra("name")!! - textAvatarView = binding.TextAvatar - cardAvatarView = binding.CardAvatar - cardImageAvatarView = binding.ImageAvatar - nameView = binding.Name + val title: String = name + val contact = Contact.androidContact(name) - val contact = Contact.contacts()[index] as Contact.AndroidContact - val name = contact.name - color = contact.color - val thumbnailUri = contact.thumbnailUri - if (thumbnailUri != null) - showImageAvatar(thumbnailUri) - else - showTextAvatar(name, color) - title = name - nameView.text = name - - val listView = binding.uris - ulAdapter = AndroidUriListAdapter(this, contact.uris, aor) - listView.adapter = ulAdapter - - Utils.addActivity("android contact,$aor,$index") - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (BaresipService.activities.indexOf("android contact,$aor,$index") == -1) - return true - - when (item.itemId) { - android.R.id.home -> - goBack() + if (contact == null) { + Log.e(TAG, "No Android contact found with name $name") + goBack() } - return true + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + ContactScreen(this, title, contact!!) { goBack() } + } + } + } + Utils.addActivity("android contact, $name") + + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun ContactScreen(ctx: Context, title: String, contact: Contact.AndroidContact, + navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier.fillMaxHeight().imePadding().safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + TopAppBar( + title = { + Text( + text = title, color = LocalCustomColors.current.grayLight, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + ) + }, + content = { contentPadding -> + ContactContent(ctx, contentPadding, contact) + } + ) + } + + @Composable + fun ContactContent(ctx: Context, contentPadding: PaddingValues, contact: Contact.AndroidContact) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(contact) + ContactName() + Uris(ctx, contact) + } + } + + @Composable + fun TextAvatar(text: String, size: Int, color: Int) { + Box( + modifier = Modifier.size(size.dp), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle(SolidColor(Color(color))) + } + Text(text, fontSize = 72.sp, color = Color.White) + } + } + + @Composable + fun ImageAvatar(uri: Uri, size: Int) { + AsyncImage( + model = uri, + contentDescription = stringResource(R.string.avatar_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(size.dp).clip(CircleShape) + ) + } + + @Composable + fun Avatar(contact: Contact.AndroidContact) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + color = contact.color + val thumbnailUri = contact.thumbnailUri + if (thumbnailUri != null) + ImageAvatar(thumbnailUri, 96) + else + TextAvatar(if (name == "") "" else name[0].toString(), 96, color) + } + } + + @Composable + fun ContactName() { + Row( + Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(name, fontSize = 24.sp, color = LocalCustomColors.current.itemText) + } + } + + @Composable + fun Uris(ctx: Context, contact: Contact.AndroidContact) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .background(LocalCustomColors.current.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(contact.uris) { uri -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = uri.substringAfter(":"), + modifier = Modifier.weight(1f), + fontSize = 18.sp, + color = LocalCustomColors.current.itemText, + ) + Image( + painter = painterResource(R.drawable.message), + colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText), + contentDescription = "Send Message", + modifier = Modifier.padding(end = 24.dp).clickable { + val i = Intent(ctx, MainActivity::class.java) + i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + i.putExtra("action", "message") + val ua = UserAgent.ofAor(aor) + if (ua == null) { + Log.w(TAG, "message clickable did not find AoR $aor") + } else { + BaresipService.activities.clear() + i.putExtra("uap", ua.uap) + i.putExtra("peer", uri) + (ctx as Activity).startActivity(i) + } + } + ) + Image( + painter = painterResource(R.drawable.call_small), + colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText), + contentDescription = "Call", + modifier = Modifier.clickable { + val i = Intent(ctx, MainActivity::class.java) + i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + i.putExtra("action", "call") + val ua = UserAgent.ofAor(aor) + if (ua == null) { + Log.w(TAG, "call clickable did not find AoR $aor") + } else { + BaresipService.activities.clear() + i.putExtra("uap", ua.uap) + i.putExtra("peer", uri) + (ctx as Activity).startActivity(i) + } + } + ) + } + } + } } private fun goBack() { - BaresipService.activities.remove("android contact,$index") - setResult(Activity.RESULT_CANCELED, Intent(this, MainActivity::class.java)) + BaresipService.activities.remove("android contact,$name") + setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java)) finish() } - - private fun showTextAvatar(name: String, color: Int) { - textAvatarView.visibility = View.VISIBLE - cardAvatarView.visibility = View.GONE - textAvatarView.background.setTint(color) - textAvatarView.text = "${name[0]}" - } - - private fun showImageAvatar(thumbNailUri: Uri) { - textAvatarView.visibility = View.GONE - cardAvatarView.visibility = View.VISIBLE - cardImageAvatarView.setImageURI(thumbNailUri) - } - } diff --git a/app/src/main/kotlin/com/tutpro/baresip/AndroidUriListAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/AndroidUriListAdapter.kt deleted file mode 100644 index b451d3fc..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/AndroidUriListAdapter.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.tutpro.baresip - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageButton -import android.widget.TextView - -class AndroidUriListAdapter(private val ctx: Context, private val rows: ArrayList, val aor: String) : - ArrayAdapter(ctx, R.layout.android_uri_row, rows) { - - private val layoutInflater = LayoutInflater.from(context) - - private class ViewHolder(view: View?) { - val uriView: TextView = view?.findViewById(R.id.uri)!! - val messageView: ImageButton = view?.findViewById(R.id.message)!! - val callView: ImageButton = view?.findViewById(R.id.call)!! - } - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.android_uri_row, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val uri = rows[position] - - viewHolder.uriView.text = uri.substringAfter(":") - - viewHolder.messageView.setOnClickListener { - val i = Intent(ctx, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "message") - val ua = UserAgent.ofAor(aor) - if (ua == null) { - Log.w(TAG, "onClickListener did not find AoR $aor") - } else { - BaresipService.activities.clear() - i.putExtra("uap", ua.uap) - i.putExtra("peer", uri) - (ctx as Activity).startActivity(i) - } - } - - viewHolder.callView.setOnClickListener { - val i = Intent(ctx, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "call") - val ua = UserAgent.ofAor(aor) - if (ua == null) { - Log.w(TAG, "onClickListener did not find AoR $aor") - } else { - BaresipService.activities.clear() - i.putExtra("uap", ua.uap) - i.putExtra("peer", uri) - (ctx as Activity).startActivity(i) - } - } - - return rowView - } -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/Api.kt b/app/src/main/kotlin/com/tutpro/baresip/Api.kt index 0a73d6af..a14a7a66 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Api.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Api.kt @@ -121,6 +121,6 @@ object Api { external fun module_load(module: String): Int external fun module_unload(module: String) - external fun create_AAudio_SessionId(): Int - + external fun AAudio_open_stream(): Int + external fun AAudio_close_stream() } diff --git a/app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt b/app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt new file mode 100644 index 00000000..e58dc578 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt @@ -0,0 +1,67 @@ +package com.tutpro.baresip + +import android.app.Activity +import android.os.Build +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.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + val darkTheme = remember { BaresipService.darkTheme } + + // "normal" palette, nothing change here + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme.value) dynamicDarkColorScheme(context = LocalContext.current) + else dynamicLightColorScheme(context = LocalContext.current) + } + darkTheme.value -> darkColorScheme() + else -> lightColorScheme() + } + + // logic for which custom palette to use + val customColorsPalette = + if (darkTheme.value) DarkCustomColors + else LightCustomColors + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val decorView = window.decorView + + // Ensure insets are applied correctly + WindowCompat.setDecorFitsSystemWindows(window, false) + + // Handle the status bar appearance + val insetsController = WindowCompat.getInsetsController(window, decorView) + insetsController.apply { + isAppearanceLightStatusBars = !darkTheme.value + isAppearanceLightNavigationBars = !darkTheme.value + } + } + } + + // here is the important point, where you will expose custom objects + CompositionLocalProvider( + LocalCustomColors provides customColorsPalette // our custom palette + ) { + MaterialTheme( + colorScheme = colorScheme, // the MaterialTheme still uses the "normal" palette + content = content + ) + } + +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AudioActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AudioActivity.kt index 3cd41bbb..d24a16c9 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/AudioActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/AudioActivity.kt @@ -1,42 +1,77 @@ package com.tutpro.baresip -import android.os.Build +import android.content.Context import android.os.Bundle -import android.util.TypedValue -import android.view.Gravity -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.* +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding -import com.tutpro.baresip.databinding.ActivityAudioBinding +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tutpro.baresip.CustomElements.Checkbox +import com.tutpro.baresip.CustomElements.verticalScrollbar -class AudioActivity : AppCompatActivity() { - - private lateinit var binding: ActivityAudioBinding - private lateinit var micGain: EditText - private lateinit var opusBitRate: EditText - private lateinit var opusPacketLoss: EditText - private lateinit var aec: CheckBox - private lateinit var speakerPhone: CheckBox - private lateinit var audioDelay: EditText +class AudioActivity : ComponentActivity() { private var save = false private var restart = false - private var callVolume = BaresipService.callVolume + private var oldCallVolume = BaresipService.callVolume + private var newCallVolume = oldCallVolume private var oldMicGain = "" - private var oldAudioModules = mutableMapOf() - private var oldOpusBitrate = "" - private var oldOpusPacketLoss = "" - private var oldAec = false - private var toneCountry = BaresipService.toneCountry + private var newMicGain = "" + private var newSpeakerPhone = BaresipService.speakerPhone + private val modules = Config.variables("module") + private var newAudioModules = mutableMapOf() + private var oldOpusBitrate = Config.variable("opus_bitrate") + private var newOpusBitrate = oldOpusBitrate + private var oldOpusPacketLoss = Config.variable("opus_packet_loss") + private var newOpusPacketLoss = oldOpusPacketLoss + private var newAudioDelay = BaresipService.audioDelay.toString() + private var newToneCountry = BaresipService.toneCountry + private var arrowTint = Color.Unspecified private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -47,323 +82,562 @@ class AudioActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityAudioBinding.inflate(layoutInflater) - setContentView(binding.root) - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.AudioView.updatePadding(top = systemBars.top + 56) - WindowInsetsCompat.CONSUMED - } + enableEdgeToEdge() - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true + val title = String.format(getString(R.string.configuration)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) Utils.addActivity("audio") - val callVolSpinner = binding.VolumeSpinner - val volKeys = arrayListOf("None", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10") - val volVals = arrayListOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10) - val curVal = callVolume - val curKey = volKeys[curVal] - volKeys.removeAt(curVal) - volVals.removeAt(curVal) - volKeys.add(0, curKey) - volVals.add(0, curVal) - val callVolAdapter = ArrayAdapter( - this, android.R.layout.simple_spinner_item, - volKeys - ) - callVolAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - callVolSpinner.adapter = callVolAdapter - callVolSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>, - view: View?, - position: Int, - id: Long - ) { - callVolume = volVals[volKeys.indexOf(parent.selectedItem.toString())] - } + if (!BaresipService.agcAvailable) + oldMicGain = Config.variable("augain") - override fun onNothingSelected(parent: AdapterView<*>) { + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + AudioScreen(this, title) { goBack() } + } } } + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + @Composable + fun AudioScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { TopAppBar(title, navigateBack) }, + content = { contentPadding -> + AudioContent(ctx, contentPadding) + } + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopAppBar(title: String, navigateBack: () -> Unit) { + androidx.compose.material3.TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton(onClick = { + checkOnClick() + }) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + } + ) + } + + @Composable + fun AudioContent(ctx: Context, contentPadding: PaddingValues) { + arrowTint = if (BaresipService.darkTheme.value) + LocalCustomColors.current.grayLight + else + LocalCustomColors.current.black + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding), + ) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + state = lazyListState, + ) { + item { CallVolume(ctx) } + item { MicGain(ctx) } + item { SpeakerPhone(ctx) } + item { AudioModules(ctx) } + item { OpusBitRate(ctx) } + item { OpusPacketLoss(ctx) } + item { AudioDelay(ctx) } + item { ToneCountry(ctx) } + } + } + } + + @Composable + private fun CallVolume(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.default_call_volume), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.default_call_volume), + getString(R.string.default_call_volume_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val volNames = listOf("--", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10") + val volValues = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + val itemPosition = remember { + mutableIntStateOf(volValues.indexOf(oldCallVolume)) + } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = volNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + volNames.forEachIndexed { index, vol -> + DropdownMenuItem(text = { + Text(text = vol) + }, + onClick = { + isDropDownExpanded.value = false + itemPosition.intValue = index + newCallVolume = volValues[index] + }) + if (index < 10) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + } + } + } + } + } + + @Composable + private fun MicGain(ctx: Context) { + if (!BaresipService.agcAvailable) + Row( + Modifier.fillMaxWidth().padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var micGain by remember { mutableStateOf(oldMicGain) } + newMicGain = micGain + OutlinedTextField( + value = micGain, + placeholder = { Text(stringResource(R.string.microphone_gain)) }, + onValueChange = { + micGain = it + newMicGain = micGain + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + Utils.alertView( + ctx, getString(R.string.microphone_gain), + getString(R.string.microphone_gain_help) + ) + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { Text(stringResource(R.string.microphone_gain)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun SpeakerPhone(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.speaker_phone), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.speaker_phone), + getString(R.string.speaker_phone_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var speakerPhone by remember { mutableStateOf(BaresipService.speakerPhone) } + newSpeakerPhone = speakerPhone + Checkbox( + checked = speakerPhone, + onCheckedChange = { + speakerPhone = it + newSpeakerPhone = speakerPhone + } + ) + } + } + + @Composable + private fun AudioModules(ctx: Context) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text(text = stringResource(R.string.audio_modules_title), + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + modifier = Modifier.clickable { + Utils.alertView(ctx, getString(R.string.audio_modules_title), + getString(R.string.audio_modules_help)) + }) + for (module in audioModules) { + Row(horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 18.dp) + ) { + Text(text = String.format(getString(R.string.bullet_item), module), + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + Spacer(modifier = Modifier.weight(1f)) + Checkbox( + checked = modules.contains("${module}.so"), + onCheckedChange = { + newAudioModules[module] = it + } + ) + } + } + } + } + + @Composable + private fun OpusBitRate(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var opusBitrate by remember { mutableStateOf(oldOpusBitrate) } + newOpusBitrate = opusBitrate + OutlinedTextField( + value = opusBitrate, + placeholder = { Text(stringResource(R.string.opus_bit_rate)) }, + onValueChange = { + opusBitrate = it + newOpusBitrate = opusBitrate + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.opus_bit_rate), + getString(R.string.opus_bit_rate_help) + ) + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.opus_bit_rate)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun OpusPacketLoss(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var opusPacketLoss by remember { mutableStateOf(oldOpusPacketLoss) } + newOpusPacketLoss = opusPacketLoss + OutlinedTextField( + value = opusPacketLoss, + placeholder = { Text(stringResource(R.string.opus_packet_loss)) }, + onValueChange = { + opusPacketLoss = it + newOpusPacketLoss = opusPacketLoss + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.opus_packet_loss), + getString(R.string.opus_packet_loss_help) + ) + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.opus_packet_loss)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun AudioDelay(ctx: Context) { + Row( + Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var audioDelay by remember { mutableStateOf(BaresipService.audioDelay.toString()) } + newAudioDelay = audioDelay + OutlinedTextField( + value = audioDelay, + placeholder = { Text(stringResource(R.string.audio_delay)) }, + onValueChange = { + audioDelay = it + newAudioDelay = audioDelay + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + Utils.alertView(ctx, getString(R.string.audio_delay), + getString(R.string.audio_delay_help) + ) + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.audio_delay)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun ToneCountry(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.tone_country), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.tone_country), + getString(R.string.tone_country_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val countryNames = arrayListOf("BG", "BR", "DE", "CZ", "ES", "FI", "FR", "GB", "JP", "NO", "NZ", "SE", "RU", "US") + val countryValues = arrayListOf("bg", "br", "de", "cz", "es", "fi", "fr", "uk", "jp", "no", "nz", "se", "ru", "us") + val itemPosition = remember { + mutableIntStateOf(countryValues.indexOf(BaresipService.toneCountry)) + } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = countryNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + countryNames.forEachIndexed { index, name -> + DropdownMenuItem(text = { + Text(text = name) + }, + onClick = { + isDropDownExpanded.value = false + itemPosition.intValue = index + newToneCountry = countryValues[index] + }) + if (index < 10) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + } + } + } + } + } + + private fun checkOnClick() { + + if (BaresipService.activities.indexOf("audio") == -1) + return + + if (BaresipService.callVolume != newCallVolume) { + BaresipService.callVolume = newCallVolume + Config.replaceVariable("call_volume", newCallVolume.toString()) + save = true + } + if (!BaresipService.agcAvailable) { - micGain = binding.MicGain - oldMicGain = Config.variable("augain") - micGain.setText(oldMicGain) - } else { - binding.MicGainLayout.visibility = View.GONE - } - - speakerPhone = binding.SpeakerPhone - speakerPhone.isChecked = BaresipService.speakerPhone - - val modules = Config.variables("module") - - val audioModulesList = binding.AudioModulesList - var id = 1000 - for (module in audioModules) { - val rl = RelativeLayout(this) - val rlParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT) - rlParams.marginStart = 16 - rl.layoutParams = rlParams - val tv = TextView(this) - tv.id = id++ - val tvParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT) - tvParams.addRule(RelativeLayout.ALIGN_PARENT_START) - tvParams.addRule(RelativeLayout.CENTER_VERTICAL) - tvParams.addRule(RelativeLayout.START_OF, id) - tv.layoutParams = tvParams - tv.text = String.format(getString(R.string.bullet_item), module) - tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - tv.setTextColor(ContextCompat.getColor(this, R.color.colorItemText)) - rl.addView(tv) - val cb = CheckBox(this) - cb.id = id++ - val cbParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT) - cbParams.addRule(RelativeLayout.ALIGN_PARENT_END) - cbParams.addRule(RelativeLayout.CENTER_VERTICAL) - cb.layoutParams = cbParams - cb.gravity = Gravity.END - cb.isChecked = modules.contains("${module}.so") - oldAudioModules[module] = cb.isChecked - rl.addView(cb) - audioModulesList.addView(rl) - } - - opusBitRate = binding.OpusBitRate - oldOpusBitrate = Config.variable("opus_bitrate") - opusBitRate.setText(oldOpusBitrate) - - opusPacketLoss = binding.OpusPacketLoss - oldOpusPacketLoss = Config.variable("opus_packet_loss") - opusPacketLoss.setText(oldOpusPacketLoss) - - aec = binding.Aec - oldAec = modules.contains("webrtc_aecm.so") - aec.isChecked = oldAec - aec.setOnClickListener { - if (!aec.isChecked) - if (!Utils.isAecSupported()) - Utils.alertView(this, getString(R.string.notice), - getString(R.string.no_hw_aec)) - } - - audioDelay = binding.AudioDelay - audioDelay.setText("${BaresipService.audioDelay}") - - val toneCountrySpinner = binding.ToneCountrySpinner - val toneCountryKeys = arrayListOf("bg", "br", "de", "cz", "es", "fi", "fr", "uk", "jp", "no", "nz", "se", "ru", "us") - val toneCountryVals = arrayListOf("BG", "BR", "DE", "CZ", "ES", "FI", "FR", "GB", "JP", "NO", "NZ", "SE", "RU", "US") - val keyIx = toneCountryKeys.indexOf(toneCountry) - val keyVal = toneCountryVals.elementAt(keyIx) - toneCountryKeys.removeAt(keyIx) - toneCountryVals.removeAt(keyIx) - toneCountryKeys.add(0, toneCountry) - toneCountryVals.add(0, keyVal) - val toneCountryAdapter = ArrayAdapter(this,android.R.layout.simple_spinner_item, - toneCountryVals) - toneCountryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - toneCountrySpinner.adapter = toneCountryAdapter - toneCountrySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - toneCountry = toneCountryKeys[toneCountryVals.indexOf(parent.selectedItem.toString())] - } - override fun onNothingSelected(parent: AdapterView<*>) { - } - } - - bindTitles() - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - - super.onCreateOptionsMenu(menu) - val inflater = menuInflater - inflater.inflate(R.menu.check_icon, menu) - return true - - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (BaresipService.activities.indexOf("audio") == -1) - return true - - when (item.itemId) { - - R.id.checkIcon -> { - - if (BaresipService.callVolume != callVolume) { - BaresipService.callVolume = callVolume - Config.replaceVariable("call_volume", callVolume.toString()) - save = true + var gain = newMicGain.trim() + if (!gain.contains(".")) + gain = "$gain.0" + if (gain != oldMicGain) { + if (!checkMicGain(gain)) { + Utils.alertView( + this, getString(R.string.notice), + "${getString(R.string.invalid_microphone_gain)}: $gain." + ) + return } - - var gain = "1.0" - if (!BaresipService.agcAvailable) { - gain = micGain.text.toString().trim() - if (!gain.contains(".")) - gain = "$gain.0" - if (gain != oldMicGain) { - if (!checkMicGain(gain)) { + if (gain == "1.0") { + Api.module_unload("augain") + Config.removeVariableValue("module", "augain.so") + Config.replaceVariable("augain", "1.0") + } else { + if (oldMicGain == "1.0") { + if (Api.module_load("augain") != 0) { Utils.alertView( - this, getString(R.string.notice), - "${getString(R.string.invalid_microphone_gain)}: $gain." + this, getString(R.string.error), + getString(R.string.failed_to_load_module) ) - micGain.setText(oldMicGain) - return false + return } - if (gain == "1.0") { - Api.module_unload("augain") - Config.removeVariableValue("module", "augain.so") - Config.replaceVariable("augain", "1.0") - } else { - if (oldMicGain == "1.0") { - if (Api.module_load("augain") != 0) { - Utils.alertView( - this, getString(R.string.error), - getString(R.string.failed_to_load_module) - ) - micGain.setText(oldMicGain) - return false - } - Config.addVariable("module", "augain.so") - } - Config.replaceVariable("augain", gain) - Api.cmd_exec("augain $gain") - } - save = true + Config.addVariable("module", "augain.so") } + Config.replaceVariable("augain", gain) + Api.cmd_exec("augain $gain") } + save = true + } + } - if (speakerPhone.isChecked != BaresipService.speakerPhone) { - BaresipService.speakerPhone = speakerPhone.isChecked - Config.replaceVariable("speaker_phone", - if (BaresipService.speakerPhone) "yes" else "no") - save = true - } + if (newSpeakerPhone != BaresipService.speakerPhone) { + BaresipService.speakerPhone = newSpeakerPhone + Config.replaceVariable("speaker_phone", + if (BaresipService.speakerPhone) "yes" else "no") + save = true + } - var id = 1001 - for (module in audioModules) { - val box = findViewById(id++) - if (box.isChecked && !oldAudioModules[module]!!) { + for (module in audioModules) { + if (newAudioModules[module] != null) { + if (newAudioModules[module]!!) { + if (!modules.contains("${module}.so")) { if (Api.module_load("${module}.so") != 0) { - Utils.alertView(this, getString(R.string.error), - "${getString(R.string.failed_to_load_module)}: ${module}.so") - return false + Utils.alertView( + this, getString(R.string.error), + "${getString(R.string.failed_to_load_module)}: ${module}.so" + ) + return } Config.addVariable("module", "${module}.so") save = true } - if (!box.isChecked && oldAudioModules[module]!!) { - Api.module_unload("${module}.so") - Config.removeVariableValue("module", "${module}.so") - for (ua in BaresipService.uas) - ua.account.removeAudioCodecs(module) - AccountsActivity.saveAccounts() - save = true - } - id++ - } - - val opusBitRate = opusBitRate.text.toString().trim() - if (opusBitRate != oldOpusBitrate) { - if (!checkOpusBitRate(opusBitRate)) { - Utils.alertView(this, getString(R.string.notice), - "${getString(R.string.invalid_opus_bitrate)}: $opusBitRate.") - return false - } - Config.replaceVariable("opus_bitrate", opusBitRate) - restart = true + } else if (modules.contains("${module}.so")) { + Api.module_unload("${module}.so") + Config.removeVariableValue("module", "${module}.so") + for (ua in BaresipService.uas.value) + ua.account.removeAudioCodecs(module) + AccountsActivity.saveAccounts() save = true } - - val opusPacketLoss = opusPacketLoss.text.toString().trim() - if (opusPacketLoss != oldOpusPacketLoss) { - if (!checkOpusPacketLoss(opusPacketLoss)) { - Utils.alertView(this, getString(R.string.notice), - "${getString(R.string.invalid_opus_packet_loss)}: $opusPacketLoss") - return false - } - Config.replaceVariable("opus_packet_loss", opusPacketLoss) - restart = true - save = true - } - - if (aec.isChecked != oldAec) { - if (aec.isChecked) { - Config.addVariable("module", "webrtc_aecm.so") - if (gain != "1.0") { - restart = true - } else { - if (Api.module_load("webrtc_aecm.so") != 0) { - Utils.alertView( - this, getString(R.string.error), - getString(R.string.failed_to_load_module) - ) - aec.isChecked = false - return false - } - } - BaresipService.webrtcAec = true - } else { - Api.module_unload("webrtc_aecm.so") - Config.removeVariableValue("module", "webrtc_aecm.so") - BaresipService.webrtcAec = false - } - save = true - } - - val audioDelay = audioDelay.text.toString().trim() - if (audioDelay != BaresipService.audioDelay.toString()) { - if (!checkAudioDelay(audioDelay)) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_audio_delay), audioDelay)) - return false - } - Config.replaceVariable("audio_delay", audioDelay) - BaresipService.audioDelay = audioDelay.toLong() - save = true - } - - if (BaresipService.toneCountry != toneCountry) { - BaresipService.toneCountry = toneCountry - Config.replaceVariable("tone_country", toneCountry) - save = true - } - - if (save) - Config.save() - - setResult(if (restart) RESULT_OK else RESULT_CANCELED) - - BaresipService.activities.remove("audio") - finish() - return true } - - android.R.id.home -> { - goBack() - return true - } - } - return super.onOptionsItemSelected(item) + if (newOpusBitrate != oldOpusBitrate) { + if (!checkOpusBitRate(newOpusBitrate)) { + Utils.alertView(this, getString(R.string.notice), + "${getString(R.string.invalid_opus_bitrate)}: $newOpusBitrate.") + return + } + Config.replaceVariable("opus_bitrate", newOpusBitrate) + restart = true + save = true + } + if (newOpusPacketLoss != oldOpusPacketLoss) { + if (!checkOpusPacketLoss(newOpusPacketLoss)) { + Utils.alertView(this, getString(R.string.notice), + "${getString(R.string.invalid_opus_packet_loss)}: $newOpusPacketLoss") + return + } + Config.replaceVariable("opus_packet_loss", newOpusPacketLoss) + restart = true + save = true + } + + val audioDelay = newAudioDelay.trim() + if (audioDelay != BaresipService.audioDelay.toString()) { + if (!checkAudioDelay(audioDelay)) { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_audio_delay), audioDelay)) + return + } + Config.replaceVariable("audio_delay", audioDelay) + BaresipService.audioDelay = audioDelay.toLong() + save = true + } + + if (BaresipService.toneCountry != newToneCountry) { + BaresipService.toneCountry = newToneCountry + Config.replaceVariable("tone_country", newToneCountry) + save = true + } + + if (save) + Config.save() + + setResult(if (restart) RESULT_OK else RESULT_CANCELED) + + BaresipService.activities.remove("audio") + finish() } private fun goBack() { @@ -371,46 +645,6 @@ class AudioActivity : AppCompatActivity() { finish() } - private fun bindTitles() { - binding.VolumeTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.default_call_volume), - getString(R.string.default_call_volume_help)) - } - binding.MicGainTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.microphone_gain), - getString(R.string.microphone_gain_help) - ) - } - binding.SpeakerPhoneTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.speaker_phone), - getString(R.string.speaker_phone_help)) - } - binding.AudioModulesTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.audio_modules_title), - getString(R.string.audio_modules_help)) - } - binding.OpusBitRateTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.opus_bit_rate), - getString(R.string.opus_bit_rate_help)) - } - binding.OpusPacketLossTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.opus_packet_loss), - getString(R.string.opus_packet_loss_help)) - } - binding.AecTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.aec), - getString(R.string.aec_help)) - } - binding.AudioDelayTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.audio_delay), - getString(R.string.audio_delay_help)) - } - binding.ToneCountryTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.tone_country), - getString(R.string.tone_country_help)) - } - } - private fun checkMicGain(micGain: String): Boolean { val number = try { diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactActivity.kt new file mode 100644 index 00000000..ac24f3af --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactActivity.kt @@ -0,0 +1,704 @@ +package com.tutpro.baresip + +import android.content.ContentProviderOperation +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.os.Bundle +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds +import android.provider.ContactsContract.Contacts.Data +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import coil.compose.rememberAsyncImagePainter +import com.tutpro.baresip.CustomElements.Checkbox +import java.io.ByteArrayOutputStream +import java.io.File + +class BaresipContactActivity : ComponentActivity() { + + private lateinit var name: String + private lateinit var uri: String + private var new = false + private var favorite = false + private var android = false + private var avatarImage: Bitmap? = null + private var newName = "" + private var newUri = "" + private var newFavorite = false + private var newAndroid = false + private var uriOrName = "" + private var color = 0 + private var id: Long = 0 + private var newAvatar = "" + private var tmpFile: File? = null + + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + goBack() + } + } + + public override fun onCreate(savedInstanceState: Bundle?) { + + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + val title: String + + new = intent.getBooleanExtra("new", false) + + if (new) { + name = "" + uri = intent.getStringExtra("uri")!! + favorite = false + avatarImage = null + android = BaresipService.contactsMode == "android" + title = getString(R.string.new_contact) + uriOrName = uri + color = Utils.randomColor() + id = System.currentTimeMillis() + } else { + name = intent.getStringExtra("name")!! + val contact = Contact.baresipContact(name)!! + uri = contact.uri + favorite = contact.favorite + avatarImage = contact.avatarImage + android = false + title = name + uriOrName = name + color = contact.color + id = contact.id + } + + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + ContactScreen(this, title) { goBack() } + } + } + } + + Utils.addActivity("baresip contact,$new,$uriOrName") + + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + @Composable + fun ContactScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier.fillMaxHeight().imePadding().safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { TopAppBar(ctx, title, navigateBack) }, + content = { contentPadding -> + ContactContent(ctx, contentPadding) + } + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopAppBar(ctx: Context, title: String, navigateBack: () -> Unit) { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.grayLight, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton(onClick = { + checkOnClick(ctx) + }) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + } + ) + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun ContactContent(ctx: Context, contentPadding: PaddingValues) { + + var avatarType by remember { mutableStateOf(if (avatarImage != null) "image" else "text") } + var textAvatarText by remember { mutableStateOf("") } + var textAvatarColor by remember { mutableIntStateOf(0) } + var imageAvatarUri by remember { mutableStateOf("") } + + val avatarRequest = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + if (it != null) + try { + val inputStream = baseContext.contentResolver.openInputStream(it) + val avatarBitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + val scaledBitmap = avatarBitmap.scale(192, 192) + val exif = ExifInterface(baseContext.contentResolver.openInputStream(it)!!) + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + val rotatedBitmap = rotateBitmap(scaledBitmap, orientation) + tmpFile = File( + BaresipService.filesPath + "/tmp", + "${System.currentTimeMillis()}.png" + ) + if (Utils.saveBitmap(rotatedBitmap, tmpFile!!)) { + newAvatar = "image" + avatarType = newAvatar + imageAvatarUri = Uri.fromFile(tmpFile).toString() + } + } catch (e: Exception) { + Log.e(TAG, "Could not read avatar image: $e") + } + } + + @Composable + fun TextAvatar(size: Int) { + Box( + modifier = Modifier.size(size.dp), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle(SolidColor(Color(textAvatarColor))) + } + textAvatarText = if (name == "") "" else name[0].toString() + Text(textAvatarText, fontSize = 72.sp, color = Color.White) + } + } + + @Composable + fun ImageAvatar(size: Int) { + Image( + painter = rememberAsyncImagePainter(model = imageAvatarUri), + contentDescription = stringResource(R.string.avatar_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(size.dp).clip(CircleShape) + ) + } + + @Composable + fun avatar() { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (new) { + textAvatarText = "?" + textAvatarColor = color + } else { + val avatarImage = avatarImage + val avatarFile = File(BaresipService.filesPath, "${id}.png") + if (avatarImage != null && (newAvatar == "" || newAvatar == "image")) { + imageAvatarUri = if (tmpFile != null && tmpFile!!.exists()) + Uri.fromFile(tmpFile).toString() + else + Uri.fromFile(avatarFile).toString() + } + else { + textAvatarText = "${name[0]}" + textAvatarColor = color + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(96.dp) + .combinedClickable( + onClick = { + avatarRequest.launch("image/*") + }, + onLongClick = { + textAvatarColor = Utils.randomColor() + color = textAvatarColor + newAvatar = "text" + avatarType = newAvatar + } + ), + ) { + if (avatarType == "text") + TextAvatar(96) + else + ImageAvatar(96) + } + } + } + + @Composable + fun contactName() { + var contactName by remember { mutableStateOf(name) } + newName = contactName + OutlinedTextField( + value = contactName, + placeholder = { Text(stringResource(R.string.contact_name)) }, + onValueChange = { + contactName = it + newName = contactName + }, + modifier = Modifier.fillMaxWidth(), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { Text(stringResource(R.string.contact_name)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Sentences + ) + ) + } + + @Composable + fun contactUri() { + var contactUri by remember { mutableStateOf(uri) } + newUri = contactUri + OutlinedTextField( + value = contactUri, + placeholder = { Text(stringResource(R.string.user_domain_or_number)) }, + onValueChange = { + contactUri = it + newUri = contactUri + }, + modifier = Modifier.fillMaxWidth(), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { Text(stringResource(R.string.sip_or_tel_uri)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + + @Composable + fun favorite(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.favorite), + modifier = Modifier.weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.favorite), getString(R.string.favorite_help) + ) + }, + color = LocalCustomColors.current.itemText, + ) + var favoriteContact by remember { mutableStateOf(favorite) } + newFavorite = favoriteContact + Checkbox( + checked = favoriteContact, + onCheckedChange = { + favoriteContact = it + newFavorite = favoriteContact + } + ) + } + } + + @Composable + fun android() { + if (new && BaresipService.contactsMode != "baresip") + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.android), + modifier = Modifier.weight(1f), + color = LocalCustomColors.current.itemText + ) + var androidContact by remember { mutableStateOf(android) } + newAndroid = androidContact + Checkbox( + checked = androidContact, + onCheckedChange = { + if (BaresipService.contactsMode != "android") { + androidContact = it + newAndroid = androidContact + } + } + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + avatar() + contactName() + contactUri() + favorite(ctx) + android() + } + } + + private fun checkOnClick(ctx: Context) { + if (BaresipService.activities.indexOf("baresip contact,$new,$uriOrName") == -1) + return + + newUri = newUri.filterNot{setOf('-', ' ', '(', ')').contains(it)} + if (!newUri.startsWith("sip:") && !newUri.startsWith("tel:")) + newUri = if (Utils.isTelNumber(newUri)) + "tel:$newUri" + else + "sip:$newUri" + if (!Utils.checkUri(newUri)) { + Utils.alertView( + ctx, getString(R.string.notice), + String.format(getString(R.string.invalid_sip_or_tel_uri), newUri) + ) + return + } + + newName = newName.trim() + if (newName == "") newName = newUri.substringAfter(":") + if (!Utils.checkName(newName)) { + Utils.alertView( + ctx, getString(R.string.notice), + String.format(getString(R.string.invalid_contact), newName) + ) + return + } + + val alert: Boolean = if (new) + Contact.nameExists(newName, BaresipService.contacts,true) + else { + (uriOrName != newName) && Contact.nameExists(newName, BaresipService.contacts, false) + } + if (alert) { + Utils.alertView( + ctx, getString(R.string.notice), + String.format(getString(R.string.contact_already_exists), newName)) + return + } + + val contact: Contact.BaresipContact + + if (new) { + contact = Contact.BaresipContact(newName, newUri, color, id, newFavorite) + } else { + contact = Contact.baresipContact(name)!! + contact.uri = newUri + contact.name = newName + contact.color = color + contact.favorite = newFavorite + } + + when (newAvatar) { + "text" -> { + if (contact.avatarImage != null) { + contact.avatarImage = null + Utils.deleteFile(File(BaresipService.filesPath, "${contact.id}.png")) + } + } + "image" -> { + val imageFilePath = BaresipService.filesPath + "/${contact.id}.png" + val imageFile = File(imageFilePath) + tmpFile!!.copyTo(target = imageFile, overwrite = true) + contact.avatarImage = BitmapFactory.decodeFile(imageFilePath) + } + } + + + if (newAndroid) + addOrUpdateAndroidContact(ctx, contact) + else { + if (new) + Contact.addBaresipContact(contact) + else + Contact.updateBaresipContact(contact) + } + + Contact.contactsUpdate() + + Contact.saveBaresipContacts() + + BaresipService.activities.remove("baresip contact,$new,$uriOrName") + + val i = Intent(ctx, MainActivity::class.java) + if (newAndroid && contact.favorite) + i.putExtra("name", newName) + setResult(RESULT_OK, i) + finish() + } + + private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_NORMAL -> return bitmap + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.setRotate(180f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) + else -> return bitmap + } + val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, + bitmap.width, bitmap.height, matrix, true) + bitmap.recycle() + return rotatedBitmap + } + + private fun addOrUpdateAndroidContact(ctx: Context, contact: Contact.BaresipContact) { + val projection = arrayOf(ContactsContract.Data.RAW_CONTACT_ID) + val selection = ContactsContract.Data.MIMETYPE + "='" + + CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE + "' AND " + + CommonDataKinds.StructuredName.DISPLAY_NAME + "='" + contact.name + "'" + val c: Cursor? = ctx.contentResolver.query( + ContactsContract.Data.CONTENT_URI, projection, + selection, null, null) + if (c != null && c.moveToFirst()) { + updateAndroidContact(c.getLong(0), contact) + } else { + addAndroidContact(ctx, contact) + } + c?.close() + } + + private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Boolean { + val ops = ArrayList() + ops.add( + ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null).build()) + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name) + .build()) + val mimeType = if (contact.uri.startsWith("sip:")) + CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE + else + CommonDataKinds.Phone.CONTENT_ITEM_TYPE + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValue(Data.MIMETYPE, mimeType) + .withValue(Data.DATA1, contact.uri.substringAfter(":")) + .build()) + + if (contact.avatarImage != null) { + val photoData: ByteArray? = bitmapToPNGByteArray(contact.avatarImage!!) + if (photoData != null) { + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.Photo.PHOTO, photoData) + .build()) + } + } + try { + ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } catch (e: Exception) { + Log.e(TAG, "Adding of contact ${contact.name} failed") + return false + } + return true + } + + private fun updateAndroidContact(rawContactId: Long, contact: Contact.BaresipContact) { + if (updateAndroidUri(rawContactId, contact.uri) == 0) + addAndroidUri(rawContactId, contact.uri) + if (updateAndroidPhoto(rawContactId, contact.avatarImage) == 0) + if (contact.avatarImage != null) + addAndroidPhoto(rawContactId, contact.avatarImage!!) + } + + private fun addAndroidUri(rawContactId: Long, uri: String) { + val mimeType = if (uri.startsWith("sip:")) + CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE + else + CommonDataKinds.Phone.CONTENT_ITEM_TYPE + val ops = ArrayList() + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValue(Data.RAW_CONTACT_ID, rawContactId) + .withValue(Data.MIMETYPE, mimeType) + .withValue(Data.DATA1, uri.substringAfter(":")) + .build()) + try { + contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } catch (e: Exception) { + Log.e(TAG, "Adding of SIP URI $uri failed") + } + } + + private fun updateAndroidUri(rawContactId: Long, uri: String): Int { + val mimeType = if (uri.startsWith("sip:")) + CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE + else + CommonDataKinds.Phone.CONTENT_ITEM_TYPE + val contentValues = ContentValues() + contentValues.put(ContactsContract.Data.DATA1, uri) + val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + + "${ContactsContract.Data.MIMETYPE}='$mimeType'" + return try { + contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) + } catch (e: Exception) { + Log.e(TAG, "Update of Android URI $uri failed") + 0 + } + } + + private fun addAndroidPhoto(rawContactId: Long, photoBits: Bitmap) { + val photoBytes = bitmapToPNGByteArray(photoBits) + if (photoBytes != null) { + val ops = ArrayList() + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValue(Data.RAW_CONTACT_ID, rawContactId) + .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.Photo.PHOTO, photoBytes) + .build()) + try { + contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } catch (e: Exception) { + Log.e(TAG, "Adding of Android photo failed") + } + } + } + + private fun updateAndroidPhoto(rawContactId: Long, photoBits: Bitmap?): Int { + val photoBytes = if (photoBits == null) + null + else + bitmapToPNGByteArray(photoBits) + val contentValues = ContentValues() + contentValues.put(CommonDataKinds.Photo.PHOTO, photoBytes) + val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + + "${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Photo.CONTENT_ITEM_TYPE}'" + return try { + contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) + } catch (e: Exception) { + Log.e(TAG, "updateAndroidPhoto failed") + 0 + } + } + + private fun bitmapToPNGByteArray(bitmap: Bitmap): ByteArray? { + val size = bitmap.width * bitmap.height * 4 + val out = ByteArrayOutputStream(size) + return try { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + out.flush() + out.close() + out.toByteArray() + } catch (e: Exception) { + Log.w(TAG, "Unable to serialize photo: $e") + null + } + } + + private fun goBack() { + BaresipService.activities.remove("baresip contact,$new,$uriOrName") + setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java)) + finish() + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index 8fe74f68..be92df10 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -2,7 +2,11 @@ package com.tutpro.baresip import android.Manifest import android.annotation.SuppressLint -import android.app.* +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothHeadset import android.bluetooth.BluetoothManager @@ -13,20 +17,37 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL +import android.content.res.Configuration import android.database.ContentObserver -import android.media.* +import android.media.AudioDeviceInfo +import android.media.AudioManager import android.media.AudioManager.MODE_IN_COMMUNICATION import android.media.AudioManager.MODE_NORMAL import android.media.AudioManager.RINGER_MODE_SILENT +import android.media.MediaPlayer +import android.media.Ringtone +import android.media.RingtoneManager import android.media.audiofx.AcousticEchoCanceler import android.media.audiofx.AutomaticGainControl import android.media.audiofx.NoiseSuppressor -import android.net.* +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.net.wifi.WifiManager -import android.os.* import android.os.Build.VERSION +import android.os.CountDownTimer +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.PowerManager +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import android.provider.ContactsContract import android.provider.Settings +import android.system.OsConstants import android.telecom.TelecomManager import android.text.Spannable import android.text.SpannableString @@ -37,7 +58,10 @@ import android.widget.Toast import androidx.annotation.ColorRes import androidx.annotation.Keep import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -49,7 +73,9 @@ import java.io.File import java.net.InetAddress import java.nio.charset.Charset import java.nio.charset.StandardCharsets -import java.util.* +import java.util.GregorianCalendar +import java.util.Timer +import java.util.TimerTask import kotlin.concurrent.schedule import kotlin.math.roundToInt @@ -99,7 +125,7 @@ class BaresipService: Service() { filesPath = filesDir.absolutePath pName = packageName - am = applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + am = applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager val ntUri = RingtoneManager.getActualDefaultRingtoneUri(applicationContext, RingtoneManager.TYPE_NOTIFICATION) @@ -113,14 +139,14 @@ class BaresipService: Service() { createNotificationChannels() snb = NotificationCompat.Builder(this, DEFAULT_CHANNEL_ID) - pm = getSystemService(Context.POWER_SERVICE) as PowerManager + pm = getSystemService(POWER_SERVICE) as PowerManager vibrator = if (VERSION.SDK_INT >= 31) { - val vibratorManager = applicationContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + val vibratorManager = applicationContext.getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager vibratorManager.defaultVibrator } else { @Suppress("DEPRECATION") - applicationContext.getSystemService(AppCompatActivity.VIBRATOR_SERVICE) as Vibrator + applicationContext.getSystemService(VIBRATOR_SERVICE) as Vibrator } // This is needed to keep service running also in Doze Mode @@ -133,7 +159,7 @@ class BaresipService: Service() { } } - cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager val builder = NetworkRequest.Builder() .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) cm.registerNetworkCallback( @@ -182,7 +208,7 @@ class BaresipService: Service() { } ) - wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager hotSpotIsEnabled = Utils.isHotSpotOn(wm) hotSpotReceiver = object : BroadcastReceiver() { @@ -238,9 +264,9 @@ class BaresipService: Service() { this.registerReceiver(hotSpotReceiver, IntentFilter("android.net.wifi.WIFI_AP_STATE_CHANGED")) - tm = getSystemService(Context.TELECOM_SERVICE) as TelecomManager + tm = getSystemService(TELECOM_SERVICE) as TelecomManager - btm = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + btm = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager btAdapter = btm.adapter proximityWakeLock = pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, @@ -530,10 +556,7 @@ class BaresipService: Service() { if (ua == null) Log.w(TAG, "onStartCommand did not find UA $uap") else - ChatsActivity.saveUaMessage( - ua.account.aor, - intent.getStringExtra("time")!!.toLong() - ) + Message.updateAorMessage(ua.account.aor, intent.getStringExtra("time")!!.toLong()) nm.cancel(MESSAGE_NOTIFICATION_ID) } @@ -543,10 +566,8 @@ class BaresipService: Service() { if (ua == null) Log.w(TAG, "onStartCommand did not find UA $uap") else - ChatsActivity.deleteUaMessage( - ua.account.aor, - intent.getStringExtra("time")!!.toLong() - ) + Message.deleteAorMessage(ua.account.aor, intent.getStringExtra("time")!!.toLong()) + nm.cancel(MESSAGE_NOTIFICATION_ID) } @@ -571,6 +592,20 @@ class BaresipService: Service() { return START_STICKY } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val currentNightMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK + when (currentNightMode) { + Configuration.UI_MODE_NIGHT_NO -> { + darkTheme.value = Preferences(applicationContext).displayTheme == + AppCompatDelegate.MODE_NIGHT_YES + } + Configuration.UI_MODE_NIGHT_YES -> { + darkTheme.value = true + } + } + } + override fun onBind(intent: Intent): IBinder? { return null } @@ -598,7 +633,7 @@ class BaresipService: Service() { R.drawable.circle_white else R.drawable.circle_yellow - uas.add(ua) + ua.add() val acc = ua.account if (acc.authUser != "" && acc.authPass == NO_AUTH_PASS) { @@ -629,6 +664,47 @@ class BaresipService: Service() { if (ev[0] == "recorder sessionid") { recorderSessionId = ev[1].toInt() Log.d(TAG, "got recorder sessionid $recorderSessionId") + if (recorderSessionId != 0) { + if (!webrtcAec) { + aec = AcousticEchoCanceler.create(recorderSessionId) + if (aec != null) { + if (!aec!!.getEnabled()) { + aec!!.setEnabled(true) + if (aec!!.getEnabled()) + Log.d(TAG, "AEC is enabled") + else + Log.w(TAG, "Failed to enable AEC") + } + } else + Log.w( + TAG, "Failed to create AEC for session " + + "$recorderSessionId" + ) + } + if (agcAvailable) { + agc = AutomaticGainControl.create(recorderSessionId) + if (agc != null) { + if (!agc!!.getEnabled()) { + agc!!.setEnabled(true) + if (agc!!.getEnabled()) + Log.d(TAG, "AGC is enabled") + } + } else + Log.w(TAG, "Failed to create AGC") + } + if (nsAvailable) { + ns = NoiseSuppressor.create(recorderSessionId) + if (ns != null) { + if (!ns!!.getEnabled()) { + ns!!.setEnabled(true) + if (ns!!.getEnabled()) + Log.d(TAG, "NS is enabled") + } + } else + Log.w(TAG, "Failed to create NS") + } + recorderSessionId = 0 + } return } @@ -649,31 +725,32 @@ class BaresipService: Service() { return } - for (accountIndex in uas.indices) { - if (uas[accountIndex].account.aor == aor) { + for (accountIndex in uas.value.indices) { + if (uas.value[accountIndex].account.aor == aor) { when (ev[0]) { "registering", "unregistering" -> { - ua.status = R.drawable.circle_yellow updateStatusNotification() + ua.uaUpdateStatus(R.drawable.circle_yellow) if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) return } "registered" -> { - ua.status = if (Api.account_regint(ua.account.accp) == 0) - R.drawable.circle_white - else - R.drawable.circle_green + ua.uaUpdateStatus( + if (Api.account_regint(ua.account.accp) == 0) + R.drawable.circle_white + else + R.drawable.circle_green) updateStatusNotification() if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) return } "registering failed" -> { - ua.status = if (Api.account_regint(ua.account.accp) == 0) + ua.uaUpdateStatus(if (Api.account_regint(ua.account.accp) == 0) R.drawable.circle_white else - R.drawable.circle_red + R.drawable.circle_red) updateStatusNotification() if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) @@ -752,7 +829,7 @@ class BaresipService: Service() { "call incoming" -> { val peerUri = ev[1] Log.d(TAG, "Incoming call $uap/$callp/$peerUri") - Call(callp, ua, peerUri, "in", "incoming", Utils.dtmfWatcher(callp)).add() + Call(callp, ua, peerUri, "in", "incoming", null).add() if (speakerPhone && !Utils.isSpeakerPhoneOn(am)) Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(this), am) if (ua.account.answerMode == Api.ANSWERMODE_MANUAL) { @@ -827,47 +904,6 @@ class BaresipService: Service() { if (am.mode != MODE_IN_COMMUNICATION) am.mode = MODE_IN_COMMUNICATION call!!.status = "connected" - if (recorderSessionId != 0) { - if (Utils.isAecSupported() && !webrtcAec) { - aec = AcousticEchoCanceler.create(recorderSessionId) - if (aec != null) { - if (!aec!!.getEnabled()) { - aec!!.setEnabled(true) - if (aec!!.getEnabled()) - Log.d(TAG, "AEC is enabled") - else - Log.w(TAG, "Failed to enable AEC") - } - } else - Log.w( - TAG, "Failed to create AEC for session " + - "$recorderSessionId" - ) - } - if (agcAvailable) { - agc = AutomaticGainControl.create(recorderSessionId) - if (agc != null) { - if (!agc!!.getEnabled()) { - agc!!.setEnabled(true) - if (agc!!.getEnabled()) - Log.d(TAG, "AGC is enabled") - } - } else - Log.w(TAG, "Failed to create AGC") - } - if (nsAvailable) { - ns = NoiseSuppressor.create(recorderSessionId) - if (ns != null) { - if (!ns!!.getEnabled()) { - ns!!.setEnabled(true) - if (ns!!.getEnabled()) - Log.d(TAG, "ns is enabled") - } - } else - Log.w(TAG, "Failed to create NS") - } - recorderSessionId = 0 - } call.onhold = false if (ua.account.callHistory) call.startTime = GregorianCalendar() @@ -951,8 +987,11 @@ class BaresipService: Service() { stopRinging() stopMediaPlayer() aec?.release() + aec = null agc?.release() + agc = null ns?.release() + ns = null val newCall = call.newCall if (newCall != null) { newCall.onHoldCall = null @@ -1237,14 +1276,14 @@ class BaresipService: Service() { 2-> R.id.status2 else -> R.id.status3 } - if (i < uas.size) { - notificationLayout.setImageViewResource(resId, uas[i].status) + if (i < uas.value.size) { + notificationLayout.setImageViewResource(resId, uas.value[i].status) notificationLayout.setViewVisibility(resId, View.VISIBLE) } else { notificationLayout.setViewVisibility(resId, View.INVISIBLE) } } - if (uas.size > 4) + if (uas.value.size > 4) notificationLayout.setViewVisibility(R.id.etc, View.VISIBLE) else notificationLayout.setViewVisibility(R.id.etc, View.INVISIBLE) @@ -1467,7 +1506,7 @@ class BaresipService: Service() { if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND)) { val props = cm.getLinkProperties(n) ?: continue for (la in props.linkAddresses) - if (la.scope == android.system.OsConstants.RT_SCOPE_UNIVERSE && + if (la.scope == OsConstants.RT_SCOPE_UNIVERSE && props.interfaceName != null && la.address.hostAddress != null && afMatch(la.address.hostAddress!!)) addresses[la.address.hostAddress!!] = props.interfaceName!! @@ -1544,9 +1583,10 @@ class BaresipService: Service() { stopRinging() stopMediaPlayer() abandonAudioFocus(applicationContext) - uas.clear() + uas.value = emptyList() + uasStatus.value = emptyMap() callHistory.clear() - messages.clear() + messages = emptyList() if (this::nm.isInitialized) nm.cancelAll() if (this::partialWakeLock.isInitialized && partialWakeLock.isHeld) @@ -1590,17 +1630,20 @@ class BaresipService: Service() { var isRecOn = false var toneCountry = "us" - val uas = ArrayList() + val uas = mutableStateOf(emptyList()) + val uasStatus = mutableStateOf(emptyMap()) + var contacts by mutableStateOf(emptyList()) + val darkTheme = mutableStateOf(false) + var messages by mutableStateOf(emptyList()) + val calls = ArrayList() var callHistory = ArrayList() - var messages = ArrayList() val messageUpdate = MutableLiveData() val contactUpdate = MutableLiveData() val registrationUpdate = MutableLiveData() - val baresipContacts = ArrayList() - val androidContacts = ArrayList() - val contacts = ArrayList() - val contactNames = ArrayList() + val baresipContacts = mutableStateOf(emptyList()) + val androidContacts = mutableStateOf(emptyList()) + val contactNames = mutableStateOf(emptyList()) var contactsMode = "baresip" val chatTexts: MutableMap = mutableMapOf() val activities = mutableListOf() @@ -1611,11 +1654,12 @@ class BaresipService: Service() { // of those accounts that have auth username without auth password val aorPasswords = mutableMapOf() var audioFocusRequest: AudioFocusRequestCompat? = null - var agcAvailable = AutomaticGainControl.isAvailable() - private val nsAvailable = NoiseSuppressor.isAvailable() + var aecAvailable = false private var aec: AcousticEchoCanceler? = null - private var ns: NoiseSuppressor? = null + var agcAvailable = false private var agc: AutomaticGainControl? = null + private val nsAvailable = NoiseSuppressor.isAvailable() + private var ns: NoiseSuppressor? = null var webrtcAec = false private var btAdapter: BluetoothAdapter? = null private var recorderSessionId = 0 @@ -1626,7 +1670,7 @@ class BaresipService: Service() { Log.d(TAG, "Already focused") return true } - val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager val attributes = AudioAttributesCompat.Builder() .setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION) .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH) @@ -1647,7 +1691,7 @@ class BaresipService: Service() { } fun abandonAudioFocus(ctx: Context) { - val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager if (audioFocusRequest != null) { Log.d(TAG, "Abandoning audio focus") if (AudioManagerCompat.abandonAudioFocusRequest(am, audioFocusRequest!!) == @@ -1683,7 +1727,7 @@ class BaresipService: Service() { } private fun startBluetoothSco(ctx: Context, delay: Long, count: Int) { - val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager if (isBluetoothScoOn(am)) { Log.d(TAG, "Bluetooth SCO is already on") return @@ -1705,7 +1749,7 @@ class BaresipService: Service() { private fun stopBluetoothSco(ctx: Context) { Log.d(TAG, "Stopping Bluetooth SCO") - val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager if (!isBluetoothScoOn(am)) { Log.d(TAG, "Bluetooth SCO is already off") return @@ -1719,7 +1763,5 @@ class BaresipService: Service() { am.isBluetoothScoOn = false }, 100) } - } - } diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsActivity.kt index 7d5040f2..402c3575 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsActivity.kt @@ -1,24 +1,67 @@ package com.tutpro.baresip +import android.content.Context +import android.media.AudioAttributes import android.media.MediaPlayer -import android.os.Build -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.text.format.DateUtils import android.view.MenuItem -import android.view.View +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding -import com.tutpro.baresip.databinding.ActivityCallDetailsBinding +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.tutpro.baresip.CallsActivity.Companion.uaHistory +import com.tutpro.baresip.CustomElements.verticalScrollbar +import java.io.File +import java.io.IOException +import java.text.DateFormat +import java.util.GregorianCalendar -class CallDetailsActivity : AppCompatActivity() { +class CallDetailsActivity : ComponentActivity() { - private lateinit var binding: ActivityCallDetailsBinding private lateinit var aor: String private lateinit var peer: String + private lateinit var account: Account + private lateinit var callRow: CallRow + private lateinit var details: ArrayList private val decPlayer = MediaPlayer() private val encPlayer = MediaPlayer() private var position = 0 @@ -33,38 +76,282 @@ class CallDetailsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_call_details) + enableEdgeToEdge() - binding = ActivityCallDetailsBinding.inflate(layoutInflater) - setContentView(binding.root) - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.CallDetailsView.updatePadding(top = systemBars.top + 56) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true aor = intent.getStringExtra("aor")!! + account = Account.ofAor(aor)!! peer = intent.getStringExtra("peer")!! position = intent.getIntExtra("position", 0) + callRow = uaHistory.value[position] + details = callRow.details Utils.addActivity("call_details,$aor,$peer,$position") - val headerView = binding.peer - val headerText = "${getString(R.string.peer)} $peer" - headerView.text = headerText - - val listView = binding.calls - listView.adapter = CallDetailsAdapter(this, CallsActivity.uaHistory[position].details, - decPlayer, encPlayer) + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + CallDetailsScreen(this, getString(R.string.call_details)) { goBack() } + } + } + } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun CallDetailsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + ) + }, + content = { contentPadding -> + CallDetailsContent(ctx, contentPadding) + } + ) + } + + @Composable + fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(top = 16.dp, start = 16.dp, end = 4.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Peer(ctx, peer, account) + Calls(ctx) + } + } + + @Composable + fun Peer(ctx: Context, peer: String, account: Account) { + val headerText = getString(R.string.peer) + " " + + Utils.friendlyUri(ctx, peer, account) + Text( + text = headerText, + modifier = Modifier.fillMaxWidth(), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = LocalCustomColors.current.itemText, + textAlign = TextAlign.Center + ) + } + + @Composable + fun Calls(ctx: Context) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = getString(R.string.direction), + color = LocalCustomColors.current.itemText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.width(96.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = getString(R.string.time), + color = LocalCustomColors.current.itemText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.weight(1f)) + Text(text = getString(R.string.calls_duration), + modifier = Modifier.padding(end = 12.dp), + color = LocalCustomColors.current.itemText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(details) { detail -> + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(detail.direction), + contentDescription = "Direction", + modifier = Modifier.width(64.dp) + ) + Spacer(modifier = Modifier.width(38.dp)) + val durationText = startTime(ctx, detail) + Spacer(modifier = Modifier.weight(1f)) + Duration(ctx, detail, durationText) + } + } + } + } + + @Composable + fun startTime(ctx: Context, detail: CallRow.Details): String { + val startTime = detail.startTime + val stopTime = detail.stopTime + val startTimeText: String + val durationText: String + val stopText = if (DateUtils.isToday(stopTime.timeInMillis)) { + val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) + ctx.getString(R.string.today) + " " + fmt.format(stopTime.time) + } else { + val fmt = DateFormat.getDateTimeInstance() + fmt.format(stopTime.time) + } + if (startTime == GregorianCalendar(0, 0, 0)) { + startTimeText = stopText + durationText = "?" + } else { + if (startTime == null) { + startTimeText = stopText + durationText = "" + } else { + val startText = if (DateUtils.isToday(startTime.timeInMillis)) { + val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) + ctx.getString(R.string.today) + " " + fmt.format(startTime.time) + } else { + val fmt = DateFormat.getDateTimeInstance() + fmt.format(startTime.time) + } + startTimeText = startText + val duration = (stopTime.time.time - startTime.time.time) / 1000 + durationText = DateUtils.formatElapsedTime(duration) + } + } + Text(text = startTimeText, color = LocalCustomColors.current.itemText) + return durationText + } + + @Composable + fun Duration(ctx: Context, detail: CallRow.Details, durationText: String) { + val recording = detail.recording + if (recording[0] != "") { + Text( + text = durationText, + color = LocalCustomColors.current.accent, + modifier = Modifier.padding(end = 12.dp) + .clickable(onClick = { + val builder = MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme) + builder.setTitle("Playing recording ...") + .setMessage("") + .setCancelable(true) + val dialog = builder.create() + if (!decPlayer.isPlaying && !encPlayer.isPlaying) { + decPlayer.reset() + encPlayer.reset() + Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}") + decPlayer.apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setOnPreparedListener { + encPlayer.apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setOnPreparedListener { + it.start() + decPlayer.start() + Log.d(TAG, "Started players") + dialog.show() + } + setOnCompletionListener { + Log.d(TAG, "Stopping encPlayer") + it.stop() + dialog.cancel() + } + try { + val file = recording[0] + val encFile = File(file) + .copyTo(File(BaresipService.filesPath + + "/tmp/encode.wav"), true) + val encUri = encFile.toUri() + setDataSource(ctx, encUri) + prepareAsync() + } catch (e: IllegalArgumentException) { + Log.e(TAG, "encPlayer IllegalArgumentException: $e") + } catch (e: IOException) { + Log.e(TAG, "encPlayer IOException: $e") + } catch (e: Exception) { + Log.e(TAG, "encPlayer Exception: $e") + } + } + } + setOnCompletionListener { + Log.d(TAG, "Stopping decPlayer") + it.stop() + } + try { + val file = recording[1] + val decFile = File(file) + .copyTo(File(BaresipService.filesPath + + "/tmp/decode.wav"), true) + val decUri = decFile.toUri() + setDataSource(ctx, decUri) + prepareAsync() + } catch (e: IllegalArgumentException) { + Log.e(TAG, "decPlayer IllegalArgumentException: $e") + } catch (e: IOException) { + Log.e(TAG, "decPlayer IOException: $e") + } catch (e: Exception) { + Log.e(TAG, "decPlayer Exception: $e") + } + } + } else if (decPlayer.isPlaying && encPlayer.isPlaying) { + decPlayer.stop() + encPlayer.stop() + } + }) + ) + } else { + Text(text = durationText, + modifier = Modifier.padding(end = 12.dp), + color = LocalCustomColors.current.itemText) + } + } + override fun onPause() { MainActivity.activityAor = aor super.onPause() diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsAdapter.kt deleted file mode 100644 index 8baef3ec..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsAdapter.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.tutpro.baresip - -import android.annotation.SuppressLint -import android.content.Context -import android.media.AudioAttributes -import android.media.MediaPlayer -import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import java.io.File -import java.io.IOException -import java.text.DateFormat - -import java.util.* - -class CallDetailsAdapter(private val ctx: Context, private val rows: ArrayList, - private val decPlayer: MediaPlayer, private val encPlayer: MediaPlayer) : - ArrayAdapter(ctx, R.layout.call_detail_row, rows) { - - private val layoutInflater = - context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - - private class ViewHolder(view: View?) { - val directionView: ImageView = view?.findViewById(R.id.direction)!! - val timeView: TextView = view?.findViewById(R.id.time)!! - val durationView: TextView = view?.findViewById(R.id.duration)!! - } - - @SuppressLint("SimpleDateFormat") - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.call_detail_row, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val direction = rows[position].direction - viewHolder.directionView.setImageResource(direction) - - val startTime = rows[position].startTime - val stopTime = rows[position].stopTime - - val stopText = if (DateUtils.isToday(stopTime.timeInMillis)) { - val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) - ctx.getString(R.string.today) + " " + fmt.format(stopTime.time) - } else { - val fmt = DateFormat.getDateTimeInstance() - fmt.format(stopTime.time) - } - if (startTime == GregorianCalendar(0, 0, 0)) { - viewHolder.timeView.text = stopText - viewHolder.durationView.text = "?" - } else { - if (startTime == null) { - viewHolder.timeView.text = stopText - viewHolder.durationView.text = "" - } else { - val startText = if (DateUtils.isToday(startTime.timeInMillis)) { - val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) - ctx.getString(R.string.today) + " " + fmt.format(startTime.time) - } else { - val fmt = DateFormat.getDateTimeInstance() - fmt.format(startTime.time) - } - viewHolder.timeView.text = startText - val duration = (stopTime.time.time - startTime.time.time) / 1000 - viewHolder.durationView.text = DateUtils.formatElapsedTime(duration) - val recording = rows[position].recording - if (recording[0] != "") { - viewHolder.durationView.setTextColor(ContextCompat.getColor(context, R.color.colorAccent)) - viewHolder.durationView.setOnClickListener { - if (!decPlayer.isPlaying && !encPlayer.isPlaying) { - decPlayer.reset() - encPlayer.reset() - playRecording(ctx, viewHolder.durationView, recording) - } else if (decPlayer.isPlaying && encPlayer.isPlaying) { - decPlayer.stop() - encPlayer.stop() - viewHolder.durationView.setTextColor(ContextCompat.getColor(ctx, R.color.colorItemText)) - } - } - } - } - } - - return rowView - } - - private fun playRecording(ctx: Context, textView: TextView, recording: Array) { - Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}") - decPlayer.apply { - setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - setOnPreparedListener { - encPlayer.apply { - setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - setOnPreparedListener { - it.start() - decPlayer.start() - textView.setTextColor(ContextCompat.getColor(ctx, R.color.colorAccent)) - Log.d(TAG, "Started players") - } - setOnCompletionListener { - Log.d(TAG, "Stopping encPlayer") - it.stop() - textView.setTextColor(ContextCompat.getColor(ctx, R.color.colorItemText)) - } - try { - val file = recording[0] - val encFile = File(file).copyTo(File(BaresipService.filesPath + "/tmp/encode.wav"), true) - val encUri = encFile.toUri() - setDataSource(ctx, encUri) - prepareAsync() - } catch (e: IllegalArgumentException) { - Log.e(TAG, "encPlayer IllegalArgumentException: $e") - } catch (e: IOException) { - Log.e(TAG, "encPlayer IOException: $e") - } catch (e: Exception) { - Log.e(TAG, "encPlayer Exception: $e") - } - } - } - setOnCompletionListener { - Log.d(TAG, "Stopping decPlayer") - it.stop() - } - try { - val file = recording[1] - val decFile = File(file).copyTo(File(BaresipService.filesPath + "/tmp/decode.wav"), true) - val decUri = decFile.toUri() - setDataSource(ctx, decUri) - prepareAsync() - } catch (e: IllegalArgumentException) { - Log.e(TAG, "decPlayer IllegalArgumentException: $e") - } catch (e: IOException) { - Log.e(TAG, "decPlayer IOException: $e") - } catch (e: Exception) { - Log.e(TAG, "decPlayer Exception: $e") - } - } - return - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallListAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/CallListAdapter.kt deleted file mode 100644 index 2bbee2fb..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/CallListAdapter.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.core.content.ContextCompat - -class CallListAdapter(private val ctx: Context, private val account: Account, - private val rows: ArrayList) : - ArrayAdapter(ctx, R.layout.call_row, rows) { - - private val layoutInflater = LayoutInflater.from(context) - - private class ViewHolder(view: View?) { - val textAvatarView: TextView = view?.findViewById(R.id.TextAvatar)!! - val imageAvatarView: ImageView = view?.findViewById(R.id.ImageAvatar)!! - val directionsView: LinearLayout = view?.findViewById(R.id.directions)!! - val etcView: TextView = view?.findViewById(R.id.etc)!! - val peerURIView: TextView = view?.findViewById(R.id.peer_uri)!! - val timeView: TextView = view?.findViewById(R.id.time)!! - } - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.call_row, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val callRow = rows[position] - - Utils.setAvatar(ctx, viewHolder.imageAvatarView, viewHolder.textAvatarView, callRow.peerUri) - - viewHolder.directionsView.removeAllViews() - var count = 1 - for (d in callRow.details) { - if (d.recording[0] != "") - viewHolder.timeView.setTextColor(ContextCompat.getColor(context, R.color.colorAccent)) - if (count > 3) { - viewHolder.etcView.text = "..." - continue - } - val dirView = ImageView(ctx) - val params = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT) - dirView.layoutParams = params - dirView.setPadding(0, 5, 0, 0) - dirView.setImageResource(d.direction) - viewHolder.directionsView.addView(dirView) - count++ - } - if (count <= 3) - viewHolder.etcView.text = "" - - viewHolder.peerURIView.text = Utils.friendlyUri(ctx, callRow.peerUri, account) - viewHolder.timeView.text = Utils.relativeTime(ctx, callRow.stopTime) - - viewHolder.timeView.setOnClickListener { - val i = Intent(ctx, CallDetailsActivity::class.java) - val b = Bundle() - b.putString("aor", account.aor) - b.putString("peer", viewHolder.peerURIView.text!!.toString()) - b.putInt("position", position) - i.putExtras(b) - ctx.startActivity(i) - } - - return rowView - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsActivity.kt index fe9b0865..e6425aca 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallsActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallsActivity.kt @@ -1,31 +1,75 @@ package com.tutpro.baresip -import android.app.Activity +import android.annotation.SuppressLint +import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.os.Build +import android.graphics.BitmapFactory import android.os.Bundle import android.os.SystemClock import android.view.Menu import android.view.MenuItem -import android.view.View -import android.widget.AdapterView +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tutpro.baresip.databinding.ActivityCallsBinding +import com.tutpro.baresip.Contact.BaresipContact +import com.tutpro.baresip.CustomElements.ImageAvatar +import com.tutpro.baresip.CustomElements.TextAvatar +import com.tutpro.baresip.CustomElements.verticalScrollbar -class CallsActivity : AppCompatActivity() { +class CallsActivity : ComponentActivity() { - private lateinit var binding: ActivityCallsBinding private lateinit var account: Account - private lateinit var clAdapter: CallListAdapter private var aor = "" private var lastClick: Long = 0 @@ -40,153 +84,402 @@ class CallsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) - binding = ActivityCallsBinding.inflate(layoutInflater) - setContentView(binding.root) + enableEdgeToEdge() - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.CallsView.updatePadding(top = systemBars.top + 56) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true + val title = String.format(getString(R.string.call_history)) aor = intent.getStringExtra("aor")!! Utils.addActivity("calls,$aor") val ua = UserAgent.ofAor(aor)!! account = ua.account - - val headerView = binding.account - val headerText = getString(R.string.account) + " " + - if (account.nickName != "") - account.nickName - else - aor.split(":")[1] - headerView.text = headerText - - val listView = binding.calls aorGenerateHistory(aor) - clAdapter = CallListAdapter(this, account, uaHistory) - listView.adapter = clAdapter - listView.isLongClickable = true - listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ -> - val peerUri = uaHistory[pos].peerUri - val peerName = Utils.friendlyUri(this, peerUri, account) - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE, DialogInterface.BUTTON_NEGATIVE -> { - BaresipService.activities.remove("calls,$aor") - MainActivity.activityAor = aor - returnResult() - val i = Intent(this@CallsActivity, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - if (which == DialogInterface.BUTTON_NEGATIVE) - i.putExtra("action", "call") - else - i.putExtra("action", "message") - i.putExtra("uap", ua.uap) - i.putExtra("peer", peerUri) - startActivity(i) - } - - DialogInterface.BUTTON_NEUTRAL -> { - } - } - } - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - with(MaterialAlertDialogBuilder(this@CallsActivity, R.style.AlertDialogTheme)) { - setTitle(R.string.confirmation) - setMessage( - String.format( - getString(R.string.calls_call_message_question), - peerName - ) - ) - setNeutralButton(getString(R.string.cancel), dialogClickListener) - setNegativeButton(getString(R.string.call), dialogClickListener) - setPositiveButton(getString(R.string.send_message), dialogClickListener) - show() + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + CallsScreen(this, title) { goBack() } } } } - val contactRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - clAdapter.notifyDataSetChanged() + account.missedCalls = false + invalidateOptionsMenu() + + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + @Composable + fun CallsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { TopAppBar(title, navigateBack) }, + content = { contentPadding -> + CallsContent(ctx, contentPadding) + } + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopAppBar(title: String, navigateBack: () -> Unit) { + + var expanded by remember { mutableStateOf(false) } + + val delete = String.format(getString(R.string.delete)) + val disable = String.format(getString(R.string.disable_history)) + val enable = String.format(getString(R.string.enable_history)) + + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton( + onClick = { expanded = !expanded } + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Menu", + tint = LocalCustomColors.current.light + ) + } + CustomElements.DropdownMenu(expanded, + { expanded = false }, + listOf(delete, if (account.callHistory) disable else enable), + onItemClick = { selectedItem -> + expanded = false + when (selectedItem) { + delete -> + with( + MaterialAlertDialogBuilder( + this@CallsActivity, + R.style.AlertDialogTheme + ) + ) { + setTitle(R.string.confirmation) + setMessage( + String.format( + getString(R.string.delete_history_alert), + aor.substringAfter(":") + ) + ) + setPositiveButton(delete) { dialog, _ -> + CallHistoryNew.clear(aor) + CallHistoryNew.save() + aorGenerateHistory(aor) + //clAdapter.notifyDataSetChanged() + dialog.dismiss() + } + setNeutralButton(getText(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + show() + } + + disable, enable -> { + account.callHistory = !account.callHistory + AccountsActivity.saveAccounts() + } + } + } + ) + } + ) + } + + @Composable + fun CallsContent(ctx: Context, contentPadding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Account(account) + Calls(ctx, account) + } + } + + @Composable + fun Account(account: Account) { + val headerText = getString(R.string.account) + " " + + if (account.nickName.value != "") + account.nickName.value + else + aor.split(":")[1] + Text( + text = headerText, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = LocalCustomColors.current.itemText, + textAlign = TextAlign.Center + ) + } + + @Composable + fun Calls(ctx: Context, account: Account) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = uaHistory.value) { callRow -> + + var recordings = false + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.combinedClickable( + onClick = { + callMessageClick(ctx, callRow) + }, + onLongClick = { + callMessageLongClick(ctx, callRow) + } + ) + ) { + val uri = callRow.peerUri + when (val contact = Contact.findContact(uri)) { + is Contact.BaresipContact -> { + val avatarImage = contact.avatarImage + if (avatarImage != null) + ImageAvatar(avatarImage) + else + TextAvatar(contact.name, contact.color) + } + is Contact.AndroidContact -> { + val thumbNailUri = contact.thumbnailUri + if (thumbNailUri != null) + AsyncImage( + model = thumbNailUri, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier.size(36.dp).clip(CircleShape), + ) + else + TextAvatar(contact.name, contact.color) + } + null -> { + val avatarImage = BitmapFactory + .decodeResource(ctx.resources, R.drawable.person_image) + ImageAvatar(avatarImage) + } + } + var count = 1 + for (d in callRow.details) { + if (d.recording[0] != "") + recordings = true + if (count > 3) + continue + Image(painterResource(d.direction), "Direction") + count++ + } + if (count > 3) + Text("...", color = LocalCustomColors.current.itemText) + Text(text = Utils.friendlyUri(ctx, callRow.peerUri, account), + modifier = Modifier.padding(start = 8.dp), + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = LocalCustomColors.current.itemText + ) + } + } + Box(modifier = Modifier.width(56.dp)) { + Text( + text = Utils.relativeTime(ctx, callRow.stopTime), + fontSize = 12.sp, + minLines = 2, maxLines = 2, + lineHeight = 16.sp, + textAlign = TextAlign.End, + color = if (recordings) + LocalCustomColors.current.accent + else + LocalCustomColors.current.itemText, + modifier = Modifier.padding(end = 16.dp).width(64.dp) + .clickable(onClick = { + val i = Intent(ctx, CallDetailsActivity::class.java) + val b = Bundle() + b.putString("aor", account.aor) + b.putString("peer", callRow.peerUri) + b.putInt("position", uaHistory.value.indexOf(callRow)) + i.putExtras(b) + ctx.startActivity(i) + }) + ) + } } } + } + } - listView.onItemLongClickListener = AdapterView.OnItemLongClickListener { _, _, pos, _ -> - val peerUri = uaHistory[pos].peerUri - val peerName = Utils.friendlyUri(this, peerUri, account) - val dialogClickListener = DialogInterface.OnClickListener { _, which -> + private fun callMessageClick(ctx: Context, callRow: CallRow) { + val peerUri = callRow.peerUri + val peerName = Utils.friendlyUri(ctx, peerUri, account) + val dialogClickListener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE, DialogInterface.BUTTON_NEGATIVE -> { + BaresipService.activities.remove("calls,$aor") + MainActivity.activityAor = aor + returnResult() + val i = Intent(this@CallsActivity, MainActivity::class.java) + i.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (which == DialogInterface.BUTTON_NEGATIVE) + i.putExtra("action", "call") + else + i.putExtra("action", "message") + i.putExtra("uap", UserAgent.ofAor(aor)!!.uap) + i.putExtra("peer", peerUri) + startActivity(i) + } + + DialogInterface.BUTTON_NEUTRAL -> { + } + } + } + if (SystemClock.elapsedRealtime() - lastClick > 1000) { + lastClick = SystemClock.elapsedRealtime() + with( + MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme) + ) { + setTitle(R.string.confirmation) + setMessage( + String.format(ctx.getString(R.string.contact_action_question), peerName) + ) + setNeutralButton( + ctx.getText(R.string.cancel), + dialogClickListener + ) + setNegativeButton( + ctx.getText(R.string.call), + dialogClickListener + ) + setPositiveButton( + ctx.getText(R.string.send_message), + dialogClickListener + ) + show() + } + } + } + + private fun callMessageLongClick(ctx: Context, callRow: CallRow) { + val peerUri = callRow.peerUri + val peerName = Utils.friendlyUri(ctx, peerUri, account) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_NEGATIVE -> { - val i = Intent(this, ContactActivity::class.java) + val i = Intent(ctx, BaresipContactActivity::class.java) val b = Bundle() b.putBoolean("new", true) b.putString("uri", peerUri) i.putExtras(b) - contactRequest.launch(i) + ctx.startActivity(i) } - DialogInterface.BUTTON_POSITIVE -> { - removeUaHistoryAt(pos) + removeFromHistory(callRow) CallHistoryNew.save() - clAdapter.notifyDataSetChanged() + /// clAdapter.notifyDataSetChanged() } - DialogInterface.BUTTON_NEUTRAL -> { } } } - val callText: String = if (uaHistory[pos].details.size > 1) - getString(R.string.calls_calls) - else - getString(R.string.calls_call) - val builder = MaterialAlertDialogBuilder(this@CallsActivity, R.style.AlertDialogTheme) - if (!Contact.nameExists(peerName, false)) - with(builder) { - setTitle(R.string.confirmation) - setMessage( - String.format( - getString(R.string.calls_add_delete_question), - peerName, callText - ) + val callText: String = if (callRow.details.size > 1) + getString(R.string.calls_calls) + else + getString(R.string.calls_call) + val builder = MaterialAlertDialogBuilder( + this@CallsActivity, + R.style.AlertDialogTheme + ) + if (!Contact.nameExists(peerName, BaresipService.contacts, false)) + with(builder) { + setTitle(R.string.confirmation) + setMessage( + String.format( + getString(R.string.calls_add_delete_question), + peerName, callText ) - setNeutralButton(getString(R.string.cancel), dialogClickListener) - setPositiveButton(getString(R.string.delete), dialogClickListener) - setNegativeButton(getString(R.string.add_contact), dialogClickListener) - show() - } - else - with(builder) { - setTitle(R.string.confirmation) - setMessage( - String.format( - getString(R.string.calls_delete_question), - peerName, callText - ) + ) + setNeutralButton( + getString(R.string.cancel), + dialogClickListener + ) + setPositiveButton( + getString(R.string.delete), + dialogClickListener + ) + setNegativeButton( + getString(R.string.add_contact), + dialogClickListener + ) + show() + } + else + with(builder) { + setTitle(R.string.confirmation) + setMessage( + String.format( + getString(R.string.calls_delete_question), + peerName, callText ) - setNeutralButton(getString(R.string.cancel), dialogClickListener) - setPositiveButton(getString(R.string.delete), dialogClickListener) - show() - } - true - } - - ua.account.missedCalls = false - invalidateOptionsMenu() - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - + ) + setNeutralButton( + getString(R.string.cancel), + dialogClickListener + ) + setPositiveButton( + getString(R.string.delete), + dialogClickListener + ) + show() + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -205,7 +498,7 @@ class CallsActivity : AppCompatActivity() { CallHistoryNew.clear(aor) CallHistoryNew.save() aorGenerateHistory(aor) - clAdapter.notifyDataSetChanged() + // clAdapter.notifyDataSetChanged() dialog.dismiss() } setNeutralButton(getText(R.string.cancel)) { dialog, _ -> @@ -243,7 +536,7 @@ class CallsActivity : AppCompatActivity() { } private fun returnResult() { - setResult(Activity.RESULT_CANCELED, Intent()) + setResult(RESULT_CANCELED, Intent()) finish() } @@ -263,7 +556,7 @@ class CallsActivity : AppCompatActivity() { } private fun aorGenerateHistory(aor: String) { - uaHistory.clear() + uaHistory.value = emptyList() for (i in BaresipService.callHistory.indices.reversed()) { val h = BaresipService.callHistory[i] if (h.aor == aor) { @@ -286,34 +579,54 @@ class CallsActivity : AppCompatActivity() { R.drawable.call_missed_out } } - if (uaHistory.isNotEmpty() && (uaHistory.last().peerUri == h.peerUri)) - uaHistory.last().details.add(CallRow.Details( + if (uaHistory.value.isNotEmpty() && (uaHistory.value.last().peerUri == h.peerUri)) + uaHistory.value.last().details.add(CallRow.Details( direction, h.startTime, h.stopTime, h.recording )) else - uaHistory.add(CallRow( - h.aor, h.peerUri, direction, h.startTime, h.stopTime, - h.recording - )) + addToUaHistory( + CallRow(h.aor, h.peerUri, direction, h.startTime, h.stopTime, h.recording) + ) } } } - private fun removeUaHistoryAt(i: Int) { - for (details in uaHistory[i].details) { + private fun removeFromHistory(callRow: CallRow) { + for (details in callRow.details) { if (details.recording[0] != "") CallHistoryNew.deleteRecording(details.recording) BaresipService.callHistory.removeAll { it.startTime == details.startTime && it.stopTime == details.stopTime } } - CallHistoryNew.deleteRecording(uaHistory[i].recording) - uaHistory.removeAt(i) + CallHistoryNew.deleteRecording(callRow.recording) + deleteFromUaHistory(callRow) + updateUaHistory() } + private fun addToUaHistory(callRow: CallRow) { + val updatedList = uaHistory.value.toMutableList() + updatedList.add(callRow) + uaHistory.value = updatedList + } + + private fun deleteFromUaHistory(callRow: CallRow) { + val updatedList = uaHistory.value.toMutableList() + updatedList.remove(callRow) + uaHistory.value = updatedList + } + + private fun updateUaHistory() { + val updatedList: MutableList = mutableListOf() + for (h in uaHistory.value) + updatedList.add(h) + uaHistory.value = updatedList + } + + @SuppressLint("MutableCollectionMutableState") companion object { - var uaHistory = ArrayList() + val uaHistory = mutableStateOf(emptyList()) } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatActivity.kt index a158ba47..bc03c3d4 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatActivity.kt @@ -1,45 +1,103 @@ package com.tutpro.baresip -import android.app.Activity import android.content.Context +import android.content.DialogInterface import android.content.Intent -import android.os.Build import android.os.Bundle import android.os.SystemClock +import android.text.format.DateUtils.isToday import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.WindowManager import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import android.widget.ImageButton -import android.widget.ListView import android.widget.Toast +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.Observer -import com.tutpro.baresip.databinding.ActivityChatBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.tutpro.baresip.CustomElements.Text +import com.tutpro.baresip.CustomElements.verticalScrollbar +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.GregorianCalendar -class ChatActivity : AppCompatActivity() { +class ChatActivity : ComponentActivity() { - private lateinit var binding: ActivityChatBinding - private lateinit var chatMessages: ArrayList - private lateinit var mlAdapter: MessageListAdapter - private lateinit var listView: ListView - private lateinit var newMessage: EditText - private lateinit var sendButton: ImageButton private lateinit var imm: InputMethodManager private lateinit var aor: String + private lateinit var account: Account private lateinit var peerUri: String + private lateinit var chatPeer: String private lateinit var ua: UserAgent + private var _chatMessages = mutableStateOf>(emptyList()) + private var chatMessages : List by _chatMessages private var focus = false private var lastCall: Long = 0 + private var unsentMessage = "" + private var keyboardController: SoftwareKeyboardController? = null private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -51,28 +109,15 @@ class ChatActivity : AppCompatActivity() { super.onCreate(savedInstanceState) - binding = ActivityChatBinding.inflate(layoutInflater) - setContentView(binding.root) - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.ChatView.updatePadding(top = systemBars.top + 56) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true - - supportActionBar?.setDisplayHomeAsUpEnabled(true) + enableEdgeToEdge() aor = intent.getStringExtra("aor")!! peerUri = intent.getStringExtra("peer")!! + account = UserAgent.ofAor(aor)!!.account focus = intent.getBooleanExtra("focus", false) if (BaresipService.activities.first().startsWith("chat,$aor,$peerUri")) { - returnResult(Activity.RESULT_CANCELED) + returnResult(RESULT_CANCELED) return } else { Utils.addActivity("chat,$aor,$peerUri,$focus") @@ -82,92 +127,474 @@ class ChatActivity : AppCompatActivity() { if (userAgent == null) { Log.w(TAG, "MessageActivity did not find ua of $aor") MainActivity.activityAor = aor - returnResult(Activity.RESULT_CANCELED) + returnResult(RESULT_CANCELED) return } else { ua = userAgent } + chatPeer = Utils.friendlyUri(this, peerUri, userAgent.account, true) + imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - this@ChatActivity.window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) - val chatPeer = Utils.friendlyUri(this, peerUri, userAgent.account, true) - - title = String.format(getString(R.string.chat_with), chatPeer) - - val headerView = binding.account - val headerText = getString(R.string.account) + " " + - if (ua.account.nickName != "") - ua.account.nickName - else - aor.split(":")[1] - headerView.text = headerText - - listView = binding.messages - - chatMessages = uaPeerMessages(aor, peerUri) - mlAdapter = MessageListAdapter(this, peerUri, chatPeer, chatMessages) + val title = String.format(getString(R.string.chat_with), chatPeer) val messagesObserver = Observer { - chatMessages.clear() - chatMessages.addAll(uaPeerMessages(aor, peerUri)) - mlAdapter.notifyDataSetChanged() + _chatMessages.value = listOf() + _chatMessages.value = uaPeerMessages(aor, peerUri) } BaresipService.messageUpdate.observe(this, messagesObserver) - listView.adapter = mlAdapter - //listView.isLongClickable = true - val footerView = View(applicationContext) - listView.addFooterView(footerView) - listView.smoothScrollToPosition(mlAdapter.count - 1) - - newMessage = binding.text - newMessage.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - } - } - - if (focus) newMessage.requestFocus() - - sendButton = binding.sendButton - sendButton.setOnClickListener { - val msgText = newMessage.text.toString() - if (msgText.isNotEmpty()) { - imm.hideSoftInputFromWindow(newMessage.windowToken, 0) - val time = System.currentTimeMillis() - val msg = Message(aor, peerUri, msgText, time, MESSAGE_UP_WAIT, 0, "", true) - msg.add() - var msgUri = "" - chatMessages.add(msg) - if (Utils.isTelUri(peerUri)) - if (ua.account.telProvider == "") { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.no_telephony_provider), - Utils.plainAor(aor))) - } else { - msgUri = Utils.telToSip(peerUri, ua.account) - } - else - msgUri = peerUri - if (msgUri != "") - if (Api.message_send(ua.uap, msgUri, msgText, time.toString()) != 0) { - Toast.makeText(applicationContext, "${getString(R.string.message_failed)}!", - Toast.LENGTH_SHORT).show() - msg.direction = MESSAGE_UP_FAIL - msg.responseReason = getString(R.string.message_failed) - } else { - newMessage.setText("") - BaresipService.chatTexts.remove("$aor::$peerUri") - } - mlAdapter.notifyDataSetChanged() - } - } - ua.account.unreadMessages = false - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + setContent { + AppTheme { + keyboardController = LocalSoftwareKeyboardController.current + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + ChatScreen(this, title) { goBack() } + } + } + } + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + @Composable + fun ChatScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { TopAppBar(ctx, title, navigateBack) }, + bottomBar = { NewMessage(ctx, peerUri) }, + content = { contentPadding -> + ChatContent(ctx, contentPadding) + } + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopAppBar(ctx: Context, title: String, navigateBack: () -> Unit) { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton( + onClick = { + if (SystemClock.elapsedRealtime() - lastCall > 1000) { + lastCall = SystemClock.elapsedRealtime() + val intent = Intent(ctx, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.putExtra("action", "call") + intent.putExtra("uap", ua.uap) + intent.putExtra("peer", peerUri) + startActivity(intent) + } + } + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.call_small), + contentDescription = "Call", + tint = LocalCustomColors.current.light + ) + } + } + ) + } + + @Composable + fun ChatContent(ctx: Context, contentPadding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(LocalCustomColors.current.background) + .padding(contentPadding), + verticalArrangement = Arrangement.Bottom + ) { + Account(account) + Spacer(modifier = Modifier.weight(1f)) + Messages(ctx) + } + } + + @Composable + fun Account(account: Account) { + val headerText = getString(R.string.account) + " " + + if (account.nickName.value != "") + account.nickName.value + else + aor.split(":")[1] + Text( + text = headerText, modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } + + @Composable + fun Messages(ctx: Context) { + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + _chatMessages.value = uaPeerMessages(aor, peerUri) + } + + LaunchedEffect(chatMessages) { + // Scroll to the bottom when new messages are added + if (chatMessages.isNotEmpty()) { + coroutineScope.launch { + lazyListState.scrollToItem(0) + } + } + } + + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 2.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + reverseLayout = true, + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(items = chatMessages, key = { message -> message.timeStamp }) { message -> + val down = message.direction == MESSAGE_DOWN + val peer: String = if (down) { + if (chatPeer.startsWith("sip:") && + (Utils.uriHostPart(message.peerUri) == Utils.uriHostPart(message.aor))) + Utils.uriUserPart(message.peerUri) + else + chatPeer + } + else + ctx.getString(R.string.you) + var info: String + val cal = GregorianCalendar() + cal.timeInMillis = message.timeStamp + val fmt: DateFormat = if (isToday(message.timeStamp)) + DateFormat.getTimeInstance(DateFormat.SHORT) + else + DateFormat.getDateInstance(DateFormat.SHORT) + info = fmt.format(cal.time) + if (info.length < 6) info = "${ctx.getString(R.string.today)} $info" + if (message.direction == MESSAGE_UP_FAIL) { + info = if (message.responseCode != 0) + "$info - ${ctx.getString(R.string.message_failed)}: " + "${message.responseCode} ${message.responseReason}" + else + "$info - ${ctx.getString(R.string.sending_failed)}" + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 12.dp) + ) { + Button( + onClick = { + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + val i = Intent(ctx, BaresipContactActivity::class.java) + val b = Bundle() + b.putBoolean("new", true) + b.putString("uri", peerUri) + i.putExtras(b) + ctx.startActivity(i) + } + + DialogInterface.BUTTON_NEGATIVE -> { + message.delete() + _chatMessages.value = uaPeerMessages(aor, peerUri) + } + + DialogInterface.BUTTON_NEUTRAL -> { + } + } + } + val builder = MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme) + if (chatPeer == peerUri) + with(builder) { + setTitle(R.string.confirmation) + setMessage( + String.format( + ctx.getString(R.string.long_message_question), + peerUri + ) + ) + setNeutralButton( + ctx.getString(R.string.cancel), + dialogClickListener + ) + setNegativeButton( + ctx.getString(R.string.delete), + dialogClickListener + ) + setPositiveButton( + ctx.getString(R.string.add_contact), + dialogClickListener + ) + show() + } + else + with(builder) { + setTitle(R.string.confirmation) + setMessage(ctx.getText(R.string.short_message_question)) + setNeutralButton( + ctx.getString(R.string.cancel), + dialogClickListener + ) + setNegativeButton( + ctx.getString(R.string.delete), + dialogClickListener + ) + show() + } + }, + shape = if (message.direction == MESSAGE_DOWN) + RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp) + else + RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp), + colors = ButtonDefaults.buttonColors(containerColor = + if (message.direction == MESSAGE_DOWN) + LocalCustomColors.current.secondaryLight + else + LocalCustomColors.current.primaryLight), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + Column { + Row { + Text( + peer, color = LocalCustomColors.current.dark, + fontSize = 12.sp + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + info, color = LocalCustomColors.current.dark, + fontSize = 12.sp + ) + } + Row { + SelectionContainer { + Text( + text = message.message, + color = LocalCustomColors.current.dark, + fontWeight = if (message.direction == MESSAGE_DOWN && message.new) + FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + } + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun NewMessage(ctx: Context, peerUri: String) { + var newMessage by remember { mutableStateOf("") } + // var textFieldState = rememberTextFieldState() + Row(modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = newMessage, + placeholder = { Text(text = getString(R.string.new_message)) }, + onValueChange = { newMessage = it }, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + .verticalScroll(rememberScrollState()) + .combinedClickable( + onClick = { + val msgText = newMessage + if (msgText.isNotEmpty()) { + keyboardController?.hide() + val time = System.currentTimeMillis() + val msg = Message( + aor, + peerUri, + msgText, + time, + MESSAGE_UP_WAIT, + 0, + "", + true + ) + msg.add() + var msgUri = "" + _chatMessages.value += msg + if (Utils.isTelUri(peerUri)) + if (ua.account.telProvider == "") { + Utils.alertView( + ctx, getString(R.string.notice), + String.format( + getString(R.string.no_telephony_provider), + Utils.plainAor(aor) + ) + ) + } else { + msgUri = Utils.telToSip(peerUri, ua.account) + } + else + msgUri = peerUri + if (msgUri != "") + if (Api.message_send( + ua.uap, + msgUri, + msgText, + time.toString() + ) != 0 + ) { + Toast.makeText( + ctx, + "${getString(R.string.message_failed)}!", + Toast.LENGTH_SHORT + ).show() + msg.direction = MESSAGE_UP_FAIL + msg.responseReason = getString(R.string.message_failed) + } else { + newMessage = "" + unsentMessage = "" + keyboardController?.hide() + BaresipService.chatTexts.remove("$aor::$peerUri") + } + } + }, + onLongClick = { + val clipboardManager = + ctx.getSystemService(Context.CLIPBOARD_SERVICE) as + android.content.ClipboardManager + val clipData = clipboardManager.primaryClip + if (clipData != null && clipData.itemCount > 0) { + val pastedText = clipData.getItemAt(0).text.toString() + newMessage = pastedText + } else { + Toast.makeText(ctx, "Nothing to paste", Toast.LENGTH_SHORT).show() + } + }, + ), + singleLine = false, + trailingIcon = { + if (newMessage.isNotEmpty()) { + Icon( + Icons.Outlined.Clear, + contentDescription = "Clear", + modifier = Modifier.clickable { newMessage = "" } + ) + } }, + label = { Text(text = getString(R.string.new_message), fontSize = 18.sp) }, + textStyle = TextStyle(fontSize = 18.sp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + autoCorrectEnabled = true + ) + ) + Image( + painter = painterResource(id = R.drawable.send), + contentDescription = "Send", + contentScale = ContentScale.Crop, + modifier = Modifier.size(36.dp) + .clickable { + val msgText = newMessage + if (msgText.isNotEmpty()) { + keyboardController?.hide() + val time = System.currentTimeMillis() + val msg = Message( + aor, + peerUri, + msgText, + time, + MESSAGE_UP_WAIT, + 0, + "", + true + ) + msg.add() + var msgUri = "" + _chatMessages.value += msg + if (Utils.isTelUri(peerUri)) + if (ua.account.telProvider == "") { + Utils.alertView( + ctx, getString(R.string.notice), + String.format( + getString(R.string.no_telephony_provider), + Utils.plainAor(aor) + ) + ) + } else { + msgUri = Utils.telToSip(peerUri, ua.account) + } + else + msgUri = peerUri + if (msgUri != "") + if (Api.message_send( + ua.uap, + msgUri, + msgText, + time.toString() + ) != 0 + ) { + Toast.makeText( + ctx, + "${getString(R.string.message_failed)}!", + Toast.LENGTH_SHORT + ).show() + msg.direction = MESSAGE_UP_FAIL + msg.responseReason = getString(R.string.message_failed) + } else { + newMessage = "" + unsentMessage = "" + keyboardController?.hide() + BaresipService.chatTexts.remove("$aor::$peerUri") + } + } + } + ) + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -179,61 +606,24 @@ class ChatActivity : AppCompatActivity() { super.onResume() val chatText = BaresipService.chatTexts["$aor::$peerUri"] if (chatText != null) { - Log.d(TAG, "Restoring newMessage ${newMessage.text} for $aor::$peerUri") - newMessage.setText(chatText) - newMessage.requestFocus() + Log.d(TAG, "Restoring newMessage $chatText for $aor::$peerUri") + unsentMessage = chatText + //newMessage.requestFocus() BaresipService.chatTexts.remove("$aor::$peerUri") } - chatMessages.clear() - chatMessages.addAll(uaPeerMessages(aor, peerUri)) - mlAdapter.notifyDataSetChanged() + _chatMessages.value = uaPeerMessages(aor, peerUri) } override fun onPause() { super.onPause() - if (newMessage.text.toString() != "") { - Log.d(TAG, "Saving newMessage ${newMessage.text} for $aor::$peerUri") - BaresipService.chatTexts["$aor::$peerUri"] = newMessage.text.toString() + if (unsentMessage != "") { + Log.d(TAG, "Saving newMessage $unsentMessage for $aor::$peerUri") + BaresipService.chatTexts["$aor::$peerUri"] = unsentMessage } MainActivity.activityAor = aor } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if ((BaresipService.activities.indexOf("chat,$aor,$peerUri,false") == -1) && - (BaresipService.activities.indexOf("chat,$aor,$peerUri,true") == -1)) - return true - - when (item.itemId) { - - R.id.callIcon -> { - if (SystemClock.elapsedRealtime() - lastCall > 1000) { - lastCall = SystemClock.elapsedRealtime() - val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - intent.putExtra("action", "call") - intent.putExtra("uap", ua.uap) - intent.putExtra("peer", peerUri) - startActivity(intent) - finish() - return true - } - } - - android.R.id.home -> { - goBack() - return true - } - - } - - return super.onOptionsItemSelected(item) - - } - private fun goBack() { - var save = false for (m in chatMessages) { if (m.new) { @@ -242,13 +632,10 @@ class ChatActivity : AppCompatActivity() { } } if (save) Message.save() - - imm.hideSoftInputFromWindow(newMessage.windowToken, 0) - + keyboardController?.hide() BaresipService.activities.remove("chat,$aor,$peerUri,false") BaresipService.activities.remove("chat,$aor,$peerUri,true") - returnResult(Activity.RESULT_OK) - + returnResult(RESULT_OK) } private fun returnResult(code: Int) { @@ -256,9 +643,9 @@ class ChatActivity : AppCompatActivity() { finish() } - private fun uaPeerMessages(aor: String, peerUri: String): ArrayList { - val res = ArrayList() - for (m in Message.messages()) + private fun uaPeerMessages(aor: String, peerUri: String): List { + val res = mutableListOf() + for (m in BaresipService.messages.reversed()) if ((m.aor == aor) && (m.peerUri == peerUri)) res.add(m) return res } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatListAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatListAdapter.kt deleted file mode 100644 index ad8ca4ea..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatListAdapter.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.graphics.Typeface -import android.text.format.DateUtils.isToday -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.core.content.ContextCompat -import java.text.DateFormat -import java.util.* - -class ChatListAdapter(private val ctx: Context, private val account: Account, private val rows: ArrayList) : - ArrayAdapter(ctx, R.layout.message, rows) { - - private val layoutInflater = LayoutInflater.from(context) - - private class ViewHolder(view: View?) { - val textAvatarView: TextView = view?.findViewById(R.id.TextAvatar)!! - val imageAvatarView: ImageView = view?.findViewById(R.id.ImageAvatar)!! - val layoutView: LinearLayout = view?.findViewById(R.id.chat)!! - val peerView: TextView = view?.findViewById(R.id.peer)!! - val infoView: TextView = view?.findViewById(R.id.info)!! - val textView: TextView = view?.findViewById(R.id.text)!! - } - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.chat_row, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val message = rows[position] - - viewHolder.peerView.text = Utils.friendlyUri(ctx, message.peerUri, account) - Utils.setAvatar(ctx, viewHolder.imageAvatarView, viewHolder.textAvatarView, - if (Contact.contactName(message.peerUri) == message.peerUri) - Utils.e164Uri(message.peerUri, account.countryCode) - else - message.peerUri) - - if (message.direction == MESSAGE_DOWN) - viewHolder.layoutView.setBackgroundResource(R.drawable.message_in_bg) - else - viewHolder.layoutView.setBackgroundResource(R.drawable.message_out_bg) - - val cal = GregorianCalendar() - cal.timeInMillis = message.timeStamp - val fmt: DateFormat = if (isToday(message.timeStamp)) - DateFormat.getTimeInstance(DateFormat.SHORT) - else - DateFormat.getDateInstance(DateFormat.SHORT) - viewHolder.infoView.text = fmt.format(cal.time) - if (message.direction == MESSAGE_UP_FAIL) { - val info: String = if (message.responseCode != 0) - "${viewHolder.infoView.text} - ${ctx.getString(R.string.message_failed)}: " + - "${message.responseCode} ${message.responseReason}" - else - "${viewHolder.infoView.text} - ${ctx.getString(R.string.sending_failed)}" - viewHolder.infoView.text = info - viewHolder.infoView.setTextColor(ContextCompat.getColor(ctx, R.color.colorAccent)) - } - - viewHolder.textView.text = message.message - if (message.new) - viewHolder.textView.setTypeface(null, Typeface.BOLD) - - return rowView - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatsActivity.kt index 6a7654bd..0c6ea76e 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatsActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatsActivity.kt @@ -1,38 +1,98 @@ package com.tutpro.baresip -import android.app.Activity +import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.os.Build +import android.graphics.BitmapFactory import android.os.Bundle +import android.text.format.DateUtils.isToday import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.* +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tutpro.baresip.databinding.ActivityChatsBinding +import com.tutpro.baresip.BaresipService.Companion.contactNames +import com.tutpro.baresip.CustomElements.ImageAvatar +import com.tutpro.baresip.CustomElements.Text +import com.tutpro.baresip.CustomElements.TextAvatar +import com.tutpro.baresip.CustomElements.verticalScrollbar +import java.text.DateFormat +import java.util.GregorianCalendar -class ChatsActivity: AppCompatActivity() { +class ChatsActivity: ComponentActivity() { - private lateinit var binding: ActivityChatsBinding - private lateinit var uaMessages: ArrayList - private lateinit var listView: ListView - private lateinit var clAdapter: ChatListAdapter - internal lateinit var peerUri: AutoCompleteTextView - private lateinit var plusButton: ImageButton internal lateinit var aor: String internal lateinit var account: Account private lateinit var chatRequest: ActivityResultLauncher - private var scrollPosition = -1 + private var _uaMessages = mutableStateOf>(emptyList()) + private var uaMessages : List by _uaMessages private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -44,172 +104,536 @@ class ChatsActivity: AppCompatActivity() { super.onCreate(savedInstanceState) - binding = ActivityChatsBinding.inflate(layoutInflater) - setContentView(binding.root) - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.ChatsView.updatePadding(top = systemBars.top + 56) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true - - listView = binding.chats - plusButton = binding.plusButton + enableEdgeToEdge() aor = intent.extras!!.getString("aor")!! Utils.addActivity("chats,$aor") account = UserAgent.ofAor(aor)!!.account - val headerView = binding.account - val headerText = getString(R.string.account) + " " + - if (account.nickName != "") - account.nickName - else - aor.split(":")[1] - headerView.text = headerText + val title = getString(R.string.chats) - uaMessages = uaMessages(aor) - clAdapter = ChatListAdapter(this, account, uaMessages) - listView.adapter = clAdapter - listView.isLongClickable = true + _uaMessages.value = uaMessages(aor) chatRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - clAdapter.clear() - uaMessages = uaMessages(aor) - clAdapter = ChatListAdapter(this, account, uaMessages) - listView.adapter = clAdapter - } + if (it.resultCode == RESULT_OK) + _uaMessages.value = uaMessages(aor) } - listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ -> - scrollPosition = pos - val i = Intent(this, ChatActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - b.putString("peer", uaMessages[pos].peerUri) - i.putExtras(b) - chatRequest.launch(i) - } - - listView.onItemLongClickListener = AdapterView.OnItemLongClickListener { _, _, pos, _ -> - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - val i = Intent(this, ContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", true) - b.putString("uri", uaMessages[pos].peerUri) - i.putExtras(b) - startActivity(i) - } - DialogInterface.BUTTON_NEGATIVE -> { - val peerUri = uaMessages[pos].peerUri - val messages = ArrayList() - for (m in Message.messages()) - if ((m.aor != aor) || (m.peerUri != peerUri)) - messages.add(m) - else - clAdapter.remove(m) - clAdapter.notifyDataSetChanged() - BaresipService.messages = messages - Message.save() - uaMessages = uaMessages(aor) - } - DialogInterface.BUTTON_NEUTRAL -> { - } + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + ChatsScreen(this, title) { goBack() } } } - - val builder = MaterialAlertDialogBuilder(this@ChatsActivity, R.style.AlertDialogTheme) - val peer = Utils.friendlyUri(this@ChatsActivity, uaMessages[pos].peerUri, account) - if (!Contact.nameExists(peer, false)) - with (builder) { - setTitle(R.string.confirmation) - setMessage(String.format(getString(R.string.long_chat_question), - peer)) - setNeutralButton(getText(R.string.cancel), dialogClickListener) - setNegativeButton(getText(R.string.delete), dialogClickListener) - setPositiveButton(getText(R.string.add_contact), dialogClickListener) - show() - } - else - with (builder) { - setTitle(R.string.confirmation) - setMessage(String.format(getString(R.string.short_chat_question), peer)) - setNeutralButton(getText(R.string.cancel), dialogClickListener) - setNegativeButton(getText(R.string.delete), dialogClickListener) - show() - } - true - } - - peerUri = binding.peer - peerUri.threshold = 2 - peerUri.setAdapter(ArrayAdapter(this, android.R.layout.select_dialog_item, - Contact.contactNames())) - - plusButton.setOnClickListener { - makeChat(true) } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } - private fun makeChat(lookForContact: Boolean = true) { - var uriText = peerUri.text.toString().trim() - if (uriText.isNotEmpty()) { + @Composable + fun ChatsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { TopAppBar(title, navigateBack) }, + bottomBar = { NewChatPeer() }, + content = { contentPadding -> + ChatsContent(ctx, contentPadding) + } + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopAppBar(title: String, navigateBack: () -> Unit) { + + var expanded by remember { mutableStateOf(false) } + + val delete = String.format(getString(R.string.delete)) + val disable = String.format(getString(R.string.disable_history)) + val enable = String.format(getString(R.string.enable_history)) + + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton( + onClick = { expanded = !expanded } + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Menu", + tint = LocalCustomColors.current.light + ) + } + CustomElements.DropdownMenu(expanded, + { expanded = false }, + listOf(delete, if (account.callHistory) disable else enable), + onItemClick = { selectedItem -> + expanded = false + when (selectedItem) { + delete -> + with( + MaterialAlertDialogBuilder( + this@ChatsActivity, + R.style.AlertDialogTheme + ) + ) { + setTitle(R.string.confirmation) + setMessage(String.format(getString(R.string.delete_chats_alert), + aor.substringAfter(":"))) + setPositiveButton(delete) { dialog, _ -> + Message.clearMessagesOfAor(aor) + Message.save() + _uaMessages.value = listOf() + account.unreadMessages = false + dialog.dismiss() + } + setNeutralButton(getText(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + show() + } + } + } + ) + } + ) + } + + @Composable + fun ChatsContent(ctx: Context, contentPadding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(LocalCustomColors.current.background) + .padding(contentPadding), + verticalArrangement = Arrangement.Bottom + ) { + Account(account) + Spacer(modifier = Modifier.weight(1f)) + Chats(ctx, account) + } + } + + @Composable + fun Account(account: Account) { + val headerText = getString(R.string.account) + " " + + if (account.nickName.value != "") + account.nickName.value + else + aor.split(":")[1] + Text( + text = headerText, modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } + + @Composable + fun Chats(ctx: Context, account: Account) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + reverseLayout = true, + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(items = uaMessages, key = { message -> message.timeStamp }) { message -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.combinedClickable( + onClick = { + val i = Intent(ctx, ChatActivity::class.java) + val b = Bundle() + b.putString("aor", aor) + b.putString("peer", message.peerUri) + i.putExtras(b) + chatRequest.launch(i) + }, + onLongClick = { + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + val i = + Intent(ctx, BaresipContactActivity::class.java) + val b = Bundle() + b.putBoolean("new", true) + b.putString("uri", message.peerUri) + i.putExtras(b) + startActivity(i) + } + + DialogInterface.BUTTON_NEGATIVE -> { + Message.deleteAorPeerMessages(aor, message.peerUri) + } + + DialogInterface.BUTTON_NEUTRAL -> { + } + } + } + + val builder = MaterialAlertDialogBuilder( + this@ChatsActivity, + R.style.AlertDialogTheme + ) + val peer = Utils.friendlyUri(ctx, message.peerUri, account) + if (!Contact.nameExists(peer, BaresipService.contacts, false)) + with(builder) { + setTitle(R.string.confirmation) + setMessage( + String.format( + getString(R.string.long_chat_question), + peer + ) + ) + setNeutralButton( + getText(R.string.cancel), + dialogClickListener + ) + setNegativeButton( + getText(R.string.delete), + dialogClickListener + ) + setPositiveButton( + getText(R.string.add_contact), + dialogClickListener + ) + show() + } + else + with(builder) { + setTitle(R.string.confirmation) + setMessage( + String.format( + getString(R.string.short_chat_question), + peer + ) + ) + setNeutralButton( + getText(R.string.cancel), + dialogClickListener + ) + setNegativeButton( + getText(R.string.delete), + dialogClickListener + ) + show() + } + } + )) { + when (val contact = Contact.findContact(message.peerUri)) { + is Contact.BaresipContact -> { + val avatarImage = contact.avatarImage + if (avatarImage != null) + ImageAvatar(avatarImage) + else + TextAvatar(contact.name, contact.color) + } + + is Contact.AndroidContact -> { + val thumbNailUri = contact.thumbnailUri + if (thumbNailUri != null) + AsyncImage( + model = thumbNailUri, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + else + TextAvatar(contact.name, contact.color) + } + + null -> { + val avatarImage = BitmapFactory + .decodeResource(ctx.resources, R.drawable.person_image) + ImageAvatar(avatarImage) + } + } + + Spacer(modifier = Modifier.width(6.dp)) + + Button( + onClick = { + val i = Intent(this@ChatsActivity, ChatActivity::class.java) + val b = Bundle() + b.putString("aor", aor) + b.putString("peer", message.peerUri) + i.putExtras(b) + chatRequest.launch(i) }, + shape = if (message.direction == MESSAGE_DOWN) + RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp) + else + RoundedCornerShape(20.dp, 50.dp, 20.dp, 10.dp), + colors = ButtonDefaults.buttonColors(containerColor = + if (message.direction == MESSAGE_DOWN) + LocalCustomColors.current.secondaryLight + else + LocalCustomColors.current.primaryLight), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 8.dp, end = 16.dp) + ) { + val peer = Utils.friendlyUri(ctx, message.peerUri, account) + val cal = GregorianCalendar() + cal.timeInMillis = message.timeStamp + val fmt: DateFormat = if (isToday(message.timeStamp)) + DateFormat.getTimeInstance(DateFormat.SHORT) + else + DateFormat.getDateInstance(DateFormat.SHORT) + val info = fmt.format(cal.time) + Column { + Row { + Text( + peer, color = LocalCustomColors.current.dark, + fontSize = 12.sp + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + info, color = LocalCustomColors.current.dark, + fontSize = 12.sp + ) + } + Row { + Text( + text = message.message, color = LocalCustomColors.current.dark, + maxLines = 1 + ) + } + } + } + } + } + } + } + + @Composable + fun NewChatPeer() { + val suggestions by remember { contactNames } + var filteredSuggestions by remember { mutableStateOf(suggestions) } + var showSuggestions by remember { mutableStateOf(false) } + val lazyListState = rememberLazyListState() + val focusManager = LocalFocusManager.current + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var newPeer by remember { mutableStateOf("") } + Column( + horizontalAlignment = Alignment.Start, + modifier = Modifier.weight(1f) + ) { + if (showSuggestions && filteredSuggestions.isNotEmpty()) { + Column( + modifier = Modifier + .shadow(8.dp, RoundedCornerShape(8.dp)) + .background(LocalCustomColors.current.grayLight, + shape = RoundedCornerShape(8.dp)) + .animateContentSize() + ) { + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 180.dp) + .verticalScrollbar( + state = lazyListState, + color = LocalCustomColors.current.gray + ), + horizontalAlignment = Alignment.Start, + state = lazyListState + ) { + items( + items = filteredSuggestions, + key = { suggestion -> suggestion } + ) { suggestion -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + newPeer = suggestion + showSuggestions = false + } + .padding(12.dp) + ) { + Text( + text = suggestion, + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColors.current.grayDark, + fontSize = 18.sp + ) + } + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + OutlinedTextField( + value = newPeer, + placeholder = { + Text(text = getString(R.string.new_chat_peer)) + }, + onValueChange = { + newPeer = it + showSuggestions = newPeer.length > 2 + filteredSuggestions = if (it.isEmpty()) { + suggestions + } else { + suggestions.filter { suggestion -> + newPeer.length > 2 && suggestion.startsWith( + newPeer, + ignoreCase = true + ) + } + } + }, + modifier = Modifier + .padding(end = 6.dp) + .fillMaxWidth(), + singleLine = true, + trailingIcon = { + if (newPeer.isNotEmpty()) + Icon( + Icons.Outlined.Clear, + contentDescription = null, + modifier = Modifier + .clickable { + newPeer = "" + showSuggestions = false + } + ) + }, + label = { + Text( + text = getString(R.string.new_chat_peer), + fontSize = 18.sp + ) + }, + textStyle = TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.itemText + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done) + ) + } + Spacer(Modifier.width(4.dp)) + SmallFloatingActionButton( + modifier = Modifier.padding(end = 4.dp), + onClick = { + showSuggestions = false + if (makeChat(newPeer, true)) { + newPeer = "" + focusManager.clearFocus() + } + }, + containerColor = LocalCustomColors.current.accent, + contentColor = LocalCustomColors.current.background + ) { + Icon( + imageVector = Icons.Filled.Add, + modifier = Modifier.size(36.dp), + contentDescription = stringResource(R.string.add) + ) + } + } + } + + private fun makeChat(peer: String, lookForContact: Boolean = true): Boolean { + var peerUri = peer.trim() + if (peerUri.isNotEmpty()) { if (lookForContact) { - val uris = Contact.contactUris(uriText) + val uris = Contact.contactUris(peerUri) if (uris.size == 1) - uriText = uris[0] + peerUri = uris[0] else if (uris.size > 1) { val builder = MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) with(builder) { setTitle("Choose Destination") setItems(uris.toTypedArray()) { _, which -> - peerUri.setText(uris[which]) - makeChat(false) + peerUri = uris[which] + makeChat(peerUri, false) } setNeutralButton(getString(R.string.cancel)) { _: DialogInterface, _: Int -> } show() } - return + return true } } - if (Utils.isTelNumber(uriText)) - uriText = "tel:$uriText" - val uri = if (Utils.isTelUri(uriText)) { + if (Utils.isTelNumber(peerUri)) + peerUri = "tel:$peerUri" + val uri = if (Utils.isTelUri(peerUri)) { if (account.telProvider == "") { Utils.alertView(this, getString(R.string.notice), String.format(getString(R.string.no_telephony_provider), account.aor)) - return + return false } - Utils.telToSip(uriText, account) + Utils.telToSip(peerUri, account) } else { - Utils.uriComplete(uriText, aor) + Utils.uriComplete(peerUri, aor) } if (!Utils.checkUri(uri)) { Utils.alertView(this, getString(R.string.notice), String.format(getString(R.string.invalid_sip_or_tel_uri), uri)) + return false } else { - peerUri.text.clear() - peerUri.isCursorVisible = false val i = Intent(this@ChatsActivity, ChatActivity::class.java) val b = Bundle() b.putString("aor", aor) b.putString("peer", uri) i.putExtras(b) chatRequest.launch(i) + return true } } + return false } override fun onPause() { @@ -219,18 +643,7 @@ class ChatsActivity: AppCompatActivity() { override fun onResume() { super.onResume() - clAdapter.clear() - uaMessages = uaMessages(aor) - clAdapter = ChatListAdapter(this, account, uaMessages) - listView.adapter = clAdapter - if (uaMessages.isNotEmpty()) { - if (scrollPosition >= 0) { - listView.setSelection(scrollPosition) - scrollPosition = -1 - } else { - listView.setSelection(0) - } - } + _uaMessages.value = uaMessages(aor) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -238,55 +651,20 @@ class ChatsActivity: AppCompatActivity() { return true } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - when (item.itemId) { - - R.id.delete_chats -> { - with (MaterialAlertDialogBuilder(this@ChatsActivity, R.style.AlertDialogTheme)) { - setTitle(R.string.confirmation) - setMessage(String.format(getString(R.string.delete_chats_alert), - aor.substringAfter(":"))) - setPositiveButton(getText(R.string.delete)) { dialog, _ -> - Message.clear(aor) - Message.save() - uaMessages.clear() - clAdapter.notifyDataSetChanged() - account.unreadMessages = false - dialog.dismiss() - } - setNeutralButton(getText(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - } - show() - } - return true - } - - android.R.id.home -> { - goBack() - return true - } - - } - - return super.onOptionsItemSelected(item) - } - private fun goBack() { BaresipService.activities.remove("chats,$aor") returnResult() } private fun returnResult() { - setResult(Activity.RESULT_CANCELED, Intent()) + setResult(RESULT_CANCELED, Intent()) finish() } - private fun uaMessages(aor: String) : ArrayList { - val res = ArrayList() + private fun uaMessages(aor: String) : List { + val res = mutableListOf() account.unreadMessages = false - for (m in Message.messages().reversed()) { + for (m in BaresipService.messages) { if (m.aor != aor) continue var found = false for (r in res) @@ -300,30 +678,6 @@ class ChatsActivity: AppCompatActivity() { account.unreadMessages = true } } - return res - } - - companion object { - - fun saveUaMessage(aor: String, time: Long) { - for (i in Message.messages().indices.reversed()) - if ((Message.messages()[i].aor == aor) && - (Message.messages()[i].timeStamp == time)) { - Message.messages()[i].new = false - Message.save() - return - } - } - - fun deleteUaMessage(aor: String, time: Long) { - for (i in Message.messages().indices.reversed()) - if ((Message.messages()[i].aor == aor) && - (Message.messages()[i].timeStamp == time)) { - Message.messages().removeAt(i) - Message.save() - return - } - } - + return res.toList() } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/Codec.kt b/app/src/main/kotlin/com/tutpro/baresip/Codec.kt index 08d11f24..eb9c6f8c 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Codec.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Codec.kt @@ -1,3 +1,5 @@ package com.tutpro.baresip -data class Codec(val name: String, var enabled: Boolean) \ No newline at end of file +import androidx.compose.runtime.MutableState + +data class Codec(val name: String, var enabled: MutableState) \ No newline at end of file diff --git a/app/src/main/kotlin/com/tutpro/baresip/CodecsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/CodecsActivity.kt index af7d0278..817dc9e6 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CodecsActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CodecsActivity.kt @@ -1,35 +1,64 @@ package com.tutpro.baresip -import android.annotation.SuppressLint -import android.os.Build import android.os.Bundle import android.view.Menu -import android.view.MenuItem -import android.view.View +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.ItemTouchHelper.* -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.tutpro.baresip.databinding.ActivityCodecsBinding -import java.util.* +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tutpro.baresip.CustomElements.verticalScrollbar -class CodecsActivity : AppCompatActivity() { +class CodecsActivity : ComponentActivity() { - private lateinit var binding: ActivityCodecsBinding private lateinit var acc: Account private lateinit var ua: UserAgent - private lateinit var codecsAdapter: CodecsAdapter + + private lateinit var allCodecs: List + private lateinit var accCodecs: List + private lateinit var codecs: SnapshotStateList private var aor = "" - private var newCodecs = ArrayList() private var media = "" + private var title = "" private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -41,21 +70,7 @@ class CodecsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) - binding = ActivityCodecsBinding.inflate(layoutInflater) - setContentView(binding.root) - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.CodecsView.updatePadding(top = systemBars.top + 64) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true - - supportActionBar?.setDisplayHomeAsUpEnabled(true) + enableEdgeToEdge() aor = intent.getStringExtra("aor")!! media = intent.getStringExtra("media")!! @@ -65,88 +80,256 @@ class CodecsActivity : AppCompatActivity() { ua = UserAgent.ofAor(aor)!! acc = ua.account - val allCodecs: ArrayList - val accCodecs: ArrayList - - val codecsTitle = binding.CodecsTitle - val codecList = binding.CodecList - - itemTouchHelper.attachToRecyclerView(codecList) - if (media == "audio") { - codecsTitle.text = getString(R.string.audio_codecs) + title = getString(R.string.audio_codecs) allCodecs = ArrayList(Api.audio_codecs().split(",")) accCodecs = acc.audioCodec } else { - codecsTitle.text = getString(R.string.video_codecs) + title = getString(R.string.video_codecs) allCodecs = ArrayList(Api.video_codecs().split(",").distinct()) accCodecs = acc.videoCodec } + val currentCodecs = mutableListOf() for (codec in accCodecs) - newCodecs.add(Codec(codec, true)) + currentCodecs.add(Codec(codec, mutableStateOf(true))) for (codec in allCodecs) if (codec !in accCodecs) - newCodecs.add(Codec(codec, false)) + currentCodecs.add(Codec(codec, mutableStateOf(false))) - codecsAdapter = CodecsAdapter(newCodecs) + codecs = mutableStateListOf().apply { addAll(currentCodecs) } - codecList.layoutManager = LinearLayoutManager(this) - codecList.adapter = codecsAdapter - - itemTouchHelper.attachToRecyclerView(binding.CodecList) - - codecsTitle.setOnClickListener { - if (media == "audio") - Utils.alertView(this, getString(R.string.audio_codecs), - getString(R.string.audio_codecs_help)) - else - Utils.alertView(this, getString(R.string.video_codecs), - getString(R.string.video_codecs_help)) + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + CodecsScreen { goBack() } + } + } } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - private val itemTouchHelper by lazy { - val simpleItemTouchCallback = object : SimpleCallback( - UP or DOWN or START or END, RIGHT) { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun CodecsScreen(navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + TopAppBar( + title = { + Text( + text = getString(R.string.codecs), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton(onClick = { + updateCodecs() + }) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + } + ) + }, + content = { contentPadding -> + CodecsContent(contentPadding) + }, + ) + } - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - val fromPosition = viewHolder.adapterPosition - val toPosition = target.adapterPosition - codecsAdapter.moveItem(fromPosition, toPosition) - codecsAdapter.notifyItemMoved(fromPosition, toPosition) - return true + @Composable + fun CodecsContent(contentPadding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(bottom = 16.dp), + ) { + Title() + Codecs(codecs = codecs, onCodecsChange = { updatedCodecs -> + onCodecsChange(updatedCodecs) + }) + } + } + + private fun onCodecsChange(updatedCodecs: List) { + codecs.clear() + codecs.addAll(updatedCodecs) + } + + @Composable + fun Title() { + Text( + text = title, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .clickable { + if (media == "audio") + Utils.alertView( + this, getString(R.string.audio_codecs), + getString(R.string.audio_codecs_help) + ) + else + Utils.alertView( + this, getString(R.string.video_codecs), + getString(R.string.video_codecs_help) + ) + }, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = LocalCustomColors.current.itemText, + textAlign = TextAlign.Center + ) + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun Codecs(codecs: SnapshotStateList, onCodecsChange: (List) -> Unit) { + + val draggableState = rememberDraggableListState( + onMove = { fromIndex, toIndex -> + codecs.add(toIndex, codecs.removeAt(fromIndex)) + onCodecsChange(codecs.toList()) } + ) - @SuppressLint("NotifyDataSetChanged") - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val position = viewHolder.adapterPosition - if (newCodecs[position].enabled) { - codecsAdapter.disableItem(position) - codecsAdapter.notifyItemRemoved(position) + LazyColumn( + modifier = Modifier + .padding(end = 4.dp) + .verticalScrollbar( + state = draggableState.listState, + width = 4.dp, + color = LocalCustomColors.current.gray + ), + state = draggableState.listState, + contentPadding = PaddingValues(start = 12.dp, end = 12.dp), + //verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + draggableItems( + state = draggableState, + items = codecs, + key = { item -> item.name } + ) { item, isDragging -> + ListItem( + colors = ListItemDefaults.colors( + containerColor = if (isDragging) + LocalCustomColors.current.grayLight + else + LocalCustomColors.current.background + ), + headlineContent = { + Text(text = item.name, + modifier = Modifier.fillMaxWidth() + .alpha(if (item.enabled.value) 1.0f else 0.5f) + .padding(start = 6.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + item.enabled.value = !item.enabled.value + if (item.enabled.value) { + val index = codecs.indexOf(item) + codecs.removeAt(index) + codecs.add(0, item) + } + else { + val index = codecs.indexOf(item) + codecs.removeAt(index) + codecs.add(item) + } + onCodecsChange(codecs.toList()) + } + ) + ) + }, + trailingContent = { + Icon( + modifier = Modifier.dragHandle( + state = draggableState, + key = item.name + ), + imageVector =ImageVector.vectorResource(R.drawable.reorder), + contentDescription = null + ) + }, + ) + if (codecs.indexOf(item) > 0) + HorizontalDivider( + color = LocalCustomColors.current.gray, + modifier = Modifier.padding(horizontal = 12.dp), + thickness = 1.dp + ) + } + } + } + + private fun updateCodecs() { + + var save = false + val newCodecs = ArrayList() + + for (codec in codecs) + if (codec.enabled.value) + newCodecs.add(codec.name) + + val codecList = Utils.implode(newCodecs, ",") + + if (media == "audio") + if (newCodecs != acc.audioCodec) { + if (Api.account_set_audio_codecs(acc.accp, codecList) == 0) { + Log.d(TAG, "New audio codecs '$codecList'") + acc.audioCodec = newCodecs + save = true } else { - codecsAdapter.enableItem(position) - codecsAdapter.notifyDataSetChanged() + Log.e(TAG, "Setting of audio codecs '$codecList' failed") } } - @SuppressLint("NotifyDataSetChanged") - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - recyclerView.post { codecsAdapter.notifyDataSetChanged() } + if (media == "video") + if (newCodecs != acc.videoCodec) { + if (Api.account_set_video_codecs(acc.accp, codecList) == 0) { + Log.d(TAG, "New video codecs '$codecs'") + acc.videoCodec = newCodecs + save = true + } else { + Log.e(TAG, "Setting of video codecs '$codecs' failed") + } } - } - ItemTouchHelper(simpleItemTouchCallback) + if (save) + AccountsActivity.saveAccounts() + + BaresipService.activities.remove("codecs,$aor,$media") + finish() } + override fun onCreateOptionsMenu(menu: Menu): Boolean { super.onCreateOptionsMenu(menu) @@ -156,65 +339,6 @@ class CodecsActivity : AppCompatActivity() { } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (BaresipService.activities.indexOf("codecs,$aor,$media") == -1) - return true - - when (item.itemId) { - - R.id.checkIcon -> { - - var save = false - val codecs = ArrayList() - - for (codec in newCodecs) - if (codec.enabled) - codecs.add(codec.name) - - val codecList = Utils.implode(codecs, ",") - - if (media == "audio") - if (codecs != acc.audioCodec) { - if (Api.account_set_audio_codecs(acc.accp, codecList) == 0) { - Log.d(TAG, "New audio codecs '$codecList'") - acc.audioCodec = codecs - save = true - } else { - Log.e(TAG, "Setting of audio codecs '$codecList' failed") - } - } - - if (media == "video") - if (codecs != acc.videoCodec) { - if (Api.account_set_video_codecs(acc.accp, codecList) == 0) { - Log.d(TAG, "New video codecs '$codecs'") - acc.videoCodec = codecs - save = true - } else { - Log.e(TAG, "Setting of video codecs '$codecs' failed") - } - } - - if (save) - AccountsActivity.saveAccounts() - - BaresipService.activities.remove("codecs,$aor,$media") - finish() - return true - } - - android.R.id.home -> { - goBack() - return true - } - - } - - return super.onOptionsItemSelected(item) - - } - private fun goBack() { BaresipService.activities.remove("codecs,$aor,$media") finish() diff --git a/app/src/main/kotlin/com/tutpro/baresip/CodecsAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/CodecsAdapter.kt deleted file mode 100644 index 059a85e6..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/CodecsAdapter.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.tutpro.baresip - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import java.util.* - -class CodecsAdapter(private val codecs: ArrayList): - RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CodecViewHolder { - val v = LayoutInflater.from(parent.context).inflate(R.layout.codec, parent, false) - return CodecViewHolder(v) - } - - override fun getItemCount(): Int { - return codecs.size - } - - fun moveItem(fromPosition: Int, toPosition: Int) { - val codec: Codec = codecs[fromPosition] - codec.enabled = codecs[toPosition].enabled - codecs.removeAt(fromPosition) - codecs.add(toPosition, codec) - } - - fun enableItem(position: Int) { - val codec: Codec = codecs[position] - codec.enabled = true - codecs.removeAt(position) - codecs.add(0, codec) - } - - fun disableItem(position: Int) { - val codec: Codec = codecs[position] - if (codec.enabled) { - codec.enabled = false - codecs.removeAt(position) - codecs.add(codec) - } - } - - override fun onBindViewHolder(holder: CodecViewHolder, position: Int) { - val codec = codecs[position] - holder.codecName.text = codec.name - holder.codecName.alpha = if (codec.enabled) 1.0f else 0.5f - holder.reorderImage.setImageResource( - if (codec.enabled) R.drawable.reorder else android.R.color.transparent - ) - } - - class CodecViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { - var codecName: TextView = itemView.findViewById(R.id.codecName) - var reorderImage: ImageView = itemView.findViewById(R.id.reorderImage) - } -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/Config.kt b/app/src/main/kotlin/com/tutpro/baresip/Config.kt index 8ba61aa9..4be7d1e7 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Config.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Config.kt @@ -153,11 +153,10 @@ object Config { if ("${module}.so" in previousModules) config = "${config}module ${module}.so\n" - if ((!Utils.isAecSupported() && !File(configPath).exists()) || - "webrtc_aecm.so" in previousModules) { + Utils.aecAgcCheck() + + if (!BaresipService.aecAvailable) config = "${config}module webrtc_aecm.so\n" - BaresipService.webrtcAec = true - } val micGain = previousVariable("augain") config = if (BaresipService.agcAvailable || micGain == "" || micGain == "1.0") diff --git a/app/src/main/kotlin/com/tutpro/baresip/ConfigActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ConfigActivity.kt index 0edf1016..132d372a 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ConfigActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ConfigActivity.kt @@ -1,7 +1,6 @@ package com.tutpro.baresip import android.Manifest -import android.app.Activity import android.app.role.RoleManager import android.content.ActivityNotFoundException import android.content.Context @@ -11,80 +10,112 @@ import android.os.Build import android.os.Bundle import android.os.PowerManager import android.provider.Settings -import android.text.method.LinkMovementMethod -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.CheckBox -import android.widget.EditText -import android.widget.ScrollView -import android.widget.Spinner -import android.widget.TextView +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatDelegate -import androidx.core.app.ActivityCompat +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar +import com.tutpro.baresip.CustomElements.Checkbox +import com.tutpro.baresip.CustomElements.verticalScrollbar import com.tutpro.baresip.Utils.copyInputStreamToFile -import com.tutpro.baresip.Utils.showSnackBar -import com.tutpro.baresip.databinding.ActivityConfigBinding import java.io.File import java.io.FileInputStream import java.util.Locale -class ConfigActivity : AppCompatActivity() { +class ConfigActivity : ComponentActivity() { - private lateinit var binding: ActivityConfigBinding - private lateinit var layout: ScrollView private lateinit var baresipService: Intent - private lateinit var autoStart: CheckBox - private lateinit var listenAddr: EditText - private lateinit var netAfSpinner: Spinner - private lateinit var netAf: String - private lateinit var netAfKeys: ArrayList - private lateinit var dnsServers: EditText - private lateinit var certificateFile: CheckBox - private lateinit var verifyServer: CheckBox - private lateinit var caFile: CheckBox - private lateinit var userAgent: EditText - private lateinit var darkTheme: CheckBox - private lateinit var contactsSpinner: Spinner - private lateinit var contactsMode: String - private lateinit var contactsModeKeys: ArrayList - private lateinit var batteryOptimizations: CheckBox - private lateinit var defaultDialer: CheckBox - private lateinit var debug: CheckBox - private lateinit var sipTrace: CheckBox - private lateinit var reset: CheckBox + private var oldAutoStart = false + private var newAutoStart = false + private var oldDarkTheme = false + private var newDarkTheme = false + private var oldListenAddr = "" + private var newListenAddr = "" + private var oldAddressFamily = "" + private var newAddressFamily = "" + private var oldDnsServers = "" + private var newDnsServers = "" + private var oldTlsCertificateFile = false + private var newTlsCertificateFile = false + private var oldVerifyServer = false + private var newVerifyServer = false + private var oldCaFile = false + private var newCaFile = false + private var oldUserAgent = "" + private var newUserAgent = "" + private var oldBatteryOptimizations = false + private var newBatteryOptimizations = false + private var oldDefaultDialer = false + private var newDefaultDialer = false + private var oldContactsMode = "" + private var newContactsMode = "" + private var oldDebug = false + private var newDebug = false + private var oldSipTrace = false + private var newSipTrace = false + private var arrowTint = Color.Unspecified + + private lateinit var roleManager: RoleManager + private lateinit var powerManager: PowerManager + private lateinit var dialerRoleRequest: ActivityResultLauncher + private lateinit var androidSettingsRequest: ActivityResultLauncher private lateinit var requestPermissionLauncher: ActivityResultLauncher private lateinit var requestPermissionsLauncher: ActivityResultLauncher> - private var oldAutoStart = "" - private var oldListenAddr = "" private var managingOverlayPermission = false - private var oldDnsServers = "" - private var oldCertificateFile = false - private var oldVerifyServer = false - private var oldUserAgent = "" - private var oldLogLevel = "" - private var oldDisplayTheme = -1 - private var oldContactsMode = "" - private var oldNetAf = "" private var save = false private var restart = false private var audioRestart = false - private var menu: Menu? = null private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -92,143 +123,89 @@ class ConfigActivity : AppCompatActivity() { } } + private val certificateRequest = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + it.data?.data?.also { uri -> + try { + val inputStream = + applicationContext.contentResolver.openInputStream(uri) + as FileInputStream + val certPath = BaresipService.filesPath + "/cert.pem" + File(certPath).copyInputStreamToFile(inputStream) + inputStream.close() + Config.replaceVariable("sip_certificate", certPath) + save = true + restart = true + } catch (e: Error) { + Utils.alertView( + this, getString(R.string.error), + getString(R.string.read_cert_error) + ) + newTlsCertificateFile = false + } + } + } else { + newTlsCertificateFile = false + } + } + + private val caCertsFile = File(BaresipService.filesPath + "/ca_certs.crt") + private val caCertsRequest = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) + it.data?.data?.also { uri -> + try { + val inputStream = + applicationContext.contentResolver.openInputStream(uri) + as FileInputStream + caCertsFile.copyInputStreamToFile(inputStream) + inputStream.close() + restart = true + } catch (e: Error) { + Utils.alertView( + this, getString(R.string.error), + getString(R.string.read_ca_certs_error) + ) + newCaFile = false + } + } + else + newCaFile = false + if (!newCaFile && caCertsFile.exists()) + caCertsFile.delete() + } + + private val audioRequest = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + audioRestart = it.resultCode == RESULT_OK + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val contactsPermissions = arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS) + enableEdgeToEdge() - binding = ActivityConfigBinding.inflate(layoutInflater) - setContentView(binding.root) - layout = binding.ConfigView - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.ConfigView.updatePadding(top = 172) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true + val title = String.format(getString(R.string.configuration)) Utils.addActivity("config") baresipService = Intent(this@ConfigActivity, BaresipService::class.java) - autoStart = binding.AutoStart - oldAutoStart = Config.variable("auto_start") - autoStart.isChecked = oldAutoStart == "yes" - if (autoStart.isChecked && !isAppearOnTopPermissionGranted(this)) { + oldAutoStart = Config.variable("auto_start") == "yes" + if (oldAutoStart && !isAppearOnTopPermissionGranted(this)) { Config.replaceVariable("auto_start", "no") - oldAutoStart = "no" + oldAutoStart = false save = true - autoStart.isChecked = false - } - autoStart.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - if (!isAppearOnTopPermissionGranted(this)) { - with( - MaterialAlertDialogBuilder( - this@ConfigActivity, - R.style.AlertDialogTheme - ) - ) { - setTitle(getText(R.string.notice)) - setMessage(getText(R.string.appear_on_top_permission)) - setPositiveButton(getText(R.string.ok)) { dialog, _ -> - dialog.dismiss() - managingOverlayPermission = true - val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) - startActivity(intent) - } - setNegativeButton(getText(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - } - val dialog = this.create() - dialog.setCancelable(false) - dialog.setCanceledOnTouchOutside(false) - dialog.show() - } - autoStart.isChecked = false - } - } } - val pm = getSystemService(Context.POWER_SERVICE) as PowerManager - val androidSettingsRequest = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - batteryOptimizations.isChecked = pm.isIgnoringBatteryOptimizations(packageName) == false - } - batteryOptimizations = binding.BatteryOptimizations - batteryOptimizations.isChecked = pm.isIgnoringBatteryOptimizations(packageName) == false - batteryOptimizations.setOnCheckedChangeListener { _, _ -> - try { - androidSettingsRequest.launch(Intent("android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS")) - } catch (e: ActivityNotFoundException) { - Log.e(TAG, "ActivityNotFound exception: $e") - } - } - - if (Build.VERSION.SDK_INT >= 29) { - val roleManager = getSystemService(ROLE_SERVICE) as RoleManager - defaultDialer = binding.DefaultPhoneApp - defaultDialer.isChecked = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) - val dialerRoleRequest = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - Log.d(TAG, "dialerRoleRequest succeeded: ${it.resultCode == Activity.RESULT_OK}") - defaultDialer.isChecked = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) - } - defaultDialer.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - if (!roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)) - Utils.alertView(this, getString(R.string.notice), - getString(R.string.dialer_role_not_available)) - else - if (!roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) - dialerRoleRequest.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)) - } else { - try { - dialerRoleRequest.launch(Intent("android.settings.MANAGE_DEFAULT_APPS_SETTINGS")) - } catch (e: ActivityNotFoundException) { - Log.e(TAG, "ActivityNotFound exception: $e") - - } - } - } - } else { - binding.PhoneApp.visibility = View.GONE - } - - listenAddr = binding.ListenAddress oldListenAddr = Config.variable("sip_listen") - listenAddr.setText(oldListenAddr) - netAfSpinner = binding.NetAfSpinner - netAfKeys = arrayListOf("", "ipv4", "ipv6") - val netAfVals = arrayListOf("-", "IPv4", "IPv6") - oldNetAf = Config.variable("net_af").lowercase() - netAf = oldNetAf - val netAfIndex = netAfKeys.indexOf(oldNetAf) - val netAfValue = netAfVals.elementAt(netAfIndex) - netAfKeys.removeAt(netAfIndex) - netAfVals.removeAt(netAfIndex) - netAfKeys.add(0, oldNetAf) - netAfVals.add(0, netAfValue) - val netAfAdapter = ArrayAdapter(this,android.R.layout.simple_spinner_item, netAfVals) - netAfAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - netAfSpinner.adapter = netAfAdapter - netAfSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - netAf = netAfKeys[position] - } - override fun onNothingSelected(parent: AdapterView<*>) {} - } + oldAddressFamily = Config.variable("net_af").lowercase() + newAddressFamily = oldAddressFamily - dnsServers = binding.DnsServers val dynamicDns = Config.variable("dyn_dns") if (dynamicDns == "yes") { oldDnsServers = "" @@ -239,260 +216,1004 @@ class ConfigActivity : AppCompatActivity() { serverList += ", $server" oldDnsServers = serverList.trimStart(',').trimStart(' ') } - dnsServers.setText(oldDnsServers) - certificateFile = binding.CertificateFile - oldCertificateFile = Config.variable("sip_certificate") != "" - certificateFile.isChecked = oldCertificateFile + oldTlsCertificateFile = Config.variable("sip_certificate") != "" - val certificateRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - it.data?.data?.also { uri -> - try { - val inputStream = - applicationContext.contentResolver.openInputStream(uri) - as FileInputStream - val certPath = BaresipService.filesPath + "/cert.pem" - File(certPath).copyInputStreamToFile(inputStream) - inputStream.close() - Config.replaceVariable("sip_certificate", certPath) - save = true - restart = true - } catch (e: Error) { - Utils.alertView( - this, getString(R.string.error), - getString(R.string.read_cert_error) - ) - certificateFile.isChecked = false - } - } - } else { - certificateFile.isChecked = false - } - } - - certificateFile.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - if (Build.VERSION.SDK_INT < 29) { - certificateFile.isChecked = false - when { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Read External Storage permission granted") - val downloadsPath = Utils.downloadsPath("cert.pem") - val content = Utils.getFileContents(downloadsPath) - if (content == null) { - Utils.alertView( - this, getString(R.string.error), - getString(R.string.read_cert_error) - ) - return@setOnCheckedChangeListener - } - val certPath = BaresipService.filesPath + "/cert.pem" - Utils.putFileContents(certPath, content) - Config.replaceVariable("sip_certificate", certPath) - certificateFile.isChecked = true - save = true - restart = true - } - ActivityCompat.shouldShowRequestPermissionRationale( - this, - Manifest.permission.READ_EXTERNAL_STORAGE - ) -> { - layout.showSnackBar( - binding.root, - getString(R.string.no_restore), - Snackbar.LENGTH_INDEFINITE, - getString(R.string.ok) - ) { - requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - else -> { - requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - } else { - Utils.selectInputFile(certificateRequest) - } - } else { - Config.removeVariable("sip_certificate") - save = true - restart = true - } - } - - verifyServer = binding.VerifyServer - oldVerifyServer = Config.variable("sip_verify_server") == "yes" - verifyServer.isChecked = oldVerifyServer - - caFile = binding.CAFile val caCertsFile = File(BaresipService.filesPath + "/ca_certs.crt") - caFile.isChecked = caCertsFile.exists() + oldCaFile = caCertsFile.exists() - val certificatesRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) - it.data?.data?.also { uri -> - try { - val inputStream = - applicationContext.contentResolver.openInputStream(uri) - as FileInputStream - caCertsFile.copyInputStreamToFile(inputStream) - inputStream.close() - restart = true - } catch (e: Error) { - Utils.alertView( - this, getString(R.string.error), - getString(R.string.read_ca_certs_error) - ) - caFile.isChecked = false - } - } - else - caFile.isChecked = false - if (!caFile.isChecked && caCertsFile.exists()) - caCertsFile.delete() - } + powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + oldBatteryOptimizations = powerManager + .isIgnoringBatteryOptimizations(packageName) == false - caFile.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - if (Build.VERSION.SDK_INT < 29) { - caFile.isChecked = false - when { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Read External Storage permission granted") - val downloadsPath = Utils.downloadsPath("ca_certs.crt") - val content = Utils.getFileContents(downloadsPath) - if (content == null) { - Utils.alertView( - this, getString(R.string.error), - getString(R.string.read_ca_certs_error) - ) - return@setOnCheckedChangeListener - } - caCertsFile.writeBytes(content) - caFile.isChecked = true - restart = true - } - ActivityCompat.shouldShowRequestPermissionRationale( - this, - Manifest.permission.READ_EXTERNAL_STORAGE - ) -> { - layout.showSnackBar( - binding.root, - getString(R.string.no_restore), - Snackbar.LENGTH_INDEFINITE, - getString(R.string.ok) - ) { - requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - else -> { - requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - } else { - Utils.selectInputFile(certificatesRequest) - } - } else { - if (caCertsFile.exists()) - caCertsFile.delete() - restart = true - } + androidSettingsRequest = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + newBatteryOptimizations = powerManager + .isIgnoringBatteryOptimizations(packageName) == false } - userAgent = binding.UserAgent - oldUserAgent = Config.variable("user_agent") - userAgent.setText(oldUserAgent) + dialerRoleRequest = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + Log.d(TAG, "dialerRoleRequest succeeded: " + + "${it.resultCode == RESULT_OK}") + if (Build.VERSION.SDK_INT >= 29) + newDefaultDialer = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) + } - darkTheme = binding.DarkTheme - oldDisplayTheme = Preferences(applicationContext).displayTheme - darkTheme.isChecked = oldDisplayTheme == AppCompatDelegate.MODE_NIGHT_YES + if (Build.VERSION.SDK_INT >= 29) { + roleManager = getSystemService(ROLE_SERVICE) as RoleManager + oldDefaultDialer = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) + } - contactsSpinner = binding.contactsSpinner - contactsModeKeys = arrayListOf("baresip", "android", "both") - val contactsModeVals = arrayListOf(getString(R.string.baresip), getString(R.string.android), - getString(R.string.both)) oldContactsMode = Config.variable("contacts_mode").lowercase() - contactsMode = oldContactsMode - val keyIndex = contactsModeKeys.indexOf(oldContactsMode) - val keyValue = contactsModeVals.elementAt(keyIndex) - contactsModeKeys.removeAt(keyIndex) - contactsModeVals.removeAt(keyIndex) - contactsModeKeys.add(0, oldContactsMode) - contactsModeVals.add(0, keyValue) - val contactsAdapter = ArrayAdapter(this,android.R.layout.simple_spinner_item, - contactsModeVals) - contactsAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - contactsSpinner.adapter = contactsAdapter - contactsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { - contactsMode = contactsModeKeys[position] - if (contactsMode != "baresip" && !Utils.checkPermissions(applicationContext, contactsPermissions)) - with(MaterialAlertDialogBuilder(this@ConfigActivity, R.style.AlertDialogTheme)) { - setTitle(getText(R.string.consent_request)) - setMessage(getText(R.string.contacts_consent)) - setPositiveButton(getText(R.string.accept)) { dialog, _ -> - dialog.dismiss() - requestPermissions(contactsPermissions, CONTACTS_PERMISSION_REQUEST_CODE) - } - setNegativeButton(getText(R.string.deny)) { dialog, _ -> - contactsSpinner.setSelection(contactsModeKeys.indexOf(oldContactsMode)) - dialog.dismiss() - } - val dialog = this.create() - dialog.setCancelable(false) - dialog.setCanceledOnTouchOutside(false) - dialog.show().apply { - findViewById(android.R.id.message) - ?.movementMethod = LinkMovementMethod.getInstance() - } - } - } - override fun onNothingSelected(parent: AdapterView<*>) {} - } + newContactsMode = oldContactsMode - debug = binding.Debug - oldLogLevel = Config.variable("log_level") - debug.isChecked = oldLogLevel == "0" + oldDarkTheme = Preferences(applicationContext).displayTheme == + AppCompatDelegate.MODE_NIGHT_YES + newDarkTheme = oldDarkTheme - sipTrace = binding.SipTrace - sipTrace.isChecked = BaresipService.sipTrace + oldDebug = Config.variable("log_level") == "0" + newDebug = oldDebug - reset = binding.Reset - reset.isChecked = false + oldSipTrace = BaresipService.sipTrace + newSipTrace = oldSipTrace - reset.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - with(MaterialAlertDialogBuilder(this@ConfigActivity, R.style.AlertDialogTheme)) { - setTitle(R.string.confirmation) - setMessage(getString(R.string.reset_config_alert)) - setPositiveButton(getText(R.string.reset)) { dialog, _ -> - Config.reset() - save = false - restart = true - done() - dialog.dismiss() - } - setNeutralButton(getText(R.string.cancel)) { dialog, _ -> - reset.isChecked = false - dialog.dismiss() - } - show() + requestPermissionsLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { } + + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + ConfigScreen(this, title) { goBack() } } } } - bindTitles() - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } + + @Composable + fun ConfigScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { TopAppBar(title, navigateBack) }, + content = { contentPadding -> + ConfigContent(ctx, contentPadding) + } + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopAppBar(title: String, navigateBack: () -> Unit) { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton(onClick = { + checkOnClick() + }) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + } + ) + } + + @Composable + fun ConfigContent(ctx: Context, contentPadding: PaddingValues) { + arrowTint = if (BaresipService.darkTheme.value) + LocalCustomColors.current.grayLight + else + LocalCustomColors.current.black + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding), + ) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .imePadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { StartAutomatically(ctx) } + item { ListenAddress(ctx) } + item { AddressFamily(ctx) } + item { DnsServers(ctx) } + item { TlsCertificateFile(ctx) } + item { VerifyServer(ctx) } + item { CaFile(ctx) } + item { UserAgent(ctx) } + item { AudioSettings(ctx) } + item { BatteryOptimizations(ctx) } + if (Build.VERSION.SDK_INT >= 29) + item { DefaultDialer(ctx) } + item { Contacts(ctx) } + item { DarkTheme(ctx) } + item { Debug(ctx) } + item { SipTrace(ctx) } + item { Reset(ctx) } + } + } + } + + @Composable + private fun StartAutomatically(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.start_automatically), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.start_automatically), + getString(R.string.start_automatically_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var startAutomatically by remember { mutableStateOf(oldAutoStart) } + newAutoStart = startAutomatically + Checkbox( + checked = startAutomatically, + onCheckedChange = { + if (it) { + if (!isAppearOnTopPermissionGranted(ctx)) { + with( + MaterialAlertDialogBuilder( + ctx, + R.style.AlertDialogTheme + ) + ) { + setTitle(getText(R.string.notice)) + setMessage(getText(R.string.appear_on_top_permission)) + setPositiveButton(getText(R.string.ok)) { dialog, _ -> + dialog.dismiss() + managingOverlayPermission = true + val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + startActivity(intent) + } + setNegativeButton(getText(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + val dialog = this.create() + dialog.setCancelable(false) + dialog.setCanceledOnTouchOutside(false) + dialog.show() + } + startAutomatically = false + } + else + startAutomatically = true + } + else + startAutomatically = false + newAutoStart = startAutomatically + } + ) + } + } + + @Composable + private fun ListenAddress(ctx: Context) { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + var listenAddr by remember { mutableStateOf(oldListenAddr) } + newListenAddr = listenAddr + OutlinedTextField( + value = listenAddr, + placeholder = { Text(stringResource(R.string._0_0_0_0_5060)) }, + onValueChange = { + listenAddr = it + newListenAddr = listenAddr + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + Utils.alertView( + ctx, getString(R.string.listen_address), + getString(R.string.listen_address_help) + ) + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.listen_address)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun AddressFamily(ctx: Context) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.address_family), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.address_family), + getString(R.string.address_family_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val familyNames = listOf("--", "IPv4", "IPv6") + val familyValues = listOf("", "ipv4", "ipv6") + val itemPosition = remember { + mutableIntStateOf(familyValues.indexOf(oldAddressFamily)) + } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = familyNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + familyNames.forEachIndexed { index, family -> + DropdownMenuItem(text = { + Text(text = family) + }, + onClick = { + isDropDownExpanded.value = false + itemPosition.intValue = index + newAddressFamily = familyValues[index] + }) + if (index < 2) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + } + } + } + } + } + + @Composable + private fun DnsServers(ctx: Context) { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var dnsServers by remember { mutableStateOf(oldDnsServers) } + newDnsServers = dnsServers + OutlinedTextField( + value = dnsServers, + onValueChange = { + dnsServers = it + newDnsServers = dnsServers + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + Utils.alertView( + ctx, getString(R.string.dns_servers), + getString(R.string.dns_servers_help) + ) + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.dns_servers)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun TlsCertificateFile(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.tls_certificate_file), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.tls_certificate_file), + getString(R.string.tls_certificate_file_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var tlsCertificateFile by remember { mutableStateOf(oldTlsCertificateFile) } + Checkbox( + checked = tlsCertificateFile, + onCheckedChange = { + tlsCertificateFile = it + newTlsCertificateFile = tlsCertificateFile + if (it) + if (Build.VERSION.SDK_INT < 29) { + tlsCertificateFile = false + when { + ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED -> { + Log.d(TAG, "Read External Storage permission granted") + val downloadsPath = Utils.downloadsPath("cert.pem") + val content = Utils.getFileContents(downloadsPath) + if (content == null) { + Utils.alertView( + ctx, getString(R.string.error), + getString(R.string.read_cert_error) + ) + return@Checkbox + } + val certPath = BaresipService.filesPath + "/cert.pem" + Utils.putFileContents(certPath, content) + Config.replaceVariable("sip_certificate", certPath) + tlsCertificateFile = true + save = true + restart = true + } + shouldShowRequestPermissionRationale(Manifest.permission.READ_EXTERNAL_STORAGE) -> + Utils.alertView(ctx, getString(R.string.notice), getString(R.string.no_android_contacts)) { + requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + else -> + requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } else { + Utils.selectInputFile(certificateRequest) + } + else { + Config.removeVariable("sip_certificate") + save = true + restart = true + } + } + ) + } + } + + @Composable + private fun VerifyServer(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.verify_server), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.verify_server), + getString(R.string.verify_server_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var verifyServer by remember { mutableStateOf(oldVerifyServer) } + Checkbox( + checked = verifyServer, + onCheckedChange = { + verifyServer = it + newVerifyServer = verifyServer + } + ) + } + } + + @Composable + private fun CaFile(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.tls_ca_file), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.tls_ca_file), + getString(R.string.tls_certificate_file_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var caFile by remember { mutableStateOf(oldCaFile) } + Checkbox( + checked = caFile, + onCheckedChange = { + caFile = it + newCaFile = caFile + if (it) { + if (Build.VERSION.SDK_INT < 29) { + caFile = false + when { + ContextCompat.checkSelfPermission(ctx, + Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED -> { + Log.d(TAG, "Read External Storage permission granted") + val downloadsPath = Utils.downloadsPath("ca_certs.crt") + val content = Utils.getFileContents(downloadsPath) + if (content == null) { + Utils.alertView(ctx, getString(R.string.error), getString(R.string.read_ca_certs_error)) + return@Checkbox + } + caCertsFile.writeBytes(content) + caFile = true + restart = true + } + shouldShowRequestPermissionRationale(Manifest.permission.READ_EXTERNAL_STORAGE) -> + Utils.alertView(ctx, getString(R.string.notice), getString(R.string.no_android_contacts)) { + requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + else -> + requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } else { + Utils.selectInputFile(caCertsRequest) + } + } else { + if (caCertsFile.exists()) + caCertsFile.delete() + restart = true + } + } + ) + } + } + + @Composable + private fun UserAgent(ctx: Context) { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var userAgent by remember { mutableStateOf(oldUserAgent) } + newUserAgent = userAgent + OutlinedTextField( + value = userAgent, + placeholder = { Text(stringResource(R.string.user_agent)) }, + onValueChange = { + userAgent = it + newUserAgent = userAgent + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + Utils.alertView( + ctx, getString(R.string.user_agent), + getString(R.string.user_agent_help) + ) + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { Text(stringResource(R.string.user_agent)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + } + + @Composable + private fun AudioSettings(ctx: Context) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.audio_settings), + modifier = Modifier + .weight(1f) + .clickable { + audioRequest.launch(Intent(ctx, AudioActivity::class.java)) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + fontWeight = FontWeight. Bold + ) + } + } + + @Composable + private fun BatteryOptimizations(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.battery_optimizations), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.battery_optimizations), + getString(R.string.battery_optimizations_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var battery by remember { mutableStateOf(oldBatteryOptimizations) } + Checkbox( + checked = battery, + onCheckedChange = { + battery = it + newBatteryOptimizations = battery + try { + androidSettingsRequest.launch(Intent("android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS")) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "ActivityNotFound exception: $e") + } + } + ) + } + } + + @RequiresApi(29) + @Composable + private fun DefaultDialer(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.default_phone_app), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.default_phone_app), + getString(R.string.default_phone_app_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var defaultDialer by remember { mutableStateOf(oldDefaultDialer) } + Checkbox( + checked = defaultDialer, + onCheckedChange = { + defaultDialer = it + newDefaultDialer = defaultDialer + if (it) { + if (!roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)) + Utils.alertView(ctx, getString(R.string.notice), + getString(R.string.dialer_role_not_available)) + else + if (!roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) + dialerRoleRequest.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)) + } else { + try { + dialerRoleRequest.launch(Intent("android.settings.MANAGE_DEFAULT_APPS_SETTINGS")) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "ActivityNotFound exception: $e") + } + } + } + ) + } + } + + @Composable + private fun Contacts(ctx: Context) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.contacts), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.contacts), + getString(R.string.contacts_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val contactNames = listOf("baresip", "Android", "Both") + val contactValues = listOf("baresip", "android", "both") + val itemPosition = remember { + mutableIntStateOf(contactValues.indexOf(oldContactsMode)) + } + val contactsPermissions = arrayOf(Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS) + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = contactNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = arrowTint) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + contactNames.forEachIndexed { index, name -> + DropdownMenuItem(text = { + Text(text = name) + }, + onClick = { + isDropDownExpanded.value = false + val mode = contactValues[index] + if (mode != "baresip" && !Utils.checkPermissions(applicationContext, contactsPermissions)) + with(MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme)) { + setTitle(getText(R.string.consent_request)) + setMessage(getText(R.string.contacts_consent)) + setPositiveButton(getText(R.string.accept)) { dialog, _ -> + dialog.dismiss() + newContactsMode = mode + askForContactsPermission(ctx) + } + setNegativeButton(getText(R.string.deny)) { dialog, _ -> + itemPosition.intValue = contactValues.indexOf(oldContactsMode) + dialog.dismiss() + } + val dialog = this.create() + dialog.setCancelable(false) + dialog.setCanceledOnTouchOutside(false) + dialog.show() + } + else { + itemPosition.intValue = index + newContactsMode = contactValues[index] + } + }) + if (index < 2) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + } + } + } + } + } + + @Composable + private fun DarkTheme(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.dark_theme), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.dark_theme), + getString(R.string.dark_theme_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var darkTheme by remember { mutableStateOf(oldDarkTheme) } + Checkbox( + checked = darkTheme, + onCheckedChange = { + darkTheme = it + newDarkTheme = darkTheme + } + ) + } + } + + @Composable + private fun Debug(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.debug), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.debug), + getString(R.string.debug_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var debug by remember { mutableStateOf(oldDebug) } + Checkbox( + checked = debug, + onCheckedChange = { + debug = it + newDebug = debug + } + ) + } + } + + @Composable + private fun SipTrace(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.sip_trace), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.sip_trace), + getString(R.string.sip_trace_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var sipTrace by remember { mutableStateOf(oldSipTrace) } + Checkbox( + checked = sipTrace, + onCheckedChange = { + sipTrace = it + newSipTrace = sipTrace + } + ) + } + } + + @Composable + private fun Reset(ctx: Context) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.reset_config), + modifier = Modifier + .weight(1f) + .clickable { + Utils.alertView( + ctx, getString(R.string.reset_config), + getString(R.string.reset_config_help) + ) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var reset by remember { mutableStateOf(false) } + Checkbox( + checked = reset, + onCheckedChange = { + with(MaterialAlertDialogBuilder(ctx, + R.style.AlertDialogTheme)) { + setTitle(R.string.confirmation) + setMessage(getString(R.string.reset_config_alert)) + setPositiveButton(getText(R.string.reset)) { dialog, _ -> + Config.reset() + save = false + restart = true + done() + dialog.dismiss() + } + setNeutralButton(getText(R.string.cancel)) { dialog, _ -> + reset = false + dialog.dismiss() + } + show() + } + } + ) + } + } + + private fun checkOnClick() { + + if (BaresipService.activities.indexOf("config") == -1) + return + + if (oldAutoStart != newAutoStart) { + Config.replaceVariable("auto_start", + if (newAutoStart) "yes" else "no") + save = true + } + + val listenAddr = newListenAddr.trim() + if (listenAddr != oldListenAddr) { + if ((listenAddr != "") && !Utils.checkIpPort(listenAddr)) { + Utils.alertView(this, getString(R.string.notice), + "${getString(R.string.invalid_listen_address)}: $listenAddr") + return + } + Config.replaceVariable("sip_listen", listenAddr) + save = true + restart = true + } + + if (oldAddressFamily != newAddressFamily) { + Config.replaceVariable("net_af", newAddressFamily) + save = true + restart = true + } + + var dnsServers = newDnsServers.lowercase(Locale.ROOT) + .replace(" ", "") + dnsServers = addMissingPorts(dnsServers) + if (dnsServers != oldDnsServers.replace(" ", "")) { + if (!checkDnsServers(dnsServers)) { + Utils.alertView(this, getString(R.string.notice), + "${getString(R.string.invalid_dns_servers)}: $dnsServers") + return + } + Config.removeVariable("dns_server") + if (dnsServers.isNotEmpty()) { + for (server in dnsServers.split(",")) + Config.addVariable("dns_server", server) + Config.replaceVariable("dyn_dns", "no") + if (Api.net_use_nameserver(dnsServers) != 0) { + Utils.alertView(this, getString(R.string.error), + "${getString(R.string.failed_to_set_dns_servers)}: $dnsServers") + return + } + } else { + Config.replaceVariable("dyn_dns", "yes") + Config.updateDnsServers(BaresipService.dnsServers) + } + // Api.net_dns_debug() + save = true + } + + if (oldVerifyServer != newVerifyServer) { + Config.replaceVariable("sip_verify_server", + if (newVerifyServer) "yes" else "no") + Api.config_verify_server_set(newVerifyServer) + save = true + } + + newUserAgent = newUserAgent.trim() + if (newUserAgent != oldUserAgent) { + if ((newUserAgent != "") && !Utils.checkServerVal(newUserAgent)) { + Utils.alertView(this, getString(R.string.notice), + "${getString(R.string.invalid_user_agent)}: $newUserAgent") + return + } + if (newUserAgent != "") + Config.replaceVariable("user_agent", newUserAgent) + else + Config.removeVariable("user_agent") + save = true + restart = true + } + + if (oldContactsMode != newContactsMode) { + Config.replaceVariable("contacts_mode", newContactsMode) + BaresipService.contactsMode = newContactsMode + when (newContactsMode) { + "baresip" -> { + BaresipService.androidContacts.value = listOf() + Contact.restoreBaresipContacts() + baresipService.action = "Stop Content Observer" + } + "android" -> { + BaresipService.baresipContacts.value = listOf() + Contact.loadAndroidContacts(this) + baresipService.action = "Start Content Observer" + } + "both" -> { + Contact.restoreBaresipContacts() + Contact.loadAndroidContacts(this) + baresipService.action = "Start Content Observer" + } + } + Contact.contactsUpdate() + startService(baresipService) + save = true + } + + val newDisplayTheme = if (newDarkTheme) + AppCompatDelegate.MODE_NIGHT_YES + else + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + if (oldDarkTheme != newDarkTheme) { + Preferences(applicationContext).displayTheme = newDisplayTheme + BaresipService.darkTheme.value = newDarkTheme + Config.replaceVariable("dark_theme", + if (newDarkTheme) "yes" else "no") + save = true + } + + if (oldDebug != newDebug) { + val logLevelString = if (newDebug) "0" else "2" + Config.replaceVariable("log_level", logLevelString) + Api.log_level_set(logLevelString.toInt()) + Log.logLevelSet(logLevelString.toInt()) + save = true + } + + if (oldSipTrace != newSipTrace) { + BaresipService.sipTrace = newSipTrace + Api.uag_enable_sip_trace(newSipTrace) + } + + done() } @@ -500,7 +1221,7 @@ class ConfigActivity : AppCompatActivity() { super.onResume() if (managingOverlayPermission) { managingOverlayPermission = false - autoStart.isChecked = isAppearOnTopPermissionGranted(this) + oldAutoStart = isAppearOnTopPermissionGranted(this) } } @@ -508,219 +1229,45 @@ class ConfigActivity : AppCompatActivity() { super.onStart() requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} - requestPermissionsLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - if (it.containsValue(false)) { - contactsMode = oldContactsMode - contactsSpinner.setSelection(contactsModeKeys.indexOf(oldContactsMode)) - Snackbar.make(layout, getString(R.string.no_android_contacts), Snackbar.LENGTH_LONG) - .setAction(getString(R.string.ok)) {} - .show() - } - } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - - super.onCreateOptionsMenu(menu) - - val inflater = menuInflater - inflater.inflate(R.menu.check_icon, menu) - - this.menu = menu - - return true - - } - - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, - grantResults: IntArray) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - when (requestCode) { - CONTACTS_PERMISSION_REQUEST_CODE -> { - if (grantResults.contains(PackageManager.PERMISSION_DENIED)) { - when { - ActivityCompat.shouldShowRequestPermissionRationale(this, - Manifest.permission.READ_CONTACTS) -> { - layout.showSnackBar( - binding.root, - getString(R.string.no_android_contacts), - Snackbar.LENGTH_INDEFINITE, - getString(R.string.ok) - ) { - requestPermissionsLauncher.launch(permissions) - } - } - ActivityCompat.shouldShowRequestPermissionRationale(this, - Manifest.permission.WRITE_CONTACTS) -> { - layout.showSnackBar( - binding.root, - getString(R.string.no_android_contacts), - Snackbar.LENGTH_INDEFINITE, - getString(R.string.ok) - ) { - requestPermissionsLauncher.launch(permissions) - } - } - else -> { - requestPermissionsLauncher.launch(permissions) - } - } + private fun askForContactsPermission(ctx: Context) { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission( + this, + Manifest.permission.WRITE_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + ) { + Log.d(TAG, "Contacts permissions already granted") + } else { + if (shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS) || + shouldShowRequestPermissionRationale(Manifest.permission.WRITE_CONTACTS)) { + Utils.alertView(ctx, getString(R.string.notice), getString(R.string.no_android_contacts) + ) { + requestPermissionsLauncher.launch( + arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ) + ) } - } + } else + requestPermissionsLauncher.launch( + arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ) + ) } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (BaresipService.activities.indexOf("config") == -1) return true - - when (item.itemId) { - - R.id.checkIcon -> { - - val autoStartString = if (autoStart.isChecked) "yes" else "no" - if (oldAutoStart != autoStartString) { - Config.replaceVariable("auto_start", autoStartString) - save = true - } - - val listenAddr = listenAddr.text.toString().trim() - if (listenAddr != oldListenAddr) { - if ((listenAddr != "") && !Utils.checkIpPort(listenAddr)) { - Utils.alertView(this, getString(R.string.notice), - "${getString(R.string.invalid_listen_address)}: $listenAddr") - return false - } - if (listenAddr != "") - Config.replaceVariable("sip_listen", listenAddr) - else - Config.removeVariable("sip_listen") - save = true - restart = true - } - - if (oldNetAf != netAf) { - Config.replaceVariable("net_af", netAf) - save = true - restart = true - } - - var dnsServers = dnsServers.text.toString().lowercase(Locale.ROOT) - .replace(" ", "") - dnsServers = addMissingPorts(dnsServers) - if (dnsServers != oldDnsServers.replace(" ", "")) { - if (!checkDnsServers(dnsServers)) { - Utils.alertView(this, getString(R.string.notice), - "${getString(R.string.invalid_dns_servers)}: $dnsServers") - return false - } - Config.removeVariable("dns_server") - if (dnsServers.isNotEmpty()) { - for (server in dnsServers.split(",")) - Config.addVariable("dns_server", server) - Config.replaceVariable("dyn_dns", "no") - if (Api.net_use_nameserver(dnsServers) != 0) { - Utils.alertView(this, getString(R.string.error), - "${getString(R.string.failed_to_set_dns_servers)}: $dnsServers") - return false - } - } else { - Config.replaceVariable("dyn_dns", "yes") - Config.updateDnsServers(BaresipService.dnsServers) - } - // Api.net_dns_debug() - save = true - } - - if (oldVerifyServer != verifyServer.isChecked) { - Config.replaceVariable("sip_verify_server", - if (verifyServer.isChecked) "yes" else "no") - Api.config_verify_server_set(verifyServer.isChecked) - save = true - } - - val userAgent = userAgent.text.toString().trim() - if (userAgent != oldUserAgent) { - if ((userAgent != "") && !Utils.checkServerVal(userAgent)) { - Utils.alertView(this, getString(R.string.notice), - "${getString(R.string.invalid_user_agent)}: $userAgent") - return false - } - if (userAgent != "") - Config.replaceVariable("user_agent", userAgent) - else - Config.removeVariable("user_agent") - save = true - restart = true - } - - val newDisplayTheme = if (darkTheme.isChecked) - AppCompatDelegate.MODE_NIGHT_YES - else - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - if (oldDisplayTheme != newDisplayTheme) { - Preferences(applicationContext).displayTheme = newDisplayTheme - Config.replaceVariable("dark_theme", - if (darkTheme.isChecked) "yes" else "no") - save = true - } - - if (oldContactsMode != contactsMode) { - Config.replaceVariable("contacts_mode", contactsMode) - BaresipService.contactsMode = contactsMode - when (contactsMode) { - "baresip" -> { - BaresipService.androidContacts.clear() - Contact.restoreBaresipContacts() - baresipService.action = "Stop Content Observer" - } - "android" -> { - BaresipService.baresipContacts.clear() - Contact.loadAndroidContacts(this) - baresipService.action = "Start Content Observer" - } - "both" -> { - Contact.restoreBaresipContacts() - Contact.loadAndroidContacts(this) - baresipService.action = "Start Content Observer" - } - } - Contact.contactsUpdate() - startService(baresipService) - save = true - } - - val logLevelString = if (debug.isChecked) "0" else "2" - if (oldLogLevel != logLevelString) { - Config.replaceVariable("log_level", logLevelString) - Api.log_level_set(logLevelString.toInt()) - Log.logLevelSet(logLevelString.toInt()) - save = true - } - - BaresipService.sipTrace = sipTrace.isChecked - Api.uag_enable_sip_trace(sipTrace.isChecked) - - done() - - } - - android.R.id.home -> { - goBack() - } - } - - return true - - } - private fun isAppearOnTopPermissionGranted(ctx: Context): Boolean { return Settings.canDrawOverlays(ctx) } private fun done() { - if (save) Config.save() BaresipService.activities.remove("config") @@ -729,89 +1276,18 @@ class ConfigActivity : AppCompatActivity() { intent.putExtra("restart", true) setResult(RESULT_OK, intent) finish() - } private fun goBack() { - BaresipService.activities.remove("config") if (audioRestart) { val intent = Intent(this, MainActivity::class.java) intent.putExtra("restart", true) setResult(RESULT_OK, intent) } else { - setResult(Activity.RESULT_CANCELED, Intent(this, MainActivity::class.java)) + setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java)) } finish() - - } - - private fun bindTitles() { - val audioRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - audioRestart = it.resultCode == Activity.RESULT_OK - } - binding.AutoStartTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.start_automatically), - getString(R.string.start_automatically_help)) - } - binding.ListenAddressTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.listen_address), - getString(R.string.listen_address_help)) - } - binding.NetAfTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.address_family), - getString(R.string.address_family_help)) - } - binding.DnsServersTitle .setOnClickListener { - Utils.alertView(this, getString(R.string.dns_servers), - getString(R.string.dns_servers_help)) - } - binding.CertificateFileTitle .setOnClickListener { - Utils.alertView(this, getString(R.string.tls_certificate_file), - getString(R.string.tls_certificate_file_help)) - } - binding.VerifyServerTitle .setOnClickListener { - Utils.alertView(this, getString(R.string.verify_server), - getString(R.string.verify_server_help)) - } - binding.CAFileTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.tls_ca_file), - getString(R.string.tls_ca_file_help)) - } - binding.UserAgentTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.user_agent), - getString(R.string.user_agent_help)) - } - binding.AudioSettingsTitle.setOnClickListener { - audioRequest.launch(Intent(this, AudioActivity::class.java)) - } - binding.DarkThemeTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.dark_theme), - getString(R.string.dark_theme_help)) - } - binding.ContactsTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.contacts), - getString(R.string.contacts_help)) - } - binding.BatteryOptimizationsTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.battery_optimizations), - getString(R.string.battery_optimizations_help)) - } - binding.DefaultPhoneAppTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.default_phone_app), - getString(R.string.default_phone_app_help)) - } - binding.DebugTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.debug), getString(R.string.debug_help)) - } - binding.SipTraceTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.sip_trace), - getString(R.string.sip_trace_help)) - } - binding.ResetTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.reset_config), - getString(R.string.reset_config_help)) - } } private fun checkDnsServers(dnsServers: String): Boolean { diff --git a/app/src/main/kotlin/com/tutpro/baresip/Constants.kt b/app/src/main/kotlin/com/tutpro/baresip/Constants.kt index 78499fa4..614c6c85 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Constants.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Constants.kt @@ -6,8 +6,6 @@ const val DEFAULT_CHANNEL_ID = "com.tutpro.baresip.default" const val MEDIUM_CHANNEL_ID = "com.tutpro.baresip.medium" const val HIGH_CHANNEL_ID = "com.tutpro.baresip.high" -const val CONTACTS_PERMISSION_REQUEST_CODE = 1 - const val STATUS_NOTIFICATION_ID = 101 const val CALL_NOTIFICATION_ID = 102 const val CALL_MISSED_NOTIFICATION_ID = 103 diff --git a/app/src/main/kotlin/com/tutpro/baresip/Contact.kt b/app/src/main/kotlin/com/tutpro/baresip/Contact.kt index 4282af68..ecd490f9 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Contact.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Contact.kt @@ -7,33 +7,61 @@ import android.graphics.BitmapFactory import android.net.Uri import android.provider.ContactsContract import androidx.core.net.toUri +import com.tutpro.baresip.BaresipService.Companion.contactNames import java.io.File import java.util.ArrayList sealed class Contact { - class BaresipContact(var name: String, var uri: String, var color: Int, val id: Long, + class BaresipContact(var name: String, var uri: String, var color: Int, var id: Long, var favorite: Boolean): Contact() { var avatarImage: Bitmap? = null } class AndroidContact(var name: String, var color: Int, var thumbnailUri: Uri?, - var favorite: Boolean): Contact() { + var id: Long, var favorite: Boolean): Contact() { val uris = ArrayList() } + fun name(): String { + return when (this) { + is AndroidContact -> name + is BaresipContact -> name + } + } + + fun id(): Long { + return when (this) { + is AndroidContact -> id + is BaresipContact -> id + } + } + + fun favorite(): Boolean { + return when (this) { + is AndroidContact -> favorite + is BaresipContact -> favorite + } + } + + fun copy(): Contact { + val copy = when (this) { + is BaresipContact -> + BaresipContact(name, uri, color, id, favorite) + is AndroidContact -> + AndroidContact(name, color, thumbnailUri, id, favorite) + } + when (this) { + is BaresipContact -> + (copy as BaresipContact).avatarImage = avatarImage + is AndroidContact -> + (copy as AndroidContact).uris.addAll(uris) + } + return copy + } + companion object { - const val CONTACTS_SIZE = 256 - - fun contacts(): ArrayList { - return BaresipService.contacts - } - - fun contactNames(): ArrayList { - return BaresipService.contactNames - } - // Return contact name of uri or uri itself if contact with uri is not found fun contactName(uri: String): String { var contact = findContact(uri) @@ -42,21 +70,29 @@ sealed class Contact { if (Utils.isTelNumber(userPart)) contact = findContact("tel:$userPart") } - if (contact != null) { - return when (contact) { - is BaresipContact -> - contact.name - is AndroidContact -> - contact.name - } - } + if (contact != null) + return contact.name() return uri } + fun baresipContact(name: String): BaresipContact? { + for (c in BaresipService.baresipContacts.value) + if (c.name == name) + return c + return null + } + + fun androidContact(name: String): AndroidContact? { + for (c in BaresipService.androidContacts.value) + if (c.name == name) + return c + return null + } + // Return URIs of contact name fun contactUris(name: String): ArrayList { val uris = ArrayList() - for (c in contacts()) + for (c in BaresipService.contacts) when (c) { is BaresipContact -> { if (c.name.equals(name, ignoreCase = true)) { @@ -78,7 +114,7 @@ sealed class Contact { } fun findContact(uri: String): Contact? { - for (c in contacts()) + for (c in BaresipService.contacts) when (c) { is BaresipContact -> { if (Utils.uriMatch(c.uri, uri)) @@ -95,22 +131,16 @@ sealed class Contact { return null } - fun nameExists(name: String, ignoreCase: Boolean): Boolean { - for (c in BaresipService.contacts) - when (c) { - is BaresipContact -> - if (c.name.equals(name, ignoreCase = ignoreCase)) - return true - is AndroidContact -> - if (c.name.equals(name, ignoreCase = ignoreCase)) - return true - } + fun nameExists(name: String, list: List, ignoreCase: Boolean): Boolean { + for (c in list) + if (c.name().equals(name, ignoreCase = ignoreCase)) + return true return false } fun saveBaresipContacts() { var contents = "" - for (c in BaresipService.baresipContacts) + for (c in BaresipService.baresipContacts.value) contents += "\"${c.name}\" <${c.uri}>;id=${c.id};color=${c.color}" + ";favorite=${if (c.favorite) "yes" else "no"}\n" Utils.putFileContents(BaresipService.filesPath + "/contacts", @@ -129,7 +159,7 @@ sealed class Contact { ContactsContract.Data.MIMETYPE + "='" + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE + "'" val cur: Cursor? = ctx.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, null, null) - BaresipService.androidContacts.clear() + BaresipService.androidContacts.value = listOf() val contacts = HashMap() while (cur != null && cur.moveToNext()) { val id = cur.getLong(0) @@ -141,7 +171,7 @@ sealed class Contact { val contact = if (contacts.containsKey(id)) contacts[id]!! else - AndroidContact(name, Utils.randomColor(), thumb, starred == 1) + AndroidContact(name, Utils.randomColor(), thumb, id, starred == 1) if (contact.name == "" && name != "") contact.name = name if (contact.thumbnailUri == null && thumb != null) @@ -158,9 +188,11 @@ sealed class Contact { contacts[id] = contact } cur?.close() + val newList = mutableListOf() for ((_, value) in contacts) if (value.name != "" && value.uris.isNotEmpty()) - BaresipService.androidContacts.add(value) + newList.add(value) + BaresipService.androidContacts.value = newList.toList() } @Suppress("unused") @@ -179,7 +211,8 @@ sealed class Contact { val contacts = String(content) var contactNo = 0 val baseId = System.currentTimeMillis() - BaresipService.baresipContacts.clear() + val restored = mutableListOf() + BaresipService.baresipContacts.value = listOf() contacts.lines().forEach { val parts = it.split("\"") if (parts.size == 3) { @@ -212,55 +245,62 @@ sealed class Contact { Log.e(TAG, "Could not read avatar image from '$id.png") } } - BaresipService.baresipContacts.add(contact) + restored.add(contact) } } + BaresipService.baresipContacts.value = restored.toList() return true } fun contactsUpdate() { - BaresipService.contacts.clear() + val newList = mutableListOf() if (BaresipService.contactsMode != "android") - BaresipService.contacts.addAll(BaresipService.baresipContacts) + for (c in BaresipService.baresipContacts.value) + newList.add(c.copy()) if (BaresipService.contactsMode != "baresip") - addAndroidContacts() - sortContacts() + for (c in BaresipService.androidContacts.value) + if (!nameExists(c.name, newList, true)) + newList.add(c.copy()) + newList.sortBy{ when (it) { + is BaresipContact -> if (it.favorite) "0" + it.name else "1" + it.name + is AndroidContact -> if (it.favorite) "0" + it.name else "1" + it.name + }} + BaresipService.contacts = newList.toList() generateContactNames() - BaresipService.contactUpdate.postValue(System.nanoTime()) + //BaresipService.contactUpdate.postValue(System.nanoTime()) } fun addBaresipContact(contact: BaresipContact) { - BaresipService.baresipContacts.add(contact) + val updatedList = BaresipService.baresipContacts.value.toMutableList() + updatedList.add(contact) + BaresipService.baresipContacts.value = updatedList.toList() + } + + fun updateBaresipContact(contact: BaresipContact) { + val updatedList = BaresipService.baresipContacts.value.toMutableList() + val contactCopy = contact.copy() + updatedList.removeIf { it.id == contact.id } + updatedList.add(contactCopy as BaresipContact) + BaresipService.baresipContacts.value = updatedList.toList() } fun removeBaresipContact(contact: BaresipContact) { - BaresipService.baresipContacts.remove(contact) + val updatedList = BaresipService.baresipContacts.value.toMutableList() + updatedList.removeIf { it.id == contact.id } + BaresipService.baresipContacts.value = updatedList.toList() saveBaresipContacts() } private fun generateContactNames () { - BaresipService.contactNames.clear() + val newList = mutableListOf() for (c in BaresipService.contacts) when (c) { is BaresipContact -> - BaresipService.contactNames.add(c.name) + newList.add(c.name) is AndroidContact -> - BaresipService.contactNames.add(c.name) + newList.add(c.name) } + contactNames.value = newList.toList() } - - private fun addAndroidContacts() { - for (c in BaresipService.androidContacts) - if (!nameExists(c.name, true)) - BaresipService.contacts.add(c) - } - - private fun sortContacts() { - BaresipService.contacts.sortBy{ when (it) { - is BaresipContact -> if (it.favorite) "0" + it.name else "1" + it.name - is AndroidContact -> if (it.favorite) "0" + it.name else "1" + it.name - }} - } - } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactActivity.kt deleted file mode 100644 index 4d0b9596..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactActivity.kt +++ /dev/null @@ -1,524 +0,0 @@ -package com.tutpro.baresip - -import android.app.Activity -import android.content.ContentProviderOperation -import android.content.ContentValues -import android.content.Context -import android.content.Intent -import android.database.Cursor -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.graphics.drawable.BitmapDrawable -import android.os.Build -import android.os.Bundle -import android.provider.ContactsContract -import android.provider.ContactsContract.CommonDataKinds -import android.provider.ContactsContract.Contacts.Data -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.* -import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.cardview.widget.CardView -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding -import androidx.exifinterface.media.ExifInterface -import com.tutpro.baresip.databinding.ActivityContactBinding -import java.io.ByteArrayOutputStream -import java.io.File - -class ContactActivity : AppCompatActivity() { - - private lateinit var binding: ActivityContactBinding - private lateinit var layout: LinearLayout - private lateinit var textAvatarView: TextView - private lateinit var cardAvatarView: CardView - private lateinit var cardImageAvatarView: ImageView - private lateinit var nameView: EditText - private lateinit var uriView: EditText - private lateinit var favoriteCheck: CheckBox - private lateinit var androidCheck: CheckBox - private lateinit var menu: Menu - - private var newContact = false - private var newAvatar = "" - private var uOrI = "" - - private var index = 0 - private var color = 0 - private var id: Long = 0 - - private val onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - binding = ActivityContactBinding.inflate(layoutInflater) - setContentView(binding.root) - layout = binding.ContactView - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.ContactView.updatePadding(top = systemBars.top + 56) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true - textAvatarView = binding.TextAvatar - cardAvatarView = binding.CardAvatar - cardImageAvatarView = binding.ImageAvatar - nameView = binding.Name - uriView = binding.Uri - favoriteCheck = binding.Favorite - androidCheck = binding.Android - - newContact = intent.getBooleanExtra("new", false) - - if (newContact) { - if (BaresipService.contactsMode == "baresip") { - binding.AndroidTitle.visibility = View.GONE - androidCheck.visibility = View.GONE - } - title = getString(R.string.new_contact) - color = Utils.randomColor() - id = System.currentTimeMillis() - showTextAvatar("?", color) - nameView.setText("") - nameView.hint = getString(R.string.contact_name) - nameView.setSelection(nameView.text.length) - val uri = intent.getStringExtra("uri")!! - if (uri == "") - uriView.setText("") - else - uriView.setText(uri) - uOrI = uri - favoriteCheck.isChecked = false - if ((BaresipService.contactsMode == "android")) { - androidCheck.isChecked = true - androidCheck.isClickable = false - } else { - androidCheck.isChecked = false - } - } else { - binding.AndroidTitle.visibility = View.GONE - androidCheck.visibility = View.GONE - index = intent.getIntExtra("index", 0) - val contact = Contact.contacts()[index] as Contact.BaresipContact - val name = contact.name - color = contact.color - id = contact.id - val avatarImage = contact.avatarImage - if (avatarImage != null) - showImageAvatar(avatarImage) - else - showTextAvatar(name, color) - title = name - nameView.setText(name) - uriView.setText(contact.uri) - favoriteCheck.isChecked = contact.favorite - uOrI = index.toString() - } - - val avatarRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.also { uri -> - try { - val inputStream = baseContext.contentResolver.openInputStream(uri) - val avatarBitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() - val scaledBitmap = Bitmap.createScaledBitmap(avatarBitmap, 192, 192, true) - val exif = ExifInterface(baseContext.contentResolver.openInputStream(uri)!!) - val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL) - val rotatedBitmap = rotateBitmap(scaledBitmap, orientation) - showImageAvatar(rotatedBitmap) - if (Utils.saveBitmap(rotatedBitmap, File(BaresipService.filesPath, "tmp.png"))) - newAvatar = "image" - } catch (e: Exception) { - Log.e(TAG, "Could not read avatar image: $e") - } - } - } - } - - textAvatarView.setOnClickListener { - - val intent = Intent(Intent.ACTION_GET_CONTENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "image/*" - } - avatarRequest.launch(intent) - - } - - textAvatarView.setOnLongClickListener { - - color = Utils.randomColor() - showTextAvatar(textAvatarView.text.toString(), color) - newAvatar = "text" - true - - } - - cardAvatarView.setOnClickListener { - - val intent = Intent(Intent.ACTION_GET_CONTENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "image/*" - } - avatarRequest.launch(intent) - - } - - cardAvatarView.setOnLongClickListener { - - color = Utils.randomColor() - showTextAvatar(nameView.text.toString(), color) - newAvatar = "text" - true - - } - - binding.AndroidTitle.setOnClickListener { - Utils.alertView(this, getString(R.string.android), - getString(R.string.android_contact_help)) - } - - // Log.d(TAG, "Android sip contacts ${logAndroidContacts(this, "sip")}") - // Log.d(TAG, "Android tel contacts ${logAndroidContacts(this, "tel")}") - - Utils.addActivity("contact,$newContact,$uOrI") - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - - } - - override fun onCreateOptionsMenu(optionsMenu: Menu): Boolean { - - super.onCreateOptionsMenu(optionsMenu) - - val inflater = menuInflater - inflater.inflate(R.menu.check_icon, optionsMenu) - menu = optionsMenu - return true - - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (BaresipService.activities.indexOf("contact,$newContact,$uOrI") == -1) return true - - when (item.itemId) { - - R.id.checkIcon -> { - - var newName = nameView.text.toString().trim() - var newUri = uriView.text.toString().filterNot{setOf('-', ' ', '(', ')').contains(it)} - - if (newName == "") newName = newUri - - if (!Utils.checkName(newName)) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_contact), newName)) - return false - } - - val alert: Boolean = if (newContact) - Contact.nameExists(newName, true) - else { - val c = Contact.contacts()[index] as Contact.BaresipContact - (c.name != newName) && Contact.nameExists(newName, false) - } - if (alert) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.contact_already_exists), newName)) - return false - } - - if (!newUri.startsWith("sip:") && !newUri.startsWith("tel:")) - newUri = if (Utils.isTelNumber(newUri)) - "tel:$newUri" - else - "sip:$newUri" - if (!Utils.checkUri(newUri)) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_sip_or_tel_uri), newUri)) - return false - } - - val contact: Contact.BaresipContact - - if (newContact) { - if (Contact.contacts().size >= Contact.CONTACTS_SIZE) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.contacts_exceeded), - Contact.CONTACTS_SIZE)) - BaresipService.activities.removeAt(0) - return true - } else { - contact = Contact.BaresipContact(newName, newUri, color, id, favoriteCheck.isChecked) - } - } else { - contact = Contact.contacts()[index] as Contact.BaresipContact - contact.uri = newUri - contact.name = newName - contact.color = color - contact.favorite = favoriteCheck.isChecked - } - - when (newAvatar) { - "text" -> { - if (contact.avatarImage != null) { - contact.avatarImage = null - Utils.deleteFile(File(BaresipService.filesPath, "${contact.id}.png")) - } - } - "image" -> { - contact.avatarImage = (cardImageAvatarView.drawable as BitmapDrawable).bitmap - Utils.deleteFile(File(BaresipService.filesPath, "${contact.id}.png")) - File(BaresipService.filesPath, "tmp.png") - .renameTo(File(BaresipService.filesPath, "${contact.id}.png")) - } - } - - if (androidCheck.isChecked) { - addOrUpdateAndroidContact(this, contact) - } else { - if (newContact) - Contact.addBaresipContact(contact) - Contact.saveBaresipContacts() - Contact.contactsUpdate() - } - - BaresipService.activities.remove("contact,$newContact,$uOrI") - - val i = Intent(this, MainActivity::class.java) - if (androidCheck.isChecked && contact.favorite) - i.putExtra("name", newName) - setResult(Activity.RESULT_OK, i) - finish() - } - - android.R.id.home -> { - goBack() - } - - } - - return true - - } - - private fun goBack() { - BaresipService.activities.remove("contact,$newContact,$uOrI") - setResult(Activity.RESULT_CANCELED, Intent(this, MainActivity::class.java)) - finish() - } - - private fun showTextAvatar(name: String, color: Int) { - textAvatarView.visibility = View.VISIBLE - cardAvatarView.visibility = View.GONE - textAvatarView.background.setTint(color) - textAvatarView.text = "${name[0]}" - } - - private fun showImageAvatar(image: Bitmap) { - textAvatarView.visibility = View.GONE - cardAvatarView.visibility = View.VISIBLE - cardImageAvatarView.setImageBitmap(image) - } - - private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap { - val matrix = Matrix() - when (orientation) { - ExifInterface.ORIENTATION_NORMAL -> return bitmap - ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) - ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) - ExifInterface.ORIENTATION_FLIP_VERTICAL -> { - matrix.setRotate(180f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_TRANSPOSE -> { - matrix.setRotate(90f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) - ExifInterface.ORIENTATION_TRANSVERSE -> { - matrix.setRotate(-90f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) - else -> return bitmap - } - val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, - bitmap.width, bitmap.height, matrix, true) - bitmap.recycle() - return rotatedBitmap - } - - private fun addOrUpdateAndroidContact(ctx: Context, contact: Contact.BaresipContact) { - val projection = arrayOf(ContactsContract.Data.RAW_CONTACT_ID) - val selection = ContactsContract.Data.MIMETYPE + "='" + - CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE + "' AND " + - CommonDataKinds.StructuredName.DISPLAY_NAME + "='" + contact.name + "'" - val c: Cursor? = ctx.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, - selection, null, null) - if (c != null && c.moveToFirst()) { - updateAndroidContact(c.getLong(0), contact) - } else { - addAndroidContact(ctx, contact) - } - c?.close() - } - - private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Boolean { - val ops = ArrayList() - ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) - .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) - .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null).build()) - ops.add(ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValueBackReference(Data.RAW_CONTACT_ID, 0) - .withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name) - .build()) - val mimeType = if (contact.uri.startsWith("sip:")) - CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE - else - CommonDataKinds.Phone.CONTENT_ITEM_TYPE - ops.add(ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValueBackReference(Data.RAW_CONTACT_ID, 0) - .withValue(Data.MIMETYPE, mimeType) - .withValue(Data.DATA1, contact.uri.substringAfter(":")) - .build()) - - if (contact.avatarImage != null) { - val photoData: ByteArray? = bitmapToPNGByteArray(contact.avatarImage!!) - if (photoData != null) { - ops.add(ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValueBackReference(Data.RAW_CONTACT_ID, 0) - .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.Photo.PHOTO, photoData) - .build()) - } - } - try { - ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) - } catch (e: Exception) { - Log.e(TAG, "Adding of contact ${contact.name} failed") - return false - } - return true - } - - private fun updateAndroidContact(rawContactId: Long, contact: Contact.BaresipContact) { - if (updateAndroidUri(rawContactId, contact.uri) == 0) - addAndroidUri(rawContactId, contact.uri) - if (updateAndroidPhoto(rawContactId, contact.avatarImage) == 0) - if (contact.avatarImage != null) - addAndroidPhoto(rawContactId, contact.avatarImage!!) - } - - private fun addAndroidUri(rawContactId: Long, uri: String) { - val mimeType = if (uri.startsWith("sip:")) - CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE - else - CommonDataKinds.Phone.CONTENT_ITEM_TYPE - val ops = ArrayList() - ops.add(ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValue(Data.RAW_CONTACT_ID, rawContactId) - .withValue(Data.MIMETYPE, mimeType) - .withValue(Data.DATA1, uri.substringAfter(":")) - .build()) - try { - contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) - } catch (e: Exception) { - Log.e(TAG, "Adding of SIP URI $uri failed") - } - } - - private fun updateAndroidUri(rawContactId: Long, uri: String): Int { - val mimeType = if (uri.startsWith("sip:")) - CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE - else - CommonDataKinds.Phone.CONTENT_ITEM_TYPE - val contentValues = ContentValues() - contentValues.put(ContactsContract.Data.DATA1, uri) - val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + - "${ContactsContract.Data.MIMETYPE}='$mimeType'" - return try { - contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) - } catch (e: Exception) { - Log.e(TAG, "Update of Android URI $uri failed") - 0 - } - } - - private fun addAndroidPhoto(rawContactId: Long, photoBits: Bitmap) { - val photoBytes = bitmapToPNGByteArray(photoBits) - if (photoBytes != null) { - val ops = ArrayList() - ops.add(ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValue(Data.RAW_CONTACT_ID, rawContactId) - .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.Photo.PHOTO, photoBytes) - .build()) - try { - contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) - } catch (e: Exception) { - Log.e(TAG, "Adding of Android photo failed") - } - } - } - - private fun updateAndroidPhoto(rawContactId: Long, photoBits: Bitmap?): Int { - val photoBytes = if (photoBits == null) - null - else - bitmapToPNGByteArray(photoBits) - val contentValues = ContentValues() - contentValues.put(CommonDataKinds.Photo.PHOTO, photoBytes) - val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + - "${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Photo.CONTENT_ITEM_TYPE}'" - return try { - contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) - } catch (e: Exception) { - Log.e(TAG, "updateAndroidPhoto failed") - 0 - } - } - - private fun bitmapToPNGByteArray(bitmap: Bitmap): ByteArray? { - val size = bitmap.width * bitmap.height * 4 - val out = ByteArrayOutputStream(size) - return try { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - out.flush() - out.close() - out.toByteArray() - } catch (e: Exception) { - Log.w(TAG, "Unable to serialize photo: $e") - null - } - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactListAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactListAdapter.kt deleted file mode 100644 index 12c02f7a..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactListAdapter.kt +++ /dev/null @@ -1,218 +0,0 @@ -package com.tutpro.baresip - -import android.app.Activity -import android.content.Context -import android.content.DialogInterface -import android.content.Intent -import android.graphics.Typeface -import android.os.Bundle -import android.os.SystemClock -import android.provider.ContactsContract -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.io.File -import java.io.IOException - -class ContactListAdapter(private val ctx: Context, private val rows: ArrayList, - private val aor: String) : - ArrayAdapter(ctx, R.layout.contact_row, rows) { - - private val layoutInflater = LayoutInflater.from(context) - private var lastClick: Long = 0 - - private class ViewHolder(view: View?) { - val textAvatarView: TextView = view?.findViewById(R.id.TextAvatar)!! - val imageAvatarView: ImageView = view?.findViewById(R.id.ImageAvatar)!! - val nameView: TextView = view?.findViewById(R.id.contactName)!! - val actionView: ImageButton = view?.findViewById(R.id.edit)!! - } - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.contact_row, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val contact = rows[position] - - if (contact is Contact.BaresipContact) { - - val avatarImage = contact.avatarImage - if (avatarImage != null) { - viewHolder.imageAvatarView.setImageBitmap(avatarImage) - } else { - viewHolder.textAvatarView.background.setTint(contact.color) - if (contact.name.isNotEmpty()) - viewHolder.textAvatarView.text = "${contact.name[0]}" - else - viewHolder.textAvatarView.text = "" - viewHolder.imageAvatarView.setImageBitmap(Utils.bitmapFromView(viewHolder.textAvatarView)) - } - - viewHolder.nameView.text = contact.name - viewHolder.nameView.textSize = 20f - if (contact.favorite) - viewHolder.nameView.setTypeface(null, Typeface.ITALIC) - else - viewHolder.nameView.setTypeface(null, Typeface.NORMAL) - viewHolder.nameView.setPadding(6, 6, 0, 6) - - if (aor != "") { - viewHolder.nameView.setOnClickListener { - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE, DialogInterface.BUTTON_NEGATIVE -> { - val i = Intent(ctx, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - if (which == DialogInterface.BUTTON_NEGATIVE) - i.putExtra("action", "call") - else - i.putExtra("action", "message") - val ua = UserAgent.ofAor(aor) - if (ua == null) { - Log.w(TAG, "onClickListener did not find AoR $aor") - } else { - BaresipService.activities.clear() - i.putExtra("uap", ua.uap) - i.putExtra("peer", contact.uri) - (ctx as Activity).startActivity(i) - } - } - DialogInterface.BUTTON_NEUTRAL -> { - } - } - } - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - with(MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme)) { - setTitle(R.string.confirmation) - setMessage(String.format(ctx.getString(R.string.contact_action_question), - contact.name)) - setNeutralButton(ctx.getText(R.string.cancel), dialogClickListener) - setNegativeButton(ctx.getText(R.string.call), dialogClickListener) - setPositiveButton(ctx.getText(R.string.send_message), dialogClickListener) - show() - } - } - } - } - - viewHolder.actionView.visibility = View.VISIBLE - viewHolder.actionView.setOnClickListener { - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - val i = Intent(ctx, ContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", false) - b.putInt("index", position) - i.putExtras(b) - ctx.startActivity(i, null) - } - } - - } - - if (contact is Contact.AndroidContact) { - - val thumbNailUri = contact.thumbnailUri - if (thumbNailUri != null) { - viewHolder.imageAvatarView.setImageURI(thumbNailUri) - } else { - viewHolder.textAvatarView.background.setTint(contact.color) - if (contact.name.isNotEmpty()) - viewHolder.textAvatarView.text = "${contact.name[0]}" - else - viewHolder.textAvatarView.text = "" - viewHolder.imageAvatarView.setImageBitmap(Utils.bitmapFromView(viewHolder.textAvatarView)) - } - - viewHolder.nameView.text = contact.name - viewHolder.nameView.textSize = 20f - if (contact.favorite) - viewHolder.nameView.setTypeface(null, Typeface.ITALIC) - else - viewHolder.nameView.setTypeface(null, Typeface.NORMAL) - viewHolder.nameView.setPadding(6, 6, 0, 6) - - viewHolder.nameView.setOnClickListener { - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - val i = Intent(ctx, AndroidContactActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - b.putInt("index", position) - i.putExtras(b) - ctx.startActivity(i, null) - } - } - - viewHolder.actionView.visibility = View.GONE - } - - viewHolder.nameView.setOnLongClickListener { - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - when (contact) { - is Contact.BaresipContact -> { - val id = contact.id - val avatarFile = File(BaresipService.filesPath, "$id.png") - if (avatarFile.exists()) { - try { - avatarFile.delete() - } catch (e: IOException) { - Log.e(TAG, "Could not delete file '$id.png") - } - } - Contact.removeBaresipContact(contact) - Contact.contactsUpdate() - this.notifyDataSetChanged() - } - is Contact.AndroidContact -> { - deleteAndroidContact(ctx, contact.name) - } - } - } - DialogInterface.BUTTON_NEGATIVE -> { - } - } - } - with(MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme)) { - setTitle(R.string.confirmation) - setMessage(String.format(ctx.getString(R.string.contact_delete_question), - when (contact) { - is Contact.BaresipContact -> contact.name - is Contact.AndroidContact -> contact.name - })) - setNeutralButton(ctx.getText(R.string.cancel), dialogClickListener) - setPositiveButton(ctx.getText(R.string.delete), dialogClickListener) - show() - } - true - } - - return rowView - } - - private fun deleteAndroidContact(ctx: Context, name: String): Int { - return ctx.contentResolver.delete(ContactsContract.RawContacts.CONTENT_URI, - ContactsContract.Contacts.DISPLAY_NAME + "='" + name + "'", - null) - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactsActivity.kt index 8e6e9716..dc409010 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactsActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactsActivity.kt @@ -1,28 +1,71 @@ package com.tutpro.baresip import android.app.Activity -import android.content.* -import android.os.Build +import android.content.ContentValues +import android.content.Context +import android.content.DialogInterface +import android.content.Intent import android.os.Bundle import android.os.SystemClock import android.provider.ContactsContract -import androidx.appcompat.app.AppCompatActivity import android.view.MenuItem -import android.view.View +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updatePadding +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.Observer -import com.tutpro.baresip.databinding.ActivityContactsBinding +import coil.compose.AsyncImage +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.tutpro.baresip.CustomElements.TextAvatar +import com.tutpro.baresip.CustomElements.verticalScrollbar +import java.io.File +import java.io.IOException -class ContactsActivity : AppCompatActivity() { +class ContactsActivity : ComponentActivity() { - private lateinit var binding: ActivityContactsBinding - private lateinit var clAdapter: ContactListAdapter private lateinit var aor: String private var newAndroidName: String? = null private var lastClick: Long = 0 @@ -33,32 +76,26 @@ class ContactsActivity : AppCompatActivity() { } } + private val contactRequest = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + if (it.data != null && it.data!!.hasExtra("name")) + newAndroidName = it.data!!.getStringExtra("name") + } + Contact.contactsUpdate() + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityContactsBinding.inflate(layoutInflater) - setContentView(binding.root) + enableEdgeToEdge() - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - if (Build.VERSION.SDK_INT >= 35) - binding.ContactsView.updatePadding(top = systemBars.top + 56) - WindowInsetsCompat.CONSUMED - } - - if (!Utils.isDarkTheme(this)) - WindowInsetsControllerCompat(window, binding.root).isAppearanceLightStatusBars = true + val title = String.format(getString(R.string.contacts)) aor = intent.getStringExtra("aor")!! Utils.addActivity("contacts,$aor") - val listView = binding.contacts - clAdapter = ContactListAdapter(this, BaresipService.contacts, aor) - listView.adapter = clAdapter - listView.isLongClickable = true - val androidContactsObserver = Observer { if (newAndroidName != null) { val contentValues = ContentValues() @@ -73,64 +110,374 @@ class ContactsActivity : AppCompatActivity() { } newAndroidName = null } - clAdapter.notifyDataSetChanged() + Contact.contactsUpdate() } BaresipService.contactUpdate.observe(this, androidContactsObserver) - val contactRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - if (it.data != null && it.data!!.hasExtra("name")) - newAndroidName = it.data!!.getStringExtra("name") - } - clAdapter.notifyDataSetChanged() - } - - val plusButton = binding.plusButton - plusButton.setOnClickListener { - if (Contact.contacts().size >= Contact.CONTACTS_SIZE) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.contacts_exceeded), - Contact.CONTACTS_SIZE)) - } else { - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - val intent = Intent(this, ContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", true) - b.putString("uri", "") - intent.putExtras(b) - contactRequest.launch(intent) + setContent { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + ContactsScreen(LocalContext.current, title) { + goBack() + } } } } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - override fun onResume() { - super.onResume() - clAdapter.notifyDataSetChanged() + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun ContactsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { + Scaffold( + modifier = Modifier + .fillMaxHeight() + .imePadding() + .safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + ) + }, + floatingActionButton = { + SmallFloatingActionButton(onClick = { + if (SystemClock.elapsedRealtime() - lastClick > 1000) { + lastClick = SystemClock.elapsedRealtime() + val intent = Intent(ctx, BaresipContactActivity::class.java) + val b = Bundle() + b.putBoolean("new", true) + b.putString("uri", "") + intent.putExtras(b) + contactRequest.launch(intent) + }}, + containerColor = LocalCustomColors.current.accent, + contentColor = LocalCustomColors.current.background + ) { + Icon(imageVector = Icons.Filled.Add, + modifier = Modifier.size(36.dp), + contentDescription = stringResource(R.string.add) + ) + } + }, + content = { contentPadding -> + ContactsContent(ctx, contentPadding) + } + ) + } + + @Composable + fun ContactsContent(ctx: Context, contentPadding: PaddingValues) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 64.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(BaresipService.contacts, key = { contact -> contact.id() }) { contact -> + + val name = contact.name() + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + when(contact) { + + is Contact.BaresipContact -> { + val avatarImage = contact.avatarImage + if (avatarImage != null) + Image( + bitmap = avatarImage.asImageBitmap(), + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + ) + else + TextAvatar(name, contact.color) + } + + is Contact.AndroidContact -> { + val thumbNailUri = contact.thumbnailUri + if (thumbNailUri != null) + AsyncImage( + model = thumbNailUri, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + else + TextAvatar(name, contact.color) + } + } + + when(contact) { + + is Contact.BaresipContact -> { + Text(text = name, + fontSize = 20.sp, + fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal, + color = LocalCustomColors.current.itemText, + modifier = Modifier + .weight(1f) + .padding(start = 10.dp) + .combinedClickable( + onClick = { + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE, DialogInterface.BUTTON_NEGATIVE -> { + val i = Intent( + ctx, + MainActivity::class.java + ) + i.flags = + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (which == DialogInterface.BUTTON_NEGATIVE) + i.putExtra("action", "call") + else + i.putExtra("action", "message") + val ua = UserAgent.ofAor(aor) + if (ua == null) { + Log.w( + TAG, + "onClickListener did not find AoR $aor" + ) + } else { + BaresipService.activities.clear() + i.putExtra("uap", ua.uap) + i.putExtra("peer", contact.uri) + (ctx as Activity).startActivity(i) + } + } + + DialogInterface.BUTTON_NEUTRAL -> { + } + } + } + if (SystemClock.elapsedRealtime() - lastClick > 1000) { + lastClick = SystemClock.elapsedRealtime() + with( + MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme) + ) { + setTitle(R.string.confirmation) + setMessage( + String.format( + ctx.getString(R.string.contact_action_question), + name + ) + ) + setNeutralButton( + ctx.getText(R.string.cancel), + dialogClickListener + ) + setNegativeButton( + ctx.getText(R.string.call), + dialogClickListener + ) + setPositiveButton( + ctx.getText(R.string.send_message), + dialogClickListener + ) + show() + } + } + }, + onLongClick = { + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + val id = contact.id + val avatarFile = File( + BaresipService.filesPath, + "$id.png" + ) + if (avatarFile.exists()) { + try { + avatarFile.delete() + } catch (e: IOException) { + Log.e( + TAG, + "Could not delete file '$id.png" + ) + } + } + Contact.removeBaresipContact(contact) + Contact.contactsUpdate() + } + + DialogInterface.BUTTON_NEUTRAL -> { + } + } + } + with( + MaterialAlertDialogBuilder( + ctx, + R.style.AlertDialogTheme + ) + ) { + setTitle(R.string.confirmation) + setMessage( + String.format( + ctx.getString(R.string.contact_delete_question), + name + ) + ) + setNeutralButton( + ctx.getText(R.string.cancel), + dialogClickListener + ) + setPositiveButton( + ctx.getText(R.string.delete), + dialogClickListener + ) + show() + } + } + ) + ) + SmallFloatingActionButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { + if (SystemClock.elapsedRealtime() - lastClick > 1000) { + lastClick = SystemClock.elapsedRealtime() + val intent = Intent(ctx, BaresipContactActivity::class.java) + val b = Bundle() + b.putBoolean("new", false) + b.putString("name", name) + intent.putExtras(b) + contactRequest.launch(intent) + } + }, + containerColor = LocalCustomColors.current.background, + contentColor = LocalCustomColors.current.secondary + ) { + Icon( + imageVector = Icons.Filled.Edit, + modifier = Modifier.size(28.dp), + contentDescription = stringResource(R.string.add) + ) + } + } + + is Contact.AndroidContact -> { + Text(text = name, + fontSize = 20.sp, + fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal, + color = LocalCustomColors.current.itemText, + modifier = Modifier + .weight(1f) + .padding(start = 10.dp, top = 4.dp, bottom = 4.dp) + .combinedClickable( + onClick = { + if (SystemClock.elapsedRealtime() - lastClick > 1000) { + lastClick = SystemClock.elapsedRealtime() + val i = + Intent(ctx, AndroidContactActivity::class.java) + val b = Bundle() + b.putString("aor", aor) + b.putString("name", name) + i.putExtras(b) + ctx.startActivity(i, null) + } + }, + onLongClick = { + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + ctx.contentResolver.delete( + ContactsContract.RawContacts.CONTENT_URI, + ContactsContract.Contacts.DISPLAY_NAME + "='" + name + "'", + null + ) + } + + DialogInterface.BUTTON_NEUTRAL -> { + } + } + } + with( + MaterialAlertDialogBuilder( + ctx, + R.style.AlertDialogTheme + ) + ) { + setTitle(R.string.confirmation) + setMessage( + String.format( + ctx.getString(R.string.contact_delete_question), + name + ) + ) + setNeutralButton( + ctx.getText(R.string.cancel), + dialogClickListener + ) + setPositiveButton( + ctx.getText(R.string.delete), + dialogClickListener + ) + show() + } + } + ) + ) + } + } + } + } + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { android.R.id.home -> goBack() } - return true - } private fun goBack() { - BaresipService.activities.remove("contacts,$aor") - setResult(Activity.RESULT_OK, Intent()) + setResult(RESULT_OK, Intent()) finish() - } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/CustomColors.kt b/app/src/main/kotlin/com/tutpro/baresip/CustomColors.kt new file mode 100644 index 00000000..251d8b34 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/CustomColors.kt @@ -0,0 +1,177 @@ +package com.tutpro.baresip + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.compositionLocalOf + +@Immutable +data class CustomColors( + val primary: Color = Color.Unspecified, + val primaryDark: Color = Color.Unspecified, + val primaryLight: Color = Color.Unspecified, + val secondary: Color = Color.Unspecified, + val secondaryDark: Color = Color.Unspecified, + val secondaryLight: Color = Color.Unspecified, + val gray: Color = Color.Unspecified, + val grayDark: Color = Color.Unspecified, + val grayLight: Color = Color.Unspecified, + val accent: Color = Color.Unspecified, + val baresip: Color = Color.Unspecified, + val white: Color = Color.Unspecified, + val black: Color = Color.Unspecified, + val strong: Color = Color.Unspecified, + val green: Color = Color.Unspecified, + val red: Color = Color.Unspecified, + val trafficGreen: Color = Color.Unspecified, + val trafficYellow: Color = Color.Unspecified, + val trafficRed: Color = Color.Unspecified, + val light: Color = Color.Unspecified, + val dark: Color = Color.Unspecified, + val spinnerText: Color = Color.Unspecified, + val spinnerDropdown: Color = Color.Unspecified, + val spinnerDivider: Color = Color.Unspecified, + val itemText: Color = Color.Unspecified, + val alertText: Color = Color.Unspecified, + val codec: Color = Color.Unspecified, + val background: Color = Color.Unspecified, + val popupBackground: Color = Color.Unspecified, + val alert: Color = Color.Unspecified, + val actionbar: Color = Color.Unspecified, + ) + +val light_primary = Color(0xff0ca1fd) +val light_primary_dark = Color(0xff0073c9) +val light_primary_light = Color(0xff6ad2ff) +val light_secondary = Color(0xFF00B9A1) +val light_secondary_dark = Color(0xFF008873) +val light_secondary_light = Color(0xff5cecd2) +val light_gray = Color(0xff9e9e9e) +val light_gray_dark = Color(0xFF424242) +val light_gray_light = Color(0xffe0e0e0) +val light_accent = Color(0xFFF77445) +val light_baresip = Color(0xff0ca1fd) +val light_white = Color(0xffffffff) +val light_black = Color(0xFF000000) +val light_strong = light_black +val light_green = Color(0xff01df01) +val light_red = Color(0xffff0000) +val light_traffic_green = Color(0xFF4CAF50) +val light_traffic_yellow = Color(0xFFFFEB3B) +val light_traffic_red = Color(0xFFFF5722) +val light_light = light_white +val light_dark = Color(0xFF383838) +val light_spinner_text = light_dark +val light_spinner_dropdown = light_gray_light +val light_spinner_divider = light_gray +val light_item_text = light_black +val light_alert_text = light_black +val light_codec = light_black +val light_background = Color(0xfffcfcfc) +val light_popup_background = light_secondary_dark +val light_alert = Color(0xff0ca1fd) // == light_primary +val light_actionbar = light_background + +val dark_primary = Color(0xff0073c9) +val dark_primary_dark = Color(0xff00008b) +val dark_primary_light = Color(0xff0ca1fd) +val dark_secondary = Color(0xFF00B9A1) +val dark_secondary_dark = Color(0xFF008873) +val dark_secondary_light = Color(0xff5cecd2) +val dark_gray = Color(0xff9e9e9e) +val dark_gray_dark = Color(0xFF424242) +val dark_gray_light = Color(0xffe0e0e0) +val dark_accent = Color(0xFFF77445) +val dark_baresip = Color(0xff0ca1fd) +val dark_white = Color(0xffffffff) +val dark_black = Color(0xFF000000) +val dark_strong = dark_white +val dark_green = Color(0xff01df01) +val dark_red = Color(0xffdf0101) +val dark_traffic_green = Color(0xFF2E7D32) +val dark_traffic_yellow = Color(0xFFF9A825) +val dark_traffic_red = Color(0xFFC62828) +val dark_light = dark_gray_light +val dark_dark = Color(0xFF383838) +val dark_spinner_text = dark_dark +val dark_spinner_dropdown = dark_gray +val dark_spinner_divider = dark_gray_dark +val dark_item_text = dark_gray_light +val dark_alert_text = dark_gray_light +val dark_codec = dark_secondary_dark +val dark_background = dark_dark +val dark_popup_background = dark_secondary_dark +val dark_alert = dark_primary_light +val dark_actionbar = dark_gray_dark + +val LightCustomColors = CustomColors( + primary = light_primary, + primaryDark = light_primary_dark, + primaryLight = light_primary_light, + secondary = light_secondary, + secondaryDark = light_secondary_dark, + secondaryLight = light_secondary_light, + gray = light_gray, + grayDark = light_gray_dark, + grayLight = light_gray_light, + accent = light_accent, + baresip = light_baresip, + white = light_white, + black = light_black, + strong = light_strong, + green = light_green, + red = light_red, + trafficGreen = light_traffic_green, + trafficYellow = light_traffic_yellow, + trafficRed = light_traffic_red, + light = light_light, + dark = light_dark, + spinnerText = light_spinner_text, + spinnerDropdown = light_spinner_dropdown, + spinnerDivider = light_spinner_divider, + itemText = light_item_text, + alertText = light_alert_text, + codec = light_codec, + background = light_background, + popupBackground = light_popup_background, + alert = light_alert, + actionbar = light_actionbar, + ) + +val DarkCustomColors = CustomColors( + primary = dark_primary, + primaryDark = dark_primary_dark, + primaryLight = dark_primary_light, + secondary = dark_secondary, + secondaryDark = dark_secondary_dark, + secondaryLight = dark_secondary_light, + gray = dark_gray, + grayDark = dark_gray_dark, + grayLight = dark_gray_light, + accent = dark_accent, + baresip = dark_baresip, + white = dark_white, + black = dark_black, + strong = dark_strong, + green = dark_green, + red = dark_red, + trafficGreen = dark_traffic_green, + trafficYellow = dark_traffic_yellow, + trafficRed = dark_traffic_red, + light = dark_light, + dark = dark_dark, + spinnerText = dark_spinner_text, + spinnerDropdown = dark_spinner_dropdown, + spinnerDivider = dark_spinner_divider, + itemText = dark_item_text, + alertText = dark_alert_text, + codec = dark_codec, + background = dark_background, + popupBackground = dark_popup_background, + alert = dark_alert, + actionbar = dark_actionbar, + ) + +val LocalCustomColors = compositionLocalOf { CustomColors() } diff --git a/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt b/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt new file mode 100644 index 00000000..e43cf2f4 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt @@ -0,0 +1,317 @@ +package com.tutpro.baresip + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.content.ContextCompat.getString +import com.tutpro.baresip.CustomElements.Text +import com.tutpro.baresip.CustomElements.ThreeButtonAlertDialog + +object CustomElements { + + @Composable + fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = LocalCustomColors.current.itemText, + fontSize: TextUnit = 16.sp, + fontWeight: FontWeight = FontWeight.Normal, + textAlign: TextAlign? = null, + maxLines: Int = Int.MAX_VALUE, + ) { + androidx.compose.material3.Text( + text = text, + modifier = modifier, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + textAlign = textAlign, + maxLines = maxLines + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun PullToRefreshBox( + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + enabled: Boolean = true, + content: @Composable BoxScope.() -> Unit, + ) { + val refreshState = rememberPullToRefreshState() + + Box( + modifier.pullToRefresh( + state = refreshState, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + enabled = enabled, + ), + contentAlignment = contentAlignment, + ) { + content() + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = isRefreshing, + state = refreshState, + ) + } + } + + @Composable + fun DropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + items: List, + onItemClick: (String) -> Unit + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + containerColor = LocalCustomColors.current.grayLight, + modifier = Modifier.background( + LocalCustomColors.current.popupBackground.copy(alpha = 0.95f) + ) + ) { + val itemsIterator = items.iterator() + while (itemsIterator.hasNext()) { + val item = itemsIterator.next() + DropdownMenuItem( + text = { Text(item, color = LocalCustomColors.current.light) }, + onClick = { onItemClick(item) } + ) + if (itemsIterator.hasNext()) + HorizontalDivider(color = LocalCustomColors.current.spinnerDivider) + } + } + } + + @Composable + fun Checkbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Checkbox( + checked = checked, + onCheckedChange = { + onCheckedChange(!checked) + }, + colors = CheckboxDefaults.colors( + checkedColor = LocalCustomColors.current.accent, + uncheckedColor = LocalCustomColors.current.strong, + checkmarkColor = LocalCustomColors.current.strong, + ) + ) + } + + @Composable + fun TextAvatar(name: String, color: Int) { + Box( + modifier = Modifier.size(36.dp), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle(SolidColor(Color(color))) + } + val text = if (name == "") "" else name[0].toString() + Text(text, color = Color.White, fontSize = 20.sp) + } + } + + @Composable + fun ImageAvatar(bitmap: Bitmap) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + ) + } + + @Composable + fun DrawDrawable( + drawableId: Int, + modifier: Modifier = Modifier, + contentDescription: String? = null, + tint: Color? = null + ) { + val image = painterResource(id = drawableId) + Image( + painter = image, + contentDescription = contentDescription, + modifier = modifier, + colorFilter = if (tint != null) ColorFilter.tint(tint) else null + ) + } + + @Composable + fun Modifier.verticalScrollbar( + state: LazyListState, + width: Dp = 8.dp, + alwaysShow: Boolean = true, + color: Color = LocalCustomColors.current.gray + ): Modifier { + val targetAlpha = if (state.isScrollInProgress || alwaysShow) 1f else 0f + val duration = if (state.isScrollInProgress) 150 else 500 + + val alpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = tween(durationMillis = duration) + ) + + return drawWithContent { + drawContent() + + val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index + val needDrawScrollbar = state.isScrollInProgress || alpha > 0.0f + + // Draw scrollbar if scrolling or if the animation is still running and lazy column has content + if (needDrawScrollbar && firstVisibleElementIndex != null) { + val elementHeight = this.size.height / state.layoutInfo.totalItemsCount + val scrollbarOffsetY = firstVisibleElementIndex * elementHeight + val scrollbarHeight = state.layoutInfo.visibleItemsInfo.size * elementHeight + + drawRect( + color = color, + topLeft = Offset(this.size.width - width.toPx(), scrollbarOffsetY), + size = Size(width.toPx(), scrollbarHeight), + alpha = alpha + ) + } + } + } + + @Composable + fun ThreeButtonAlertDialog( + onDismissRequest: () -> Unit, + onPositiveClick: () -> Unit, + onNegativeClick: () -> Unit, + onNeutralClick: () -> Unit, + dialogTitle: String, + dialogText: String, + ) { + Dialog(onDismissRequest = { onDismissRequest() }) { + Card( + modifier = Modifier + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = dialogTitle + ) + + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = dialogText + ) + + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton( + onClick = { onNeutralClick() }, + modifier = Modifier.weight(1f) + ) { + Text("Neutral") + } + TextButton( + onClick = { onNegativeClick() }, + modifier = Modifier.weight(1f) + ) { + Text("Negative") + } + Button( + onClick = { onPositiveClick() }, + modifier = Modifier.weight(1f) + ) { + Text("Positive") + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ThreeButtonAlertDialogPreview() { + var openDialog by remember { mutableStateOf(true) } + if (openDialog) { + ThreeButtonAlertDialog( + onDismissRequest = { openDialog = false }, + onPositiveClick = { openDialog = false }, + onNegativeClick = { openDialog = false }, + onNeutralClick = { openDialog = false }, + dialogTitle = "My Dialog Title", + dialogText = "This is the message text of my dialog.", + ) + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/DraggableLazyList.kt b/app/src/main/kotlin/com/tutpro/baresip/DraggableLazyList.kt new file mode 100644 index 00000000..93ee4a59 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/DraggableLazyList.kt @@ -0,0 +1,358 @@ +/* + * Copyright 2024 Allan Veloso Lopes + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Temporarily implementation until this issue is made available on the official library +// https://issuetracker.google.com/issues/356619939 +// Based on https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt +// https://issuetracker.google.com/issues/181282427 +package com.tutpro.baresip + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +/** + * Creates a [DraggableListState] that is remembered across compositions. + * + * @param lazyListState the [LazyListState] whose items are being dragged. + * @param onMove the callback that is triggered when an item is moved. + */ +@Composable +fun rememberDraggableListState( + lazyListState: LazyListState = rememberLazyListState(), + onMove: (Int, Int) -> Unit +): DraggableListState { + val scope = rememberCoroutineScope() + val state = remember(lazyListState) { + DraggableListState( + listState = lazyListState, + onMove = onMove, + scope = scope + ) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +/** + * A state object that can be hoisted to control and observe dragging within a list. + * + * In most cases, this will be created via [rememberLazyListState]. + * + * @param listState the state of the list where the item is being dragged. + * @param scope a coroutine scope for performing animations. + * @param onMove the callback that is triggered when an item is moved. The callback is + * invoked whenever the dragged item middle point passes another item top or bottom offset, + * it does not depend on the drag to be completed. + */ +class DraggableListState internal constructor( + val listState: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + + private var draggingItemInitialOffset by mutableIntStateOf(0) + + val draggingItemOffset: Float + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + + var previousIndexOfDraggedItem by mutableStateOf(null) + private set + + var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(index: Int) { + listState.layoutInfo.visibleItemsInfo + .find { it.index == index } + ?.also { + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragStart(key: Any) { + listState.layoutInfo.visibleItemsInfo + .find { it.key == key } + ?.also { + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = listState.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + + if (targetItem != null) { + if ( + draggingItem.index == listState.firstVisibleItemIndex || + targetItem.index == listState.firstVisibleItemIndex + ) { + listState.requestScrollToItem( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset + ) + } + onMove(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index + } else { + val overscroll = when { + draggingItemDraggedDelta > 0 -> + (endOffset - listState.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemDraggedDelta < 0 -> + (startOffset - listState.layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +/** + * Defines the area that will act as the handle for dragging operations. + * + * @param state The draggable state of the list where the handler is being used. + * @param index The index of the item for which the handler is being used. + * @param onlyAfterLongPress Indicates whether dragging should begin only after a long press. + */ +fun Modifier.dragHandle( + state: DraggableListState, + index: Int, + onlyAfterLongPress: Boolean = false +): Modifier { + return pointerInput(state) { + if (onlyAfterLongPress) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + state.onDrag(offset = offset) + }, + onDragStart = { state.onDragStart(index = index) }, + onDragEnd = { state.onDragInterrupted() }, + onDragCancel = { state.onDragInterrupted() } + ) + } else { + detectDragGestures( + onDrag = { change, offset -> + change.consume() + state.onDrag(offset = offset) + }, + onDragStart = { state.onDragStart(index = index) }, + onDragEnd = { state.onDragInterrupted() }, + onDragCancel = { state.onDragInterrupted() } + ) + } + } +} + +/** + * Defines the area that will act as the handle for dragging operations. + * + * If you use this version of the [dragHandle], you must implement the key parameter on the + * [draggableItems] or [draggableItemsIndexed]. + * + * @param state The draggable state of the list where the handler is being used. + * @param key The unique identifier for the item within the list for which the handler is being used. + * @param onlyAfterLongPress Indicates whether dragging should begin only after a long press. + */ +fun Modifier.dragHandle( + state: DraggableListState, + key: Any, + onlyAfterLongPress: Boolean = false +): Modifier { + return pointerInput(state) { + if (onlyAfterLongPress) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + state.onDrag(offset = offset) + }, + onDragStart = { state.onDragStart(key = key) }, + onDragEnd = { state.onDragInterrupted() }, + onDragCancel = { state.onDragInterrupted() } + ) + } else { + detectDragGestures( + onDrag = { change, offset -> + change.consume() + state.onDrag(offset = offset) + }, + onDragStart = { state.onDragStart(key = key) }, + onDragEnd = { state.onDragInterrupted() }, + onDragCancel = { state.onDragInterrupted() } + ) + } + } +} + +/** + * Adds a list of draggable items. + * + * To the items to be draggable, it's also necessary to add the modifier [dragHandle] to + * the preferable UI element of the item which will be the target of the drag operation. + * + * @param items the data list + * @param key a factory of stable and unique keys representing the item. Using the same key + * for multiple items in the list is not allowed. Type of the key should be saveable + * via Bundle on Android. If null is passed the position in the list will represent the key. + * When you specify the key the scroll position will be maintained based on the key, which + * means if you add/remove items before the current visible item the item with the given key + * will be kept as the first visible one. This can be overridden by calling 'requestScrollToItem' + * on the 'LazyListState'. + * @param contentType a factory of the content types for the item. The item compositions of + * the same type could be reused more efficiently. Note that null is a valid type and items of such + * type will be considered compatible. + * @param itemContent the content displayed by a single item + */ +inline fun LazyListScope.draggableItemsIndexed( + state: DraggableListState, + items: List, + noinline key: ((index: Int, item: T) -> Any)? = null, + crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null }, + crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T, isDragging: Boolean) -> Unit +) = itemsIndexed( + items = items, + key = key, + contentType = contentType +) { index, item -> + + val isDragging = index == state.draggingItemIndex + val draggingModifier = if (isDragging) { + Modifier + .zIndex(1f) + .graphicsLayer { translationY = state.draggingItemOffset } + } else if (index == state.previousIndexOfDraggedItem) { + Modifier + .zIndex(1f) + .graphicsLayer { translationY = state.previousItemOffset.value } + } else { + Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + } + Box(modifier = draggingModifier) { + itemContent(index, item, isDragging) + } +} + +/** + * Adds a list of draggable items. + * + * To the items to be draggable, it's also necessary to add the modifier [dragHandle] to + * the preferable UI element of the item which will be the target of the drag operation. + * + * @param items the data list + * @param key a factory of stable and unique keys representing the item. Using the same key + * for multiple items in the list is not allowed. Type of the key should be saveable + * via Bundle on Android. If null is passed the position in the list will represent the key. + * When you specify the key the scroll position will be maintained based on the key, which + * means if you add/remove items before the current visible item the item with the given key + * will be kept as the first visible one. This can be overridden by calling 'requestScrollToItem' + * on the 'LazyListState'. + * @param contentType a factory of the content types for the item. The item compositions of + * the same type could be reused more efficiently. Note that null is a valid type and items of such + * type will be considered compatible. + * @param itemContent the content displayed by a single item + */ +inline fun LazyListScope.draggableItems( + state: DraggableListState, + items: List, + noinline key: ((item: T) -> Any)? = null, + crossinline contentType: (item: T) -> Any? = { _ -> null }, + crossinline itemContent: @Composable LazyItemScope.(item: T, isDragging: Boolean) -> Unit +) = draggableItemsIndexed( + state = state, + items = items, + key = key?.let { block -> { _, item -> block(item) } }, + contentType = { _, item -> contentType(item) }, + itemContent = { _, item, isDragging -> itemContent(item, isDragging) } +) diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt index a4bad715..d97a362e 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt @@ -1,91 +1,162 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.tutpro.baresip -import android.Manifest.permission.* +import android.Manifest.permission.BLUETOOTH_CONNECT +import android.Manifest.permission.POST_NOTIFICATIONS +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.RECORD_AUDIO +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.annotation.SuppressLint -import android.app.Activity -import android.app.AlertDialog import android.app.KeyguardManager import android.app.NotificationManager -import android.content.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.DialogInterface +import android.content.Intent import android.content.Intent.ACTION_CALL import android.content.Intent.ACTION_DIAL import android.content.Intent.ACTION_VIEW +import android.content.IntentFilter import android.content.pm.PackageManager +import android.content.res.Configuration import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.media.AudioManager import android.net.Uri -import android.os.* +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.os.SystemClock import android.provider.DocumentsContract import android.provider.MediaStore -import android.text.InputType -import android.text.TextWatcher -import android.view.* +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.WindowManager import android.view.inputmethod.InputMethodManager -import android.widget.* +import android.widget.Chronometer +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast +import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.core.app.ActivityCompat +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets import androidx.core.net.toUri -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.Observer -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.tutpro.baresip.Utils.showSnackBar -import com.tutpro.baresip.databinding.ActivityMainBinding +import com.tutpro.baresip.BaresipService.Companion.contactNames +import com.tutpro.baresip.BaresipService.Companion.uas +import com.tutpro.baresip.BaresipService.Companion.uasStatus +import com.tutpro.baresip.CustomElements.Checkbox +import com.tutpro.baresip.CustomElements.DropdownMenu +import com.tutpro.baresip.CustomElements.PullToRefreshBox +import com.tutpro.baresip.CustomElements.Text +import com.tutpro.baresip.CustomElements.verticalScrollbar +import kotlinx.coroutines.delay import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.system.exitProcess -class MainActivity : AppCompatActivity() { +class MainActivity : ComponentActivity() { - private lateinit var binding: ActivityMainBinding - private lateinit var layout: RelativeLayout - private lateinit var callTitle: TextView - private lateinit var callTimer: Chronometer - private lateinit var callUri: AutoCompleteTextView - private lateinit var securityButton: ImageButton - private lateinit var diverter: LinearLayout - private lateinit var diverterUri: TextView - private lateinit var callButton: ImageButton - private lateinit var hangupButton: ImageButton - private lateinit var answerButton: ImageButton - private lateinit var rejectButton: ImageButton - private lateinit var callControl: RelativeLayout - private lateinit var holdButton: ImageButton - private lateinit var transferButton: ImageButton - private lateinit var voicemailButton: ImageButton - private lateinit var voicemailButtonSpace: Space - private lateinit var contactsButton: ImageButton - private lateinit var messagesButton: ImageButton - private lateinit var callsButton: ImageButton - private lateinit var dialpadButton: ImageButton - private lateinit var dtmf: EditText - private var dtmfWatcher: TextWatcher? = null - private lateinit var infoButton: ImageButton - private lateinit var onHoldNotice: TextView - private lateinit var uaAdapter: UaSpinnerAdapter - private lateinit var aorSpinner: Spinner private lateinit var imm: InputMethodManager private lateinit var nm: NotificationManager private lateinit var am: AudioManager private lateinit var kgm: KeyguardManager private lateinit var screenEventReceiver: BroadcastReceiver private lateinit var serviceEventObserver: Observer> - private var recIcon: MenuItem? = null - private var micIcon: MenuItem? = null - private var speakerIcon: MenuItem? = null - private lateinit var swipeRefresh: SwipeRefreshLayout private lateinit var requestPermissionLauncher: ActivityResultLauncher private lateinit var requestPermissionsLauncher: ActivityResultLauncher> private lateinit var accountsRequest: ActivityResultLauncher @@ -96,25 +167,56 @@ class MainActivity : AppCompatActivity() { private lateinit var logcatRequest: ActivityResultLauncher private lateinit var contactsRequest: ActivityResultLauncher private lateinit var callsRequest: ActivityResultLauncher + private lateinit var accountRequest: ActivityResultLauncher private lateinit var comDevChangedListener: AudioManager.OnCommunicationDeviceChangedListener private lateinit var permissions: Array + private lateinit var baresipService: Intent + private var callHandler: Handler = Handler(Looper.getMainLooper()) private var callRunnable: Runnable? = null private var downloadsInputUri: Uri? = null private var downloadsOutputUri: Uri? = null private var audioModeChangedListener: AudioManager.OnModeChangedListener? = null - - private lateinit var baresipService: Intent + private var keyboardController: SoftwareKeyboardController? = null private var restart = false private var atStartup = false + private var initialized = false private var resumeUri = "" private var resumeUap = 0L private var resumeCall: Call? = null private var resumeAction = "" + private val viewModel: ViewModel by viewModels() + + private var callUri = mutableStateOf("") + private var callUriEnabled = mutableStateOf(true) + private var callUriLabel = mutableStateOf("") + private var securityIcon = mutableIntStateOf(-1) + private var callTimer: Chronometer? = null + private var showCallTimer = mutableStateOf(false) + private var showCallButton = mutableStateOf(true) + private var showAnswerRejectButtons = mutableStateOf(false) + private var showHangupButton = mutableStateOf(false) + private var showOnHoldNotice = mutableStateOf(false) + private var holdIcon = mutableIntStateOf(R.drawable.call_hold) + private var transferButtonEnabled = mutableStateOf(false) + private var transferIcon = mutableIntStateOf(R.drawable.call_transfer) + private var dtmfText = mutableStateOf("") + private var dtmfEnabled = mutableStateOf(false) + private var focusDtmf = mutableStateOf(false) + private var showVmIcon by mutableStateOf(false) + private var vmIcon = mutableIntStateOf(R.drawable.voicemail) + private var messagesIcon = mutableIntStateOf(R.drawable.messages) + private var callsIcon = mutableIntStateOf(R.drawable.calls) + private var micIcon by mutableIntStateOf(R.drawable.mic_on) + private var speakerIcon by mutableIntStateOf(R.drawable.speaker_off) + private var dialpad by mutableStateOf(false) + private var dialpadButtonEnabled by mutableStateOf(true) + private var pullToRefreshEnabled by mutableStateOf(true) + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { moveTaskToBack(true) @@ -124,68 +226,28 @@ class MainActivity : AppCompatActivity() { @SuppressLint("ClickableViewAccessibility") override fun onCreate(savedInstanceState: Bundle?) { - // theme.applyStyle(R.style.OptOutEdgeToEdgeEnforcement,false) - super.onCreate(savedInstanceState) enableEdgeToEdge() - AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - binding = ActivityMainBinding.inflate(layoutInflater) - val extraAction = intent.getStringExtra("action") Log.d(TAG, "Main onCreate ${intent.action}/${intent.data}/$extraAction") window.addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES) - setContentView(binding.root) - - ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v: View, insets: WindowInsetsCompat -> - val systemBars: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - WindowInsetsCompat.CONSUMED - } + BaresipService.darkTheme.value = Utils.isThemeDark(this) // Must be done after view has been created this.setShowWhenLocked(true) this.setTurnScreenOn( true) Utils.requestDismissKeyguard(this) - setSupportActionBar(binding.toolbar) - - layout = binding.mainActivityLayout - aorSpinner = binding.aorSpinner - callTitle = binding.callTitle - callTimer = binding.callTimer - callUri = binding.callUri - securityButton = binding.securityButton - diverter = binding.diverter - diverterUri = binding.diverterUri - callButton = binding.callButton - hangupButton = binding.hangupButton - answerButton = binding.answerButton - rejectButton = binding.rejectButton - callControl = binding.callControl - holdButton = binding.holdButton - transferButton = binding.transferButton - dtmf = binding.dtmf - infoButton = binding.info - onHoldNotice = binding.onHoldNotice - voicemailButton = binding.voicemailButton - voicemailButtonSpace = binding.voicemailButtonSpace - contactsButton = binding.contactsButton - messagesButton = binding.messagesButton - callsButton = binding.callsButton - dialpadButton = binding.dialpadButton - swipeRefresh = binding.swipeRefresh - - imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager am = getSystemService(AUDIO_SERVICE) as AudioManager - kgm = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + kgm = getSystemService(KEYGUARD_SERVICE) as KeyguardManager serviceEventObserver = Observer { val event = it.getContentIfNotHandled() @@ -215,42 +277,29 @@ class MainActivity : AppCompatActivity() { comDevChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device -> if (device != null) { Log.d(TAG, "Com device changed to type ${device.type} in mode ${am.mode}") - setSpeakerIcon() + speakerIcon = if (Utils.isSpeakerPhoneOn(am)) + R.drawable.speaker_on + else + R.drawable.speaker_off } } am.addOnCommunicationDeviceChangedListener(mainExecutor, comDevChangedListener) } - uaAdapter = UaSpinnerAdapter(applicationContext, BaresipService.uas) - aorSpinner.adapter = uaAdapter - aorSpinner.setSelection(-1) - aorSpinner.tag = "" - aorSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - // Have to allow NULL view, since sometimes when onItemSelected is called, view is NULL. - // Haven't found any explanation why this can happen. - override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { - Log.d(TAG, "aorSpinner selecting $position") - if (position < BaresipService.uas.size) { - val ua = BaresipService.uas[position] - val acc = ua.account - aorSpinner.tag = acc.aor - showCall(ua) - updateIcons(acc) - } - } - override fun onNothingSelected(parent: AdapterView<*>) { - Log.d(TAG, "Nothing selected") - } - } - - val registrationObserver = Observer { uaAdapter.notifyDataSetChanged() } - BaresipService.registrationUpdate.observe(this, registrationObserver) + initialized = true accountsRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - uaAdapter.notifyDataSetChanged() - spinToAor(activityAor) - if (aorSpinner.tag != "") - updateIcons(Account.ofAor(aorSpinner.tag.toString())!!) + if (Account.ofAor(activityAor) != null) + spinToAor(activityAor) + else { + if (Account.ofAor(viewModel.selectedAor.value) == null) { + if (uas.value.isNotEmpty()) + viewModel.updateSelectedAor(uas.value.first().account.aor) + else + viewModel.updateSelectedAor("") + } + updateIcons(Account.ofAor(viewModel.selectedAor.value)) + } if (BaresipService.isServiceRunning) { baresipService.action = "Update Notification" startService(baresipService) @@ -259,291 +308,14 @@ class MainActivity : AppCompatActivity() { accountRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { spinToAor(activityAor) - val ua = UserAgent.ofAor(activityAor)!! + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! updateIcons(ua.account) - if (it.resultCode == Activity.RESULT_OK) + if (it.resultCode == RESULT_OK) if (BaresipService.aorPasswords[activityAor] == NO_AUTH_PASS) askPassword(getString(R.string.authentication_password), ua) } - aorSpinner.setOnTouchListener { view, event -> - if (event.action == MotionEvent.ACTION_DOWN) { - if (aorSpinner.selectedItemPosition == -1) { - val i = Intent(this@MainActivity, AccountsActivity::class.java) - val b = Bundle() - b.putString("aor", "") - i.putExtras(b) - accountsRequest.launch(i) - true - } else { - if ((event.x - view.left) < 100) { - val i = Intent(this@MainActivity, AccountActivity::class.java) - val b = Bundle() - b.putString("aor", aorSpinner.tag.toString()) - i.putExtras(b) - accountRequest!!.launch(i) - true - } else { - BaresipService.uas[aorSpinner.selectedItemPosition].account.resumeUri = - callUri.text.toString() - false - } - } - } else { - // view.performClick() - false - } - } - - aorSpinner.setOnLongClickListener { - if (aorSpinner.selectedItemPosition != -1) { - val ua = UserAgent.ofAor(aorSpinner.tag.toString()) - if (ua != null) { - val acc = ua.account - if (Api.account_regint(acc.accp) > 0) { - Api.account_set_regint(acc.accp, 0) - Api.ua_unregister(ua.uap) - } else { - Api.account_set_regint(acc.accp, acc.configuredRegInt) - Api.ua_register(ua.uap) - } - acc.regint = Api.account_regint(acc.accp) - AccountsActivity.saveAccounts() - } - } - true - } - - callUri.setAdapter(ArrayAdapter(this, android.R.layout.select_dialog_item, - Contact.contactNames())) - callUri.threshold = 2 - callUri.setOnFocusChangeListener { view, b -> - if (b) { - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) - } - } - callUri.setOnClickListener { view -> - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) - } - - securityButton.setOnClickListener { - when (securityButton.tag) { - R.drawable.unlocked -> { - Utils.alertView(this, getString(R.string.alert), - getString(R.string.call_not_secure)) - } - R.drawable.locked_yellow -> { - Utils.alertView(this, getString(R.string.alert), - getString(R.string.peer_not_verified)) - } - R.drawable.locked_green -> { - with(MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme)) { - setTitle(R.string.info) - setMessage(getString(R.string.call_is_secure)) - setPositiveButton(getString(R.string.unverify)) { dialog, _ -> - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val call = ua.currentCall() - if (call != null) { - if (Api.cmd_exec("zrtp_unverify " + call.zid) != 0) { - Log.e(TAG, "Command 'zrtp_unverify ${call.zid}' failed") - } else { - securityButton.setImageResource(R.drawable.locked_yellow) - securityButton.tag = R.drawable.locked_yellow - } - } - dialog.dismiss() - } - setNeutralButton(getString(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - } - show() - } - } - } - } - - callButton.setOnClickListener { - if (aorSpinner.selectedItemPosition >= 0) { - if (Utils.checkPermissions(this, arrayOf(RECORD_AUDIO))) - makeCall() - else - Toast.makeText(applicationContext, R.string.no_calls, Toast.LENGTH_SHORT).show() - } - } - - hangupButton.setOnClickListener { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - if (Build.VERSION.SDK_INT < 31) { - if (callRunnable != null) { - callHandler.removeCallbacks(callRunnable!!) - callRunnable = null - BaresipService.abandonAudioFocus(applicationContext) - showCall(ua) - return@setOnClickListener - } - } else { - if (audioModeChangedListener != null) { - am.removeOnModeChangedListener(audioModeChangedListener!!) - audioModeChangedListener = null - BaresipService.abandonAudioFocus(applicationContext) - showCall(ua) - return@setOnClickListener - } - } - val aor = ua.account.aor - val uaCalls = ua.calls() - if (uaCalls.size > 0) { - val call = uaCalls[uaCalls.size - 1] - val callp = call.callp - Log.d(TAG, "AoR $aor hanging up call $callp with ${callUri.text}") - hangupButton.isEnabled = false - Api.ua_hangup(ua.uap, callp, 0, "") - } - } - - answerButton.setOnClickListener { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val call = ua.currentCall() ?: return@setOnClickListener - Log.d(TAG, "AoR ${ua.account.aor} answering call from ${callUri.text}") - answerButton.isEnabled = false - rejectButton.isEnabled = false - val intent = Intent(this@MainActivity, BaresipService::class.java) - intent.action = "Call Answer" - intent.putExtra("uap", ua.uap) - intent.putExtra("callp", call.callp) - intent.putExtra("video", Api.VIDMODE_OFF) - startService(intent) - } - - rejectButton.setOnClickListener { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val aor = ua.account.aor - val call = ua.currentCall()!! - val callp = call.callp - Log.d(TAG, "AoR $aor rejecting call $callp from ${callUri.text}") - answerButton.isEnabled = false - rejectButton.isEnabled = false - call.rejected = true - Api.ua_hangup(ua.uap, callp, 486, "Busy Here") - } - - holdButton.setOnClickListener { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val aor = ua.account.aor - val call = ua.currentCall()!! - if (call.onhold) { - Log.d(TAG, "AoR $aor resuming call ${call.callp} with ${callUri.text}") - call.resume() - call.onhold = false - holdButton.setImageResource(R.drawable.call_hold) - } else { - Log.d(TAG, "AoR $aor holding call ${call.callp} with ${callUri.text}") - call.hold() - call.onhold = true - holdButton.setImageResource(R.drawable.resume) - } - } - - transferButton.setOnClickListener { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val call = ua.currentCall() - if (call != null ) { - if (call.onHoldCall != null) { - if (!call.executeTransfer()) - Utils.alertView(this@MainActivity, getString(R.string.notice), - String.format(getString(R.string.transfer_failed))) - } else { - makeTransfer(ua) - } - } - } - - infoButton.setOnClickListener { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val call = ua.currentCall() - if (call != null) { - val stats = call.stats("audio") - if (stats != "") { - val parts = stats.split(",") as java.util.ArrayList - if (parts[2] == "0/0") { - parts[2] = "?/?" - parts[3] = "?/?" - parts[4] = "?/?" - } - val codecs = call.audioCodecs() - val duration = call.duration() - val txCodec = codecs.split(',')[0].split("/") - val rxCodec = codecs.split(',')[1].split("/") - Utils.alertView(this, getString(R.string.call_info), - "${String.format(getString(R.string.duration), duration)}\n" + - "${getString(R.string.codecs)}: ${txCodec[0]} ch ${txCodec[2]}/" + - "${rxCodec[0]} ch ${rxCodec[2]}\n" + - "${String.format(getString(R.string.rate), parts[0])}\n" + - "${String.format(getString(R.string.average_rate), parts[1])}\n" + - "${getString(R.string.packets)}: ${parts[2]}\n" + - "${getString(R.string.lost)}: ${parts[3]}\n" + - String.format(getString(R.string.jitter), parts[4])) - } else { - Utils.alertView(this, getString(R.string.call_info), - getString(R.string.call_info_not_available)) - } - } - } - - dtmf.setOnFocusChangeListener { view, hasFocus -> - if (hasFocus) { - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) - } - } - dtmf.setOnClickListener { view -> - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) - } - - voicemailButton.setOnClickListener { - if (aorSpinner.selectedItemPosition >= 0) { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val acc = ua.account - if (acc.vmUri != "") { - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - val i = Intent(this, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "call") - i.putExtra("uap", ua.uap) - i.putExtra("peer", acc.vmUri) - startActivity(i) - } - DialogInterface.BUTTON_NEGATIVE -> { - } - } - } - with(MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme)) { - setTitle(R.string.voicemail_messages) - setMessage(acc.vmMessages(this@MainActivity)) - setPositiveButton(getString(R.string.listen), dialogClickListener) - setNeutralButton(getString(R.string.cancel), dialogClickListener) - show() - } - } - } - } - contactsRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - callUri.setAdapter(ArrayAdapter(this, android.R.layout.select_dialog_item, - Contact.contactNames())) - } - - contactsButton.setOnClickListener { - val i = Intent(this@MainActivity, ContactsActivity::class.java) - val b = Bundle() - if (aorSpinner.selectedItemPosition >= 0) - b.putString("aor", aorSpinner.tag.toString()) - else - b.putString("aor", "") - i.putExtras(b) - contactsRequest.launch(i) } chatRequests = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -551,44 +323,9 @@ class MainActivity : AppCompatActivity() { updateIcons(Account.ofAor(activityAor)!!) } - messagesButton.setOnClickListener { - if (aorSpinner.selectedItemPosition >= 0) { - val i = Intent(this@MainActivity, ChatsActivity::class.java) - val b = Bundle() - b.putString("aor", aorSpinner.tag.toString()) - b.putString("peer", resumeUri) - i.putExtras(b) - chatRequests.launch(i) - } - } - callsRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { spinToAor(activityAor) - callsButton.setImageResource(R.drawable.calls) - } - - callsButton.setOnClickListener { - if (aorSpinner.selectedItemPosition >= 0) { - val i = Intent(this@MainActivity, CallsActivity::class.java) - val b = Bundle() - b.putString("aor", aorSpinner.tag.toString()) - i.putExtras(b) - callsRequest.launch(i) - } - } - - dialpadButton.tag = "off" - dialpadButton.setOnClickListener { - if (dialpadButton.tag == "off") { - callUri.inputType = InputType.TYPE_CLASS_PHONE - dialpadButton.setImageResource(R.drawable.dialpad_on) - dialpadButton.tag = "on" - } else { - callUri.inputType = InputType.TYPE_CLASS_TEXT + - InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS - dialpadButton.setImageResource(R.drawable.dialpad_off) - dialpadButton.tag = "off" - } + updateIcons(Account.ofAor(viewModel.selectedAor.value)) } configRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -609,12 +346,11 @@ class MainActivity : AppCompatActivity() { val displayTheme = Preferences(applicationContext).displayTheme if (displayTheme != AppCompatDelegate.getDefaultNightMode()) { AppCompatDelegate.setDefaultNightMode(displayTheme) - delegate.applyDayNight() } } backupRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) + if (it.resultCode == RESULT_OK) it.data?.data?.also { uri -> downloadsOutputUri = uri askPassword(getString(R.string.encrypt_password)) @@ -622,7 +358,7 @@ class MainActivity : AppCompatActivity() { } restoreRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) + if (it.resultCode == RESULT_OK) it.data?.data?.also { uri -> downloadsInputUri = uri askPassword(getString(R.string.decrypt_password)) @@ -630,7 +366,7 @@ class MainActivity : AppCompatActivity() { } logcatRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) + if (it.resultCode == RESULT_OK) it.data?.data?.also { uri -> try { val out = contentResolver.openOutputStream(uri) @@ -647,49 +383,21 @@ class MainActivity : AppCompatActivity() { } } - swipeRefresh.setOnTouchListener(object : OnSwipeTouchListener(this@MainActivity) { + micIcon = if (BaresipService.isMicMuted) + R.drawable.mic_off + else + R.drawable.mic_on - override fun onSwipeLeft() { - super.onSwipeLeft() - if (BaresipService.uas.size > 0) { - val curPos = aorSpinner.selectedItemPosition - val newPos = if (curPos == -1) - 0 - else - (curPos + 1) % BaresipService.uas.size - if (curPos != newPos) { - aorSpinner.setSelection(newPos) - showCall(BaresipService.uas[newPos]) - } + setContent { + AppTheme { + keyboardController = LocalSoftwareKeyboardController.current + Surface( + modifier = Modifier.fillMaxSize(), + color = LocalCustomColors.current.background + ) { + MainContent(this) } } - - override fun onSwipeRight() { - super.onSwipeRight() - if (BaresipService.uas.size > 0) { - val curPos = aorSpinner.selectedItemPosition - val newPos = when (curPos) { - -1 -> 0 - 0 -> BaresipService.uas.size - 1 - else -> curPos - 1 - } - if (curPos != newPos) { - aorSpinner.setSelection(newPos) - showCall(BaresipService.uas[newPos]) - } - } - } - }) - - swipeRefresh.setOnRefreshListener { - if (BaresipService.uas.size > 0) { - if (aorSpinner.selectedItemPosition == -1) - aorSpinner.setSelection(0) - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - if (ua.account.regint > 0) - Api.ua_register(ua.uap) - } - swipeRefresh.isRefreshing = false } baresipService = Intent(this@MainActivity, BaresipService::class.java) @@ -722,8 +430,7 @@ class MainActivity : AppCompatActivity() { it.forEach { permission -> if (!permission.value) { denied.add(permission.key) - if (ActivityCompat.shouldShowRequestPermissionRationale(this, - permission.key)) + if (shouldShowRequestPermissionRationale(permission.key)) shouldShow.add(permission.key) } } @@ -762,12 +469,1335 @@ class MainActivity : AppCompatActivity() { } // OnCreate - private fun setSpeakerIcon() { - if (speakerIcon != null) { - if (Utils.isSpeakerPhoneOn(am)) - speakerIcon!!.setIcon(R.drawable.speaker_on) + @Composable + private fun MainContent(ctx: Context) { + Scaffold( + modifier = Modifier.safeDrawingPadding(), + containerColor = LocalCustomColors.current.background, + topBar = { TopAppBar(ctx, String.format(getString(R.string.baresip))) }, + bottomBar = { Buttons(ctx) }, + content = { contentPadding -> + var isRefreshing by remember { mutableStateOf(false) } + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(1000) + isRefreshing = false + } + } + PullToRefreshBox( + isRefreshing = isRefreshing, + modifier = Modifier.fillMaxSize(), + enabled = pullToRefreshEnabled, + onRefresh = { + isRefreshing = true + if (uas.value.isNotEmpty()) { + if (viewModel.selectedAor.value == "") + spinToAor(uas.value.first().account.aor) + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + if (ua.account.regint > 0) + Api.ua_register(ua.uap) + } + } + ) { + Column( + modifier = Modifier + .consumeWindowInsets(contentPadding) + .padding( + PaddingValues( + top = 76.dp, bottom = 6.dp, + start = 16.dp, end = 16.dp + ) + ) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AccountSpinner(this@MainActivity) + CallUriRow(this@MainActivity) + CallRow(this@MainActivity) + OnHoldNotice() + } + } + } + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TopAppBar(ctx: Context, title: String) { + + val recOffImage = ImageVector.vectorResource(R.drawable.rec_off) + val recOnImage = ImageVector.vectorResource(R.drawable.rec_on) + + var recImage by remember { mutableStateOf(recOffImage) } + var menuExpanded by remember { mutableStateOf(false) } + + val about = String.format(getString(R.string.about)) + val settings = String.format(getString(R.string.configuration)) + val accounts = String.format(getString(R.string.accounts)) + val backup = String.format(getString(R.string.backup)) + val restore = String.format(getString(R.string.restore)) + val logcat = String.format(getString(R.string.logcat)) + val restart = String.format(getString(R.string.restart)) + val quit = String.format(getString(R.string.quit)) + + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + actions = { + IconButton( + modifier = Modifier.padding(end=12.dp), + onClick = { + if (Call.call("connected") == null) { + BaresipService.isRecOn = !BaresipService.isRecOn + recImage = if (BaresipService.isRecOn) { + Api.module_load("sndfile") + recOnImage + } + else { + Api.module_unload("sndfile") + recOffImage + } + } + else + Toast.makeText(ctx, R.string.rec_in_call, Toast.LENGTH_SHORT).show() + } + ) { + Icon(imageVector = recImage, + tint = Color.Unspecified, + contentDescription = null) + } + IconButton( + modifier = Modifier.padding(end=12.dp), + onClick = { + if (Call.call("connected") != null) { + BaresipService.isMicMuted = !BaresipService.isMicMuted + if (BaresipService.isMicMuted) { + micIcon = R.drawable.mic_off + Api.calls_mute(true) + } else { + micIcon = R.drawable.mic_on + Api.calls_mute(false) + } + } + } + ) { + Icon( + imageVector = ImageVector.vectorResource(micIcon), + tint = Color.Unspecified, + contentDescription = null) + } + IconButton( + modifier = Modifier.padding(end=6.dp), + onClick = { + if (Build.VERSION.SDK_INT >= 31) + Log.d(TAG, "Toggling speakerphone when dev/mode is " + + "${am.communicationDevice!!.type}/${am.mode}" + ) + Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(ctx), am) + speakerIcon = if (Utils.isSpeakerPhoneOn(am)) + R.drawable.speaker_on + else + R.drawable.speaker_off + } + ) { + Icon( + imageVector = ImageVector.vectorResource(speakerIcon), + tint = Color.Unspecified, + contentDescription = null) + } + IconButton( + onClick = { menuExpanded = !menuExpanded } + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Menu", + tint = LocalCustomColors.current.light + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + items = if (Build.VERSION.SDK_INT >= 29) + listOf(about, settings, accounts, backup, restore, logcat, restart, quit) + else + listOf(about, settings, accounts, backup, restore, restart, quit), + onItemClick = { selectedItem -> + menuExpanded = false + when (selectedItem) { + about -> { + startActivity(Intent(ctx, AboutActivity::class.java)) + } + settings -> { + startActivity(Intent(ctx, ConfigActivity::class.java)) + } + accounts -> { + val i = Intent(ctx, AccountsActivity::class.java) + val b = Bundle() + b.putString("aor", viewModel.selectedAor.value) + i.putExtras(b) + accountsRequest.launch(i) + } + backup -> { + when { + Build.VERSION.SDK_INT >= 29 -> + pickupFileFromDownloads("backup") + ContextCompat.checkSelfPermission(ctx, WRITE_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED -> { + Log.d(TAG, "Write External Storage permission granted") + val path = Utils.downloadsPath("baresip.bs") + downloadsOutputUri = File(path).toUri() + askPassword(getString(R.string.encrypt_password)) + } + shouldShowRequestPermissionRationale(WRITE_EXTERNAL_STORAGE) -> + Utils.alertView(ctx, getString(R.string.notice), getString(R.string.no_backup)) { + requestPermissionLauncher.launch(WRITE_EXTERNAL_STORAGE) + } + else -> + requestPermissionLauncher.launch(WRITE_EXTERNAL_STORAGE) + } + } + restore -> { + when { + Build.VERSION.SDK_INT >= 29 -> + pickupFileFromDownloads("restore") + ContextCompat.checkSelfPermission(ctx, READ_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED -> { + Log.d(TAG, "Read External Storage permission granted") + val path = Utils.downloadsPath("baresip.bs") + downloadsInputUri = File(path).toUri() + askPassword(getString(R.string.decrypt_password)) + } + shouldShowRequestPermissionRationale(READ_EXTERNAL_STORAGE) -> + Utils.alertView(ctx, getString(R.string.notice), getString(R.string.no_restore)) { + requestPermissionLauncher.launch(READ_EXTERNAL_STORAGE) + } + else -> + requestPermissionLauncher.launch(READ_EXTERNAL_STORAGE) + } + } + logcat -> { + if (Build.VERSION.SDK_INT >= 29) + pickupFileFromDownloads("logcat") + } + restart -> { + quitRestart(true) + } + quit -> { + quitRestart(false) + } + } + } + ) + } + ) + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun AccountSpinner(ctx: Context) { + + var expanded by rememberSaveable { mutableStateOf(false) } + val selected: String by viewModel.selectedAor.collectAsState() + + if (uas.value.isEmpty()) + viewModel.updateSelectedAor("") + else + if (selected == "" || UserAgent.ofAor(selected) == null) { + viewModel.updateSelectedAor(uas.value.first().account.aor) + } + + showCall(UserAgent.ofAor(selected)) + updateIcons(Account.ofAor(selected)) + + if (selected == "") { + OutlinedButton( + onClick = { + val i = Intent(this@MainActivity, AccountsActivity::class.java) + val b = Bundle() + b.putString("aor", selected) + i.putExtras(b) + accountsRequest.launch(i) + }, + modifier = Modifier + .padding(horizontal = 4.dp) + .height(50.dp) + .fillMaxWidth(), + colors = ButtonColors( + containerColor = LocalCustomColors.current.grayLight, + contentColor = LocalCustomColors.current.dark, + disabledContainerColor = LocalCustomColors.current.grayLight, + disabledContentColor = LocalCustomColors.current.dark + ), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text(text = "") + } + } + else + OutlinedButton( + onClick = { + expanded = !expanded + }, + enabled = true, + modifier = Modifier + .padding(horizontal = 4.dp) + .height(50.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + expanded = true + }, + onLongPress = { + val ua = UserAgent.ofAor(selected) + if (ua != null) { + val acc = ua.account + if (Api.account_regint(acc.accp) > 0) { + Api.account_set_regint(acc.accp, 0) + Api.ua_unregister(ua.uap) + } else { + Api.account_set_regint( + acc.accp, + acc.configuredRegInt + ) + Api.ua_register(ua.uap) + } + acc.regint = Api.account_regint(acc.accp) + AccountsActivity.saveAccounts() + } + } + ) + }, + colors = ButtonColors( + containerColor = LocalCustomColors.current.grayLight, + contentColor = LocalCustomColors.current.dark, + disabledContainerColor = LocalCustomColors.current.grayLight, + disabledContentColor = LocalCustomColors.current.dark + ), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource( + uasStatus.value[selected] ?: + R.drawable.locked_yellow), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .padding(end = 10.dp) + .clickable(onClick = { + val i = Intent(ctx, AccountActivity::class.java) + val b = Bundle() + b.putString("aor", selected) + i.putExtras(b) + accountRequest.launch(i) + }) + ) + Text( + text = Account.ofAor(selected)?.text() ?: "", + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier + .weight(1f) + .combinedClickable( + onClick = { expanded = true }, + onLongClick = { + val ua = UserAgent.ofAor(selected) + if (ua != null) { + val acc = ua.account + if (Api.account_regint(acc.accp) > 0) { + Api.account_set_regint(acc.accp, 0) + Api.ua_unregister(ua.uap) + } else { + Api.account_set_regint( + acc.accp, + acc.configuredRegInt + ) + Api.ua_register(ua.uap) + } + acc.regint = Api.account_regint(acc.accp) + AccountsActivity.saveAccounts() + } + } + ) + ) + Icon( + imageVector = if (expanded) + Icons.Default.KeyboardArrowUp + else + Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + uas.value.forEachIndexed { _, ua -> + val acc = ua.account + DropdownMenuItem( + onClick = { + expanded = false + run { + viewModel.updateSelectedAor(acc.aor) + } + showCall(ua) + updateIcons(acc) + }, + text = { Text( + text = acc.text(), + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) }, + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(uasStatus.value[acc.aor]!!), + contentDescription = null, + tint = Color.Unspecified, + ) + } + ) + } + } + } + } + + @Composable + fun CallUriRow(ctx: Context) { + val suggestions by remember { contactNames } + var filteredSuggestions by remember { mutableStateOf(suggestions) } + var showSuggestions by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val lazyListState = rememberLazyListState() + + Row(modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = callUri.value, + enabled = callUriEnabled.value, + singleLine = true, + onValueChange = { + val newValue = it + if (it != callUri.value) { + callUri.value = newValue + showSuggestions = newValue.length > 2 + filteredSuggestions = suggestions.filter { suggestion -> + newValue.length > 2 && suggestion.startsWith(newValue, ignoreCase = true) + } + } + }, + trailingIcon = { + if (callUriEnabled.value && callUri.value.isNotEmpty()) + Icon(Icons.Outlined.Clear, + contentDescription = null, + modifier = Modifier + .clickable { + callUri.value = "" + showSuggestions = false + } + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 4.dp, top = 12.dp, bottom = 2.dp) + .focusRequester(focusRequester), + label = { + Text( + text = callUriLabel.value, + fontSize = 18.sp, + ) + }, + textStyle = TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.itemText + ), + keyboardOptions = if (dialpad) + KeyboardOptions(keyboardType = KeyboardType.Phone) + else + KeyboardOptions(keyboardType = KeyboardType.Text) + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(8.dp, RoundedCornerShape(8.dp)) + .background(LocalCustomColors.current.grayLight, + shape = RoundedCornerShape(8.dp)) + .animateContentSize() + ) { + if (showSuggestions && filteredSuggestions.isNotEmpty()) { + Box(modifier = Modifier.fillMaxWidth().heightIn(max = 150.dp)) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .verticalScrollbar( + state = lazyListState, + color = LocalCustomColors.current.gray + ), + horizontalAlignment = Alignment.Start, + state = lazyListState + ) { + items( + items = filteredSuggestions, + key = { suggestion -> suggestion } + ) { suggestion -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + callUri.value = suggestion + showSuggestions = false + } + .padding(12.dp) + ) { + Text( + text = suggestion, + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColors.current.grayDark, + fontSize = 18.sp + ) + } + } + } + } + } + } + } + if (showCallTimer.value) { + val textColor = LocalCustomColors.current.itemText.toArgb() + AndroidView( + factory = { context -> + Chronometer(context).also { callTimer = it + callTimer?.textSize = 16F + callTimer?.setTextColor(textColor) + } + }, + modifier = Modifier.padding(start = 6.dp, + top = 4.dp, + end = if (securityIcon.intValue != -1) 6.dp else 0.dp), + ) + } + if (securityIcon.intValue != -1) { + Icon( + imageVector = ImageVector.vectorResource(securityIcon.intValue), + contentDescription = null, + modifier = Modifier.size(28.dp).padding(top = 4.dp) + .clickable { + when (securityIcon.intValue) { + R.drawable.unlocked -> { + Utils.alertView(ctx, getString(R.string.alert), + getString(R.string.call_not_secure) + ) + } + R.drawable.locked_yellow -> { + Utils.alertView(ctx, getString(R.string.alert), + getString(R.string.peer_not_verified) + ) + } + R.drawable.locked_green -> { + with(MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme)) { + setTitle(R.string.info) + setMessage(getString(R.string.call_is_secure)) + setPositiveButton(getString(R.string.unverify)) { dialog, _ -> + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + if (call != null) { + if (Api.cmd_exec("zrtp_unverify " + call.zid) != 0) + Log.e(TAG, "Command 'zrtp_unverify ${call.zid}' failed") + else + securityIcon.intValue = R.drawable.locked_yellow + } + dialog.dismiss() + } + setNeutralButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + show() + } + } + } + }, + tint = Color.Unspecified, + ) + } + } + } + + @Composable + fun CallRow(ctx: Context) { + + Row( modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween + ) { + if (showCallButton.value) + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + callClick(ctx) + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.call), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + if (showHangupButton.value) { + + var ua: UserAgent = userAgentofSelectedAor() + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + if (Build.VERSION.SDK_INT < 31) { + if (callRunnable != null) { + callHandler.removeCallbacks(callRunnable!!) + callRunnable = null + BaresipService.abandonAudioFocus(applicationContext) + showCall(ua) + } + } else { + if (audioModeChangedListener != null) { + am.removeOnModeChangedListener(audioModeChangedListener!!) + audioModeChangedListener = null + BaresipService.abandonAudioFocus(applicationContext) + showCall(ua) + } + } + val aor = ua.account.aor + val uaCalls = ua.calls() + if (uaCalls.size > 0) { + val call = uaCalls[uaCalls.size - 1] + val callp = call.callp + Log.d(TAG, "AoR $aor hanging up call $callp with ${callUri.value}") + showHangupButton.value = false + Api.ua_hangup(ua.uap, callp, 0, "") + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.hangup), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + val aor = ua.account.aor + val call = ua.currentCall()!! + if (call.onhold) { + Log.d(TAG, "AoR $aor resuming call ${call.callp} with ${callUri.value}") + call.resume() + call.onhold = false + holdIcon.intValue = R.drawable.call_hold + } else { + Log.d(TAG, "AoR $aor holding call ${call.callp} with ${callUri.value}") + call.hold() + call.onhold = true + holdIcon.intValue = R.drawable.resume + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = holdIcon.intValue), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + var showTransferDialog by remember { mutableStateOf(false) } + IconButton( + modifier = Modifier.size(48.dp), + enabled = transferButtonEnabled.value, + onClick = { + val call = ua.currentCall() + if (call != null) { + if (call.onHoldCall != null) { + if (!call.executeTransfer()) + Utils.alertView( + ctx, getString(R.string.notice), + String.format(getString(R.string.transfer_failed)) + ) + } + else + showTransferDialog = true + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(transferIcon.intValue), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + if (showTransferDialog) { + val call = ua.currentCall() + val showDialog = remember { mutableStateOf(call != null) } + val blindChecked = remember { mutableStateOf(true) } + if (showDialog.value) + BasicAlertDialog( + onDismissRequest = { + keyboardController?.hide() + showDialog.value = false + showTransferDialog = false + } + ) { + Surface( + modifier = Modifier.wrapContentWidth().wrapContentHeight(), + color = LocalCustomColors.current.grayLight, + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = ContextCompat.getString(ctx, R.string.call_transfer), + fontSize = 20.sp, + modifier = Modifier.padding(16.dp), + color = LocalCustomColors.current.primary, + ) + var uri by remember { mutableStateOf("") } + val suggestions by remember { contactNames } + var filteredSuggestions by remember { mutableStateOf(suggestions) } + var showSuggestions by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val lazyListState = rememberLazyListState() + OutlinedTextField( + value = uri, + singleLine = true, + onValueChange = { + uri = it + showSuggestions = uri.isNotEmpty() + filteredSuggestions = if (uri.isEmpty()) + suggestions + else + suggestions.filter { suggestion -> + uri.length > 2 && suggestion.startsWith(uri, + ignoreCase = true) + } + }, + trailingIcon = { + if (uri.isNotEmpty()) + Icon( + Icons.Outlined.Clear, + contentDescription = null, + modifier = Modifier.clickable { + uri = "" + showSuggestions = false + } + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 4.dp, + end = 4.dp, + top = 12.dp, + bottom = 2.dp + ) + .focusRequester(focusRequester), + label = { + Text( + text = stringResource(R.string.transfer_destination), + fontSize = 18.sp, + color = LocalCustomColors.current.dark + ) + }, + textStyle = TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.dark + ), + keyboardOptions = if (dialpad) + KeyboardOptions(keyboardType = KeyboardType.Phone) + else + KeyboardOptions(keyboardType = KeyboardType.Text) + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(8.dp, RoundedCornerShape(8.dp)) + .background(LocalCustomColors.current.grayLight, + shape = RoundedCornerShape(8.dp)) + .animateContentSize() + ) { + if (showSuggestions && filteredSuggestions.isNotEmpty()) { + Box(modifier = Modifier.fillMaxWidth().heightIn(150.dp)) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .verticalScrollbar( + state = lazyListState, + color = LocalCustomColors.current.gray + ), + horizontalAlignment = Alignment.Start, + state = lazyListState, + ) { + items( + items = filteredSuggestions, + key = { suggestion -> suggestion } + ) { suggestion -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + callUri.value = suggestion + showSuggestions = false + } + .padding(12.dp) + ) { + Text( + text = suggestion, + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColors.current.grayDark, + fontSize = 18.sp + ) + } + } + } + } + } + } + if (call!!.replaces()) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = ContextCompat.getString(ctx, R.string.blind), + color = LocalCustomColors.current.dark, + modifier = Modifier.padding(16.dp), + ) + Checkbox( + checked = blindChecked.value + ) { + blindChecked.value = true + } + Text( + text = ContextCompat.getString(ctx, R.string.attended), + color = LocalCustomColors.current.dark, + modifier = Modifier.padding(16.dp), + ) + Checkbox( + checked = !blindChecked.value + ) { + blindChecked.value = false + } + } + Row( + horizontalArrangement = Arrangement.Absolute.SpaceEvenly + ) { + TextButton( + onClick = { + keyboardController?.hide() + showDialog.value = false + showTransferDialog = false + }, + modifier = Modifier.padding(8.dp), + ) { + Text( + text = stringResource(R.string.cancel), + color = LocalCustomColors.current.gray + ) + } + TextButton( + onClick = { + var uriText = uri.trim() + if (uriText.isNotEmpty()) { + val uris = Contact.contactUris(uriText) + if (uris.size > 1) { + val destinationBuilder = + MaterialAlertDialogBuilder( + ctx, + R.style.AlertDialogTheme + ) + with(destinationBuilder) { + setTitle(R.string.choose_destination_uri) + setItems(uris.toTypedArray()) { _, which -> + uriText = uris[which] + transfer( + ua, + if (Utils.isTelNumber(uriText)) + "tel:$uriText" + else + uriText, + !blindChecked.value + ) + } + } + } else { + if (uris.size == 1) + uriText = uris[0] + } + transfer( + ua, + if (Utils.isTelNumber(uriText)) "tel:$uriText" else uriText, + !blindChecked.value + ) + keyboardController?.hide() + showDialog.value = false + showTransferDialog = false + } + }, + modifier = Modifier.padding(8.dp), + ) { + Text( + text = stringResource( + if (blindChecked.value) + R.string.transfer + else + R.string.call + ).uppercase(), + color = LocalCustomColors.current.primary + ) + } + } + } + } + } + } + + val focusRequester = remember { FocusRequester() } + val shouldRequestFocus by focusDtmf + TextField( + value = dtmfText.value, + onValueChange = { newText -> + if (newText.length > dtmfText.value.length) { + val char = newText.last() + Log.d(TAG, "Got DTMF digit '$char'") + if (char.isDigit() || char == '*' || char == '#') { + dtmfText.value = newText + ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + ua.currentCall()!!.sendDigit(char) + } + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .width(80.dp) + .focusRequester(focusRequester), + enabled = dtmfEnabled.value, + textStyle = TextStyle(fontSize = 16.sp), + label = { Text(getString(R.string.dtmf), fontSize = 16.sp) }, + singleLine = true + ) + LaunchedEffect(shouldRequestFocus) { + if (shouldRequestFocus) { + focusRequester.requestFocus() + focusDtmf.value = false + } + } + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall()!! + val stats = call.stats("audio") + if (stats != "") { + val parts = stats.split(",") as java.util.ArrayList + if (parts[2] == "0/0") { + parts[2] = "?/?" + parts[3] = "?/?" + parts[4] = "?/?" + } + val codecs = call.audioCodecs() + val duration = call.duration() + val txCodec = codecs.split(',')[0].split("/") + val rxCodec = codecs.split(',')[1].split("/") + Utils.alertView( + ctx, getString(R.string.call_info), + "${String.format(getString(R.string.duration), duration)}\n" + + "${getString(R.string.codecs)}: ${txCodec[0]} ch ${txCodec[2]}/" + + "${rxCodec[0]} ch ${rxCodec[2]}\n" + + "${String.format(getString(R.string.rate), parts[0])}\n" + + "${ + String.format( + getString(R.string.average_rate), + parts[1] + ) + }\n" + + "${getString(R.string.packets)}: ${parts[2]}\n" + + "${getString(R.string.lost)}: ${parts[3]}\n" + + String.format(getString(R.string.jitter), parts[4]) + ) + } else { + Utils.alertView( + ctx, getString(R.string.call_info), + getString(R.string.call_info_not_available) + ) + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.info), + modifier = Modifier.size(36.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + } + + if (showAnswerRejectButtons.value) { + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + answer(ctx) + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.call), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + Spacer(Modifier.weight(1f)) + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + reject() + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.hangup), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + } + } + + } + + private fun callClick(ctx: Context) { + if (viewModel.selectedAor.value != "") { + if (Utils.checkPermissions(ctx, arrayOf(RECORD_AUDIO))) + makeCall() else - speakerIcon!!.setIcon(R.drawable.speaker_off) + Toast.makeText(applicationContext, R.string.no_calls, Toast.LENGTH_SHORT).show() + } + } + + private fun makeCall(lookForContact: Boolean = true) { + val ua = UserAgent.ofAor(viewModel.selectedAor.value) + val aor = ua!!.account.aor + if (!Call.inCall()) { + var uriText = callUri.value.trim() + if (uriText.isNotEmpty()) { + if (lookForContact) { + val uris = Contact.contactUris(uriText) + if (uris.size == 1) + uriText = uris[0] + else if (uris.size > 1) { + val builder = MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) + with(builder) { + setTitle(R.string.choose_destination_uri) + setItems(uris.toTypedArray()) { _, which -> + callUri.value = uris[which] + makeCall(false) + } + setNeutralButton(getString(R.string.cancel)) { _: DialogInterface, _: Int -> } + show() + } + return + } + } + if (Utils.isTelNumber(uriText)) + uriText = "tel:$uriText" + val uri = if (Utils.isTelUri(uriText)) { + if (ua.account.telProvider == "") { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.no_telephony_provider), aor)) + return + } + Utils.telToSip(uriText, ua.account) + } else { + Utils.uriComplete(uriText, aor) + } + if (!Utils.checkUri(uri)) { + Utils.alertView(this, getString(R.string.notice), + String.format(getString(R.string.invalid_sip_or_tel_uri), uri)) + } else if (!BaresipService.requestAudioFocus(applicationContext)) { + Toast.makeText(applicationContext, R.string.audio_focus_denied, + Toast.LENGTH_SHORT).show() + } else { + callUriEnabled.value = false + showCallButton.value = false + showHangupButton.value = true + if (Build.VERSION.SDK_INT < 31) { + Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") + am.mode = AudioManager.MODE_IN_COMMUNICATION + runCall(ua, uri) + } else { + if (am.mode == AudioManager.MODE_IN_COMMUNICATION) { + runCall(ua, uri) + } else { + audioModeChangedListener = AudioManager.OnModeChangedListener { mode -> + if (mode == AudioManager.MODE_IN_COMMUNICATION) { + Log.d(TAG, "Audio mode changed to MODE_IN_COMMUNICATION using " + + "device ${am.communicationDevice!!.type}") + if (audioModeChangedListener != null) { + am.removeOnModeChangedListener(audioModeChangedListener!!) + audioModeChangedListener = null + } + runCall(ua, uri) + } else { + Log.d(TAG, "Audio mode changed to mode ${am.mode} using " + + "device ${am.communicationDevice!!.type}") + } + } + am.addOnModeChangedListener(mainExecutor, audioModeChangedListener!!) + Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") + am.mode = AudioManager.MODE_IN_COMMUNICATION + } + } + } + } else { + val latestPeerUri = CallHistoryNew.aorLatestPeerUri(aor) + if (latestPeerUri != null) + callUri.value = Utils.friendlyUri(this, latestPeerUri, ua.account) + } + } + } + + private fun answer(ctx: Context) { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + if (call != null) { + Log.d(TAG, "AoR ${ua.account.aor} answering call from ${callUri.value}") + val intent = Intent(ctx, BaresipService::class.java) + intent.action = "Call Answer" + intent.putExtra("uap", ua.uap) + intent.putExtra("callp", call.callp) + intent.putExtra("video", Api.VIDMODE_OFF) + startService(intent) + } + } + + private fun reject() { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val aor = ua.account.aor + val call = ua.currentCall()!! + val callp = call.callp + Log.d(TAG, "AoR $aor rejecting call $callp from ${callUri.value}") + call.rejected = true + Api.ua_hangup(ua.uap, callp, 486, "Busy Here") + } + + @Composable + fun Buttons(ctx: Context) { + val buttonSize = 48.dp + Row( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + if (showVmIcon) + IconButton( + onClick = { + if (viewModel.selectedAor.value != "") { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val acc = ua.account + if (acc.vmUri != "") { + val dialogClickListener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + val i = Intent(ctx, MainActivity::class.java) + i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + i.putExtra("action", "call") + i.putExtra("uap", ua.uap) + i.putExtra("peer", acc.vmUri) + startActivity(i) + } + DialogInterface.BUTTON_NEGATIVE -> { + } + } + } + with(MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme)) { + setTitle(R.string.voicemail_messages) + setMessage(acc.vmMessages(ctx)) + setPositiveButton(getString(R.string.listen), dialogClickListener) + setNeutralButton(getString(R.string.cancel), dialogClickListener) + show() + } + } + } + }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(vmIcon.intValue), + contentDescription = null, + Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + + IconButton( + onClick = { + if (viewModel.selectedAor.value != "") { + val i = Intent(ctx, ContactsActivity::class.java) + val b = Bundle() + b.putString("aor", viewModel.selectedAor.value) + i.putExtras(b) + contactsRequest.launch(i) + } + }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.contacts), + contentDescription = null, + Modifier.size(buttonSize), + tint = LocalCustomColors.current.secondary + ) + } + + IconButton( + onClick = { + if (viewModel.selectedAor.value != "") { + val i = Intent(this@MainActivity, ChatsActivity::class.java) + val b = Bundle() + b.putString("aor", viewModel.selectedAor.value) + b.putString("peer", resumeUri) + i.putExtras(b) + chatRequests.launch(i) + } + }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(messagesIcon.intValue), + contentDescription = null, + Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + + IconButton( + onClick = { + calls(ctx) + }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.calls), + contentDescription = null, + Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + + IconButton( + onClick = { dialpad = !dialpad }, + modifier = Modifier + .weight(1f) + .size(buttonSize), + enabled = dialpadButtonEnabled + ) { + Icon( + imageVector = if (dialpad) + ImageVector.vectorResource(R.drawable.dialpad_on) + else + ImageVector.vectorResource(R.drawable.dialpad_off), + contentDescription = null, + modifier = Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + } + + } + + @Composable + fun OnHoldNotice() { + if (showOnHoldNotice.value) + OutlinedButton( + onClick = {}, + border = BorderStroke(1.dp, LocalCustomColors.current.accent), + modifier = Modifier.padding(16.dp), + shape = RoundedCornerShape(20) + ) { + Text(text = getString(R.string.call_is_on_hold), + fontSize = 18.sp, + color = LocalCustomColors.current.itemText, + ) + } + + } + + private fun calls(ctx: Context) { + if (viewModel.selectedAor.value != "") { + val i = Intent(ctx, CallsActivity::class.java) + val b = Bundle() + b.putString("aor", viewModel.selectedAor.value) + i.putExtras(b) + callsRequest.launch(i) + } + } + + private fun updateIcons(acc: Account?) { + if (acc == null) { + showVmIcon = false + messagesIcon.intValue = R.drawable.messages + callsIcon.intValue = R.drawable.calls + } + else { + if (acc.vmUri != "") { + showVmIcon = true + vmIcon.intValue = if (acc.vmNew > 0) + R.drawable.voicemail_new + else + R.drawable.voicemail + } else + showVmIcon = false + messagesIcon.intValue= if (acc.unreadMessages) + R.drawable.messages_unread + else + R.drawable.messages + callsIcon.intValue = if (acc.missedCalls) + R.drawable.calls_missed + else + R.drawable.calls } } @@ -793,20 +1823,20 @@ class MainActivity : AppCompatActivity() { arrayListOf(resumeCall!!.ua.uap, resumeCall!!.callp)) } "call answer" -> { - answerButton.performClick() + answer(this@MainActivity) showCall(resumeCall!!.ua) } "call missed" -> { - callsButton.performClick() + calls(this@MainActivity) } "call reject" -> - rejectButton.performClick() + reject() "call" -> { - callUri.setText(BaresipService.uas[aorSpinner.selectedItemPosition].account.resumeUri) - callButton.performClick() + callUri.value = Account.ofAor(viewModel.selectedAor.value)!!.resumeUri + callClick(this@MainActivity) } "dial" -> { - callUri.setText(BaresipService.uas[aorSpinner.selectedItemPosition].account.resumeUri) + callUri.value = Account.ofAor(viewModel.selectedAor.value)!!.resumeUri } "call transfer", "transfer show", "transfer accept" -> handleServiceEvent("$resumeAction,$resumeUri", @@ -819,20 +1849,17 @@ class MainActivity : AppCompatActivity() { spinToAor(incomingCall.ua.account.aor) } else { restoreActivities() - if (BaresipService.uas.size > 0) { - if (aorSpinner.selectedItemPosition == -1) { + if (uas.value.isNotEmpty()) { + if (viewModel.selectedAor.value == "") { if (Call.inCall()) spinToAor(Call.calls()[0].ua.account.aor) - else { - aorSpinner.setSelection(0) - aorSpinner.tag = BaresipService.uas[0].account.aor - } + else + spinToAor(uas.value.first().account.aor) } } } - uaAdapter.notifyDataSetChanged() - if (BaresipService.uas.size > 0) { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] + val ua = UserAgent.ofAor(viewModel.selectedAor.value) + if (ua != null) { showCall(ua) updateIcons(ua.account) } @@ -846,7 +1873,7 @@ class MainActivity : AppCompatActivity() { Log.d(TAG, "Main onPause") Utils.addActivity("main") BaresipService.isMainVisible = false - callTimer.stop() + callTimer?.stop() saveCallUri() } @@ -866,6 +1893,12 @@ class MainActivity : AppCompatActivity() { BaresipService.activities.clear() } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (dtmfEnabled.value) + focusDtmf.value = true + } + override fun onNewIntent(intent: Intent) { // Called when MainActivity already exists at the top of current task super.onNewIntent(intent) @@ -879,9 +1912,8 @@ class MainActivity : AppCompatActivity() { Log.d(TAG, "onNewIntent with action/data '${intent.action}/${intent.data}'") when (intent.action) { - ACTION_DIAL, ACTION_CALL, ACTION_VIEW -> { + ACTION_DIAL, ACTION_CALL, ACTION_VIEW -> callAction(intent.data, if (intent.action == ACTION_CALL) "call" else "dial") - } else -> { val action = intent.getStringExtra("action") if (action != null) { @@ -892,8 +1924,8 @@ class MainActivity : AppCompatActivity() { } } - private fun callAction(uri: Uri?, action: String) { - if (Call.inCall() || BaresipService.uas.size == 0) + private fun callAction(uri: Uri?, action: String) { + if (Call.inCall() || uas.value.isEmpty()) return Log.d(TAG, "Action $action to $uri") if (uri != null) { @@ -901,8 +1933,8 @@ class MainActivity : AppCompatActivity() { "sip" -> { val uriStr = Utils.uriUnescape(uri.toString()) var ua = UserAgent.ofDomain(Utils.uriHostPart(uriStr)) - if (ua == null && BaresipService.uas.size > 0) - ua = BaresipService.uas[0] + if (ua == null && uas.value.isNotEmpty()) + ua = uas.value[0] if (ua == null) { Log.w(TAG, "No accounts for '$uriStr'") return @@ -958,8 +1990,7 @@ class MainActivity : AppCompatActivity() { Log.w(TAG, "handleIntent 'call' did not find ua $uap") return } - if (ua.account.aor != aorSpinner.tag) - spinToAor(ua.account.aor) + spinToAor(ua.account.aor) resumeAction = action ua.account.resumeUri = intent.getStringExtra("peer")!! } @@ -971,8 +2002,7 @@ class MainActivity : AppCompatActivity() { return } val ua = call.ua - if (ua.account.aor != aorSpinner.tag) - spinToAor(ua.account.aor) + spinToAor(ua.account.aor) resumeAction = action resumeCall = call } @@ -983,8 +2013,7 @@ class MainActivity : AppCompatActivity() { Log.w(TAG, "handleIntent did not find ua $uap") return } - if (ua.account.aor != aorSpinner.tag) - spinToAor(ua.account.aor) + spinToAor(ua.account.aor) resumeAction = action } "call transfer", "transfer show", "transfer accept" -> { @@ -1009,8 +2038,7 @@ class MainActivity : AppCompatActivity() { Log.w(TAG, "handleIntent did not find ua $uap") return } - if (ua.account.aor != aorSpinner.tag) - spinToAor(ua.account.aor) + spinToAor(ua.account.aor) resumeAction = action resumeUap = uap resumeUri = intent.getStringExtra("peer")!! @@ -1056,23 +2084,19 @@ class MainActivity : AppCompatActivity() { if (event == "started") { val uriString = params[0] as String Log.d(TAG, "Handling service event 'started' with URI '$uriString'") - if (!this::uaAdapter.isInitialized) { + if (!initialized) { // Android has restarted baresip when permission has been denied in app settings recreate() return } - uaAdapter.notifyDataSetChanged() - if (uriString != "") { + if (uriString != "") callAction(uriString.toUri(), "dial") - } else { - if ((aorSpinner.selectedItemPosition == -1) && (BaresipService.uas.size > 0)) { - aorSpinner.setSelection(0) - aorSpinner.tag = BaresipService.uas[0].account.aor - } + else { + if (viewModel.selectedAor.value == "" && uas.value.isNotEmpty()) + viewModel.updateSelectedAor(uas.value.first().account.aor) } if (Preferences(applicationContext).displayTheme != AppCompatDelegate.getDefaultNightMode()) { AppCompatDelegate.setDefaultNightMode(Preferences(applicationContext).displayTheme) - delegate.applyDayNight() } handleNextEvent() return @@ -1106,16 +2130,13 @@ class MainActivity : AppCompatActivity() { when (ev[0]) { "call rejected" -> { - if (aor == aorSpinner.tag) { - callsButton.setImageResource(R.drawable.calls_missed) - } + if (aor == viewModel.selectedAor.value) + callsIcon.intValue = R.drawable.calls_missed } "call incoming", "call outgoing" -> { val callp = params[1] as Long if (BaresipService.isMainVisible) { - uaAdapter.notifyDataSetChanged() - if (aor != aorSpinner.tag) - spinToAor(aor) + spinToAor(aor) showCall(ua, Call.ofCallp(callp)) } else { Log.d(TAG, "Reordering to front") @@ -1158,9 +2179,8 @@ class MainActivity : AppCompatActivity() { showCall(ua) } "call established" -> { - if (aor == aorSpinner.tag) { - dtmf.text = null - dtmf.hint = getString(R.string.dtmf) + if (aor == viewModel.selectedAor.value) { + dtmfText.value = "" showCall(ua) } } @@ -1185,19 +2205,15 @@ class MainActivity : AppCompatActivity() { R.drawable.locked_green } call.zid = ev[2] - if (aor == aorSpinner.tag) { - securityButton.tag = call.security - securityButton.setImageResource(call.security) - } + if (aor == viewModel.selectedAor.value) + securityIcon.intValue = call.security dialog.dismiss() } setNeutralButton(getString(R.string.no)) { dialog, _ -> call.security = R.drawable.locked_yellow call.zid = ev[2] - if (aor == aorSpinner.tag) { - securityButton.tag = R.drawable.locked_yellow - securityButton.setImageResource(R.drawable.locked_yellow) - } + if (aor == viewModel.selectedAor.value) + securityIcon.intValue = R.drawable.locked_yellow dialog.dismiss() } show() @@ -1210,10 +2226,8 @@ class MainActivity : AppCompatActivity() { handleNextEvent("Call $callp that is verified is not found") return } - if (aor == aorSpinner.tag) { - securityButton.setImageResource(call.security) - securityButton.tag = call.security - } + if (aor == viewModel.selectedAor.value) + securityIcon.intValue = call.security } "call transfer", "transfer show" -> { val callp = params[1] as Long @@ -1252,7 +2266,7 @@ class MainActivity : AppCompatActivity() { if (call in Call.calls()) acceptTransfer(ua, call!!, ev[1]) else { - callUri.setText(ev[1]) + callUri.value = ev[1] makeCall() } dialog.dismiss() @@ -1277,23 +2291,18 @@ class MainActivity : AppCompatActivity() { showCall(ua) } "call closed" -> { - uaAdapter.notifyDataSetChanged() val call = ua.currentCall() if (call != null) { call.resume() startCallTimer(call) - } else { - callTimer.stop() } - if (aor == aorSpinner.tag) { - callUri.inputType = InputType.TYPE_CLASS_TEXT + - InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS - dialpadButton.setImageResource(R.drawable.dialpad_off) - dialpadButton.tag = "off" + else + callTimer?.stop() + if (aor == viewModel.selectedAor.value) { ua.account.resumeUri = "" showCall(ua) if (acc.missedCalls) - callsButton.setImageResource(R.drawable.calls_missed) + callsIcon.intValue = R.drawable.calls_missed } if (kgm.isDeviceLocked) this.setShowWhenLocked(false) @@ -1318,11 +2327,11 @@ class MainActivity : AppCompatActivity() { break } } - if (aor == aorSpinner.tag) { - if (acc.vmNew > 0) - voicemailButton.setImageResource(R.drawable.voicemail_new) + if (aor == viewModel.selectedAor.value) { + vmIcon.intValue = if (acc.vmNew > 0) + R.drawable.voicemail_new else - voicemailButton.setImageResource(R.drawable.voicemail) + R.drawable.voicemail } } else -> Log.e(TAG, "Unknown event '${ev[0]}'") @@ -1332,10 +2341,10 @@ class MainActivity : AppCompatActivity() { } private fun redirect(ua: UserAgent, redirectUri: String) { - if (ua.account.aor != aorSpinner.tag) + if (ua.account.aor != viewModel.selectedAor.value) spinToAor(ua.account.aor) - callUri.setText(redirectUri) - callButton.performClick() + callUri.value = redirectUri + callClick(this@MainActivity) } private fun reStart() { @@ -1346,172 +2355,6 @@ class MainActivity : AppCompatActivity() { exitProcess(0) } - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - if (Build.VERSION.SDK_INT >= 29) - menu.findItem(R.id.logcat).setVisible(true) - return super.onPrepareOptionsMenu(menu) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - - menuInflater.inflate(R.menu.main_menu, menu) - - menuInflater.inflate(R.menu.rec_icon, menu) - recIcon = menu.findItem(R.id.recIcon) - if (BaresipService.isRecOn) - recIcon!!.setIcon(R.drawable.rec_on) - else - recIcon!!.setIcon(R.drawable.rec_off) - - menuInflater.inflate(R.menu.mic_icon, menu) - micIcon = menu.findItem(R.id.micIcon) - if (BaresipService.isMicMuted) - micIcon!!.setIcon(R.drawable.mic_off) - else - micIcon!!.setIcon(R.drawable.mic_on) - - menuInflater.inflate(R.menu.speaker_icon, menu) - speakerIcon = menu.findItem(R.id.speakerIcon) - setSpeakerIcon() - - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - when (item.itemId) { - - R.id.recIcon -> { - if (Call.call("connected") == null) { - BaresipService.isRecOn = !BaresipService.isRecOn - if (BaresipService.isRecOn) { - item.setIcon(R.drawable.rec_on) - Api.module_load("sndfile") - } else { - item.setIcon(R.drawable.rec_off) - Api.module_unload("sndfile") - } - } else { - Toast.makeText( - applicationContext, - R.string.rec_in_call, - Toast.LENGTH_SHORT - ).show() - } - } - - R.id.micIcon -> { - if (Call.call("connected") != null) { - BaresipService.isMicMuted = !BaresipService.isMicMuted - if (BaresipService.isMicMuted) { - item.setIcon(R.drawable.mic_off) - Api.calls_mute(true) - } else { - item.setIcon(R.drawable.mic_on) - Api.calls_mute(false) - } - } - } - - R.id.speakerIcon -> { - if (Build.VERSION.SDK_INT >= 31) - Log.d( - TAG, "Toggling speakerphone when dev/mode is " + - "${am.communicationDevice!!.type}/${am.mode}" - ) - Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(this), am) - setSpeakerIcon() - } - - R.id.config -> { - configRequest.launch(Intent(this, ConfigActivity::class.java)) - } - - R.id.accounts -> { - val i = Intent(this, AccountsActivity::class.java) - val b = Bundle() - b.putString("aor", aorSpinner.tag.toString()) - i.putExtras(b) - accountsRequest.launch(i) - } - - R.id.backup -> { - when { - Build.VERSION.SDK_INT >= 29 -> pickupFileFromDownloads("backup") - ContextCompat.checkSelfPermission( - this, - WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Write External Storage permission granted") - val path = Utils.downloadsPath("baresip.bs") - downloadsOutputUri = File(path).toUri() - askPassword(getString(R.string.encrypt_password)) - } - ActivityCompat.shouldShowRequestPermissionRationale( - this, - WRITE_EXTERNAL_STORAGE) -> { - layout.showSnackBar( - binding.root, - getString(R.string.no_backup), - Snackbar.LENGTH_INDEFINITE, - getString(R.string.ok) - ) { - requestPermissionLauncher.launch(WRITE_EXTERNAL_STORAGE) - } - } - else -> { - requestPermissionLauncher.launch(WRITE_EXTERNAL_STORAGE) - } - } - } - - R.id.restore -> { - when { - Build.VERSION.SDK_INT >= 29 -> - pickupFileFromDownloads("restore") - ContextCompat.checkSelfPermission( - this, - READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Read External Storage permission granted") - val path = Utils.downloadsPath("baresip.bs") - downloadsInputUri = File(path).toUri() - askPassword(getString(R.string.decrypt_password)) - } - ActivityCompat.shouldShowRequestPermissionRationale( - this, - READ_EXTERNAL_STORAGE) -> { - layout.showSnackBar( - binding.root, - getString(R.string.no_restore), - Snackbar.LENGTH_INDEFINITE, - getString(R.string.ok) - ) { - requestPermissionLauncher.launch(READ_EXTERNAL_STORAGE) - } - } - else -> { - requestPermissionLauncher.launch(READ_EXTERNAL_STORAGE) - } - } - } - - R.id.logcat -> { - if (Build.VERSION.SDK_INT >= 29) - pickupFileFromDownloads("logcat") - } - - R.id.about -> { - startActivity(Intent(this, AboutActivity::class.java)) - } - - R.id.restart, R.id.quit -> { - quitRestart(item.itemId == R.id.restart) - } - } - return true - } - @RequiresApi(29) private fun pickupFileFromDownloads(action: String) { when (action) { @@ -1544,7 +2387,7 @@ class MainActivity : AppCompatActivity() { } private fun quitRestart(reStart: Boolean) { - Log.d(TAG, "quitRestart Restart = $reStart") + Log.i(TAG, "quitRestart Restart = $reStart") window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) if (BaresipService.isServiceRunning) { @@ -1560,86 +2403,6 @@ class MainActivity : AppCompatActivity() { } } - private fun makeTransfer(ua: UserAgent) { - - val layout = LayoutInflater.from(this) - .inflate(R.layout.call_transfer_dialog, findViewById(android.R.id.content), false) - val blind: CheckBox = layout.findViewById(R.id.blind) - val attended: CheckBox = layout.findViewById(R.id.attended) - - val transferUri: AutoCompleteTextView = layout.findViewById(R.id.transferUri) - transferUri.setAdapter(ArrayAdapter(this, android.R.layout.select_dialog_item, - Contact.contactNames())) - transferUri.threshold = 2 - transferUri.requestFocus() - - val builder = MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) - with(builder) { - setView(layout) - setPositiveButton(R.string.transfer) { dialog, _ -> - imm.hideSoftInputFromWindow(transferUri.windowToken, 0) - dialog.dismiss() - var uriText = transferUri.text.toString().trim() - if (uriText.isNotEmpty()) { - val uris = Contact.contactUris(uriText) - if (uris.size > 1) { - val destinationBuilder = MaterialAlertDialogBuilder( - this@MainActivity, - R.style.AlertDialogTheme - ) - with(destinationBuilder) { - setTitle(R.string.choose_destination_uri) - setItems(uris.toTypedArray()) { _, which -> - uriText = uris[which] - transfer( - ua, - if (Utils.isTelNumber(uriText)) "tel:$uriText" else uriText, - attended.isChecked - ) - } - setNeutralButton(getString(R.string.cancel)) { _: DialogInterface, _: Int -> } - show() - } - } else { - if (uris.size == 1) - uriText = uris[0] - } - transfer(ua, if (Utils.isTelNumber(uriText)) "tel:$uriText" else uriText, - attended.isChecked) - } - } - setNeutralButton(android.R.string.cancel) { dialog, _ -> - imm.hideSoftInputFromWindow(transferUri.windowToken, 0) - dialog.cancel() - } - } - val alertDialog = builder.create() - - val call = ua.currentCall() ?: return - val blindOrAttended: RelativeLayout = layout.findViewById(R.id.blindOrAttended) - if (call.replaces()) { - blind.setOnClickListener { - if (blind.isChecked) { - attended.isChecked = false - alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).text = getString(R.string.transfer) - } - } - attended.setOnClickListener { - if (attended.isChecked) { - blind.isChecked = false - alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).text = getString(R.string.call) - } - } - blindOrAttended.visibility = View.VISIBLE - } else { - blindOrAttended.visibility = View.GONE - } - - // This needs to be done after dialog has been created and before it is shown - alertDialog.window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - alertDialog.show() - } - private fun transfer(ua: UserAgent, uriText: String, attended: Boolean) { val uri = if (Utils.isTelUri(uriText)) Utils.telToSip(uriText, ua.account) @@ -1666,6 +2429,7 @@ class MainActivity : AppCompatActivity() { } } } + private fun askPassword(title: String, ua: UserAgent? = null) { val layout = LayoutInflater.from(this) .inflate(R.layout.password_dialog, findViewById(android.R.id.content), @@ -1781,7 +2545,7 @@ class MainActivity : AppCompatActivity() { } private fun startBaresip() { - if (!BaresipService.isStartReceived) { + if (!BaresipService.isStartReceived) { baresipService.action = "Start" startService(baresipService) if (atStartup) @@ -1830,22 +2594,32 @@ class MainActivity : AppCompatActivity() { val zipFilePath = BaresipService.filesPath + "/$zipFile" val zipData = Utils.decryptFromUri(applicationContext, downloadsInputUri!!, password) if (zipData == null) { - Utils.alertView(this, getString(R.string.error), - String.format(getString(R.string.restore_failed), - Utils.fileNameOfUri(applicationContext, downloadsInputUri!!))) + Utils.alertView( + this, getString(R.string.error), + String.format( + getString(R.string.restore_failed), + Utils.fileNameOfUri(applicationContext, downloadsInputUri!!) + ) + ) return } if (!Utils.putFileContents(zipFilePath, zipData)) { Log.w(TAG, "Failed to write zip file '$zipFile'") - Utils.alertView(this, getString(R.string.error), - String.format(getString(R.string.restore_failed), - Utils.fileNameOfUri(applicationContext, downloadsInputUri!!))) + Utils.alertView( + this, getString(R.string.error), + String.format( + getString(R.string.restore_failed), + Utils.fileNameOfUri(applicationContext, downloadsInputUri!!) + ) + ) return } if (!Utils.unZip(zipFilePath)) { Log.w(TAG, "Failed to unzip file '$zipFile'") - Utils.alertView(this, getString(R.string.error), - String.format(getString(R.string.restore_unzip_failed), "baresip", "60.0.0")) + Utils.alertView( + this, getString(R.string.error), + String.format(getString(R.string.restore_unzip_failed), "baresip", "60.0.0") + ) return } Utils.deleteFile(File(zipFilePath)) @@ -1875,29 +2649,21 @@ class MainActivity : AppCompatActivity() { } private fun spinToAor(aor: String) { - for (accountIndex in BaresipService.uas.indices) - if (BaresipService.uas[accountIndex].account.aor == aor) { - aorSpinner.setSelection(accountIndex) - aorSpinner.tag = aor - return - } - if (BaresipService.uas.isNotEmpty()) { - aorSpinner.setSelection(0) - aorSpinner.tag = BaresipService.uas[0].account.aor - } else { - aorSpinner.setSelection(-1) - aorSpinner.tag = "" - } + if (aor != viewModel.selectedAor.value) + viewModel.updateSelectedAor(aor) + updateIcons(Account.ofAor(aor)) + } + private fun userAgentofSelectedAor(): UserAgent { + return UserAgent.ofAor(viewModel.selectedAor.value)!! } private fun call(ua: UserAgent, uri: String, onHoldCall: Call? = null): Boolean { - if (ua.account.aor != aorSpinner.tag) - spinToAor(ua.account.aor) + spinToAor(ua.account.aor) val callp = ua.callAlloc(0L, Api.VIDMODE_OFF) return if (callp != 0L) { Log.d(TAG, "Adding outgoing call ${ua.uap}/$callp/$uri") - val call = Call(callp, ua, uri, "out", "outgoing", Utils.dtmfWatcher(callp)) + val call = Call(callp, ua, uri, "out", "outgoing", null) call.onHoldCall = onHoldCall call.add() if (onHoldCall != null) @@ -1924,11 +2690,10 @@ class MainActivity : AppCompatActivity() { val newCallp = ua.callAlloc(call.callp, Api.VIDMODE_OFF) if (newCallp != 0L) { Log.d(TAG, "Adding outgoing call ${ua.uap}/$newCallp/$uri") - val newCall = Call(newCallp, ua, uri, "out", "transferring", - Utils.dtmfWatcher(newCallp)) + val newCall = Call(newCallp, ua, uri, "out", "transferring", null) newCall.add() if (newCall.connect(uri)) { - if (ua.account.aor != aorSpinner.tag) + if (ua.account.aor != viewModel.selectedAor.value) spinToAor(ua.account.aor) showCall(ua) } else { @@ -1941,270 +2706,131 @@ class MainActivity : AppCompatActivity() { } } - private fun makeCall(lookForContact: Boolean = true) { - callUri.setAdapter(null) - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] - val aor = ua.account.aor - if (!Call.inCall()) { - var uriText = callUri.text.toString().trim() - if (uriText.isNotEmpty()) { - if (lookForContact) { - val uris = Contact.contactUris(uriText) - if (uris.size == 1) - uriText = uris[0] - else if (uris.size > 1) { - val builder = MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) - with(builder) { - setTitle(R.string.choose_destination_uri) - setItems(uris.toTypedArray()) { _, which -> - callUri.setText(uris[which]) - makeCall(false) - } - setNeutralButton(getString(R.string.cancel)) { _: DialogInterface, _: Int -> } - show() - } - return - } - } - if (Utils.isTelNumber(uriText)) - uriText = "tel:$uriText" - val uri = if (Utils.isTelUri(uriText)) { - if (ua.account.telProvider == "") { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.no_telephony_provider), aor)) - return - } - Utils.telToSip(uriText, ua.account) - } else { - Utils.uriComplete(uriText, aor) - } - if (!Utils.checkUri(uri)) { - Utils.alertView(this, getString(R.string.notice), - String.format(getString(R.string.invalid_sip_or_tel_uri), uri)) - } else if (!BaresipService.requestAudioFocus(applicationContext)) { - Toast.makeText(applicationContext, R.string.audio_focus_denied, - Toast.LENGTH_SHORT).show() - } else { - callUri.isFocusable = false - uaAdapter.notifyDataSetChanged() - callButton.visibility = View.INVISIBLE - callButton.isEnabled = false - hangupButton.visibility = View.VISIBLE - hangupButton.isEnabled = true - if (Build.VERSION.SDK_INT < 31) { - Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") - am.mode = AudioManager.MODE_IN_COMMUNICATION - runCall(ua, uri) - } else { - if (am.mode == AudioManager.MODE_IN_COMMUNICATION) { - runCall(ua, uri) - } else { - audioModeChangedListener = AudioManager.OnModeChangedListener { mode -> - if (mode == AudioManager.MODE_IN_COMMUNICATION) { - Log.d(TAG, "Audio mode changed to MODE_IN_COMMUNICATION using " + - "device ${am.communicationDevice!!.type}") - if (audioModeChangedListener != null) { - am.removeOnModeChangedListener(audioModeChangedListener!!) - audioModeChangedListener = null - } - runCall(ua, uri) - } else { - Log.d(TAG, "Audio mode changed to mode ${am.mode} using " + - "device ${am.communicationDevice!!.type}") - } - } - am.addOnModeChangedListener(mainExecutor, audioModeChangedListener!!) - Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") - am.mode = AudioManager.MODE_IN_COMMUNICATION - } - } - } - } else { - val latestPeerUri = CallHistoryNew.aorLatestPeerUri(aor) - if (latestPeerUri != null) - callUri.setText(Utils.friendlyUri(this, latestPeerUri, ua.account)) - } - } - } - private fun runCall(ua: UserAgent, uri: String) { callRunnable = Runnable { callRunnable = null if (!call(ua, uri)) { BaresipService.abandonAudioFocus(applicationContext) - callButton.visibility = View.VISIBLE - callButton.isEnabled = true - hangupButton.visibility = View.INVISIBLE - hangupButton.isEnabled = false + showCallButton.value = true + showHangupButton.value = false } } callHandler.postDelayed(callRunnable!!, BaresipService.audioDelay) } - private fun showCall(ua: UserAgent, showCall: Call? = null) { + private fun showCall(ua: UserAgent?, showCall: Call? = null) { + if (ua == null) + return val call = showCall ?: ua.currentCall() if (call == null) { - swipeRefresh.isEnabled = true - callTitle.text = getString(R.string.outgoing_call_to_dots) - callTimer.visibility = View.INVISIBLE + pullToRefreshEnabled = true if (ua.account.resumeUri != "") - callUri.setText(ua.account.resumeUri) + callUri.value = ua.account.resumeUri else - callUri.text.clear() - callUri.hint = getString(R.string.callee) - callUri.isFocusable = true - callUri.isFocusableInTouchMode = true - imm.hideSoftInputFromWindow(callUri.windowToken, 0) - callUri.setAdapter(ArrayAdapter(this, android.R.layout.select_dialog_item, - Contact.contactNames())) - securityButton.visibility = View.INVISIBLE - diverter.visibility = View.GONE - callButton.visibility = View.VISIBLE - callButton.isEnabled = true - hangupButton.visibility = View.INVISIBLE - answerButton.visibility = View.INVISIBLE - rejectButton.visibility = View.INVISIBLE - callControl.visibility = View.INVISIBLE - dialpadButton.isEnabled = true + callUri.value = "" + callUriLabel.value = getString(R.string.outgoing_call_to_dots) + callUriEnabled.value = true + keyboardController?.hide() + showCallTimer.value = false + securityIcon.intValue = -1 + showHangupButton.value = false + transferIcon.intValue = R.drawable.call_transfer + dtmfEnabled.value = false + focusDtmf.value = false + showCallButton.value = true + showAnswerRejectButtons.value = false + showOnHoldNotice.value = false + dialpadButtonEnabled = true if (BaresipService.isMicMuted) { BaresipService.isMicMuted = false - micIcon!!.setIcon(R.drawable.mic_on) + micIcon = R.drawable.mic_on } - onHoldNotice.visibility = View.GONE } else { - swipeRefresh.isEnabled = false - callUri.isFocusable = false + pullToRefreshEnabled = false + callUriEnabled.value = false when (call.status) { "outgoing", "transferring", "answered" -> { - callTitle.text = if (call.status == "answered") + callUriLabel.value = if (call.status == "answered") getString(R.string.incoming_call_from_dots) else getString(R.string.outgoing_call_to_dots) - callTimer.visibility = View.INVISIBLE - callUri.setText(Utils.friendlyUri(this, call.peerUri, ua.account)) - securityButton.visibility = View.INVISIBLE - diverter.visibility = View.GONE - callButton.visibility = View.INVISIBLE - hangupButton.visibility = View.VISIBLE - hangupButton.isEnabled = true - answerButton.visibility = View.INVISIBLE - rejectButton.visibility = View.INVISIBLE - callControl.visibility = View.INVISIBLE - onHoldNotice.visibility = View.GONE - dialpadButton.isEnabled = false + callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) + showCallTimer.value = false + securityIcon.intValue = -1 + showCallButton.value = false + showHangupButton.value = true + showAnswerRejectButtons.value = false + showOnHoldNotice.value = false + dialpadButtonEnabled = false } "incoming" -> { - callTitle.text = getString(R.string.incoming_call_from_dots) - callTimer.visibility = View.INVISIBLE - callUri.setText(Utils.friendlyUri(this, call.peerUri, ua.account)) - callUri.setAdapter(null) - securityButton.visibility = View.INVISIBLE + showCallTimer.value = false + securityIcon.intValue = -1 val uri = call.diverterUri() if (uri != "") { - diverterUri.text = Utils.friendlyUri(this, uri, ua.account) - diverter.visibility = View.VISIBLE - } else { - diverter.visibility = View.GONE + callUriLabel.value = getString(R.string.diverted_by_dots) + callUri.value = Utils.friendlyUri(this, uri, ua.account) } - callButton.visibility = View.INVISIBLE - hangupButton.visibility = View.INVISIBLE - answerButton.visibility = View.VISIBLE - answerButton.isEnabled = true - rejectButton.visibility = View.VISIBLE - rejectButton.isEnabled = true - callControl.visibility = View.INVISIBLE - onHoldNotice.visibility = View.GONE - dialpadButton.isEnabled = false + else { + callUriLabel.value = getString(R.string.incoming_call_from_dots) + callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) + } + showCallButton.value = false + showHangupButton.value = false + showAnswerRejectButtons.value = true + showOnHoldNotice.value = false + dialpadButtonEnabled = false } "connected" -> { - callControl.post { - callControl.scrollTo(holdButton.left, holdButton.top) - } if (call.referTo != "") { - callTitle.text = getString(R.string.transferring_call_to_dots) - callUri.setText(Utils.friendlyUri(this, call.referTo, ua.account)) - transferButton.isEnabled = false + callUriLabel.value = getString(R.string.outgoing_call_to_dots) + callUri.value = Utils.friendlyUri(this, call.referTo, ua.account) + transferButtonEnabled.value = false } else { if (call.dir == "out") { - callTitle.text = getString(R.string.outgoing_call_to_dots) - callUri.setText(Utils.friendlyUri(this, call.peerUri, ua.account)) + callUriLabel.value = getString(R.string.outgoing_call_to_dots) + callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) } else { - callTitle.text = getString(R.string.incoming_call_from_dots) - callUri.setText(Utils.friendlyUri(this, call.peerUri, ua.account)) + callUriLabel.value = getString(R.string.incoming_call_from_dots) + callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) } - transferButton.isEnabled = true + transferButtonEnabled.value = true } - if (call.onHoldCall == null) - transferButton.setImageResource(R.drawable.call_transfer) + transferIcon.intValue = if (call.onHoldCall == null) + R.drawable.call_transfer else - transferButton.setImageResource(R.drawable.call_transfer_execute) + R.drawable.call_transfer_execute + showCallTimer.value = true startCallTimer(call) - callTimer.visibility = View.VISIBLE - if (ua.account.mediaEnc == "") { - securityButton.visibility = View.INVISIBLE - } else { - securityButton.tag = call.security - securityButton.setImageResource(call.security) - securityButton.visibility = View.VISIBLE - } - callButton.visibility = View.INVISIBLE - hangupButton.visibility = View.VISIBLE - hangupButton.isEnabled = true - answerButton.visibility = View.INVISIBLE - rejectButton.visibility = View.INVISIBLE - if (call.onhold) { - holdButton.setImageResource(R.drawable.resume) - } else { - holdButton.setImageResource(R.drawable.call_hold) - } - callUri.inputType = InputType.TYPE_CLASS_PHONE - dialpadButton.setImageResource(R.drawable.dialpad_on) - dialpadButton.tag = "on" - dialpadButton.isEnabled = false - infoButton.isEnabled = true - callControl.visibility = View.VISIBLE + if (ua.account.mediaEnc == "") + securityIcon.intValue = -1 + else + securityIcon.intValue = call.security + showCallButton.value = false + showHangupButton.value = true + showAnswerRejectButtons.value = false + if (call.onhold) + holdIcon.intValue = R.drawable.resume + else + holdIcon.intValue = R.drawable.call_hold Handler(Looper.getMainLooper()).postDelayed({ - onHoldNotice.visibility = if (call.held) View.VISIBLE else View.GONE + showOnHoldNotice.value = call.held }, 100) if (call.held) { - imm.hideSoftInputFromWindow(dtmf.windowToken, 0) - dtmf.isEnabled = false + keyboardController?.hide() + dtmfEnabled.value = false + focusDtmf.value = false } else { - dtmf.isEnabled = true - dtmf.requestFocus() + dtmfEnabled.value = true + focusDtmf.value = true if (resources.configuration.orientation == ORIENTATION_PORTRAIT) - imm.showSoftInput(dtmf, InputMethodManager.SHOW_IMPLICIT) - if (dtmfWatcher != null) dtmf.removeTextChangedListener(dtmfWatcher) - dtmfWatcher = call.dtmfWatcher - dtmf.addTextChangedListener(dtmfWatcher) + keyboardController?.show() } } } } } - private fun updateIcons(acc: Account) { - if (acc.missedCalls) - callsButton.setImageResource(R.drawable.calls_missed) - else - callsButton.setImageResource(R.drawable.calls) - if (acc.unreadMessages) - messagesButton.setImageResource(R.drawable.messages_unread) - else - messagesButton.setImageResource(R.drawable.messages) - if (acc.vmUri != "") { - if (acc.vmNew > 0) - voicemailButton.setImageResource(R.drawable.voicemail_new) - else - voicemailButton.setImageResource(R.drawable.voicemail) - voicemailButton.visibility = View.VISIBLE - voicemailButtonSpace.visibility = View.VISIBLE - } else { - voicemailButton.visibility = View.GONE - voicemailButtonSpace.visibility =View.GONE - } - } - private fun restoreActivities() { if (BaresipService.activities.isEmpty()) return Log.d(TAG, "Activity stack ${BaresipService.activities}") @@ -2252,10 +2878,9 @@ class MainActivity : AppCompatActivity() { b.putString("aor", activity[1]) i.putExtras(b) contactsRequest.launch(i) - } "contact" -> { - val i = Intent(this, ContactActivity::class.java) + val i = Intent(this, BaresipContactActivity::class.java) val b = Bundle() if (activity[1] == "true") { b.putBoolean("new", true) @@ -2304,26 +2929,25 @@ class MainActivity : AppCompatActivity() { } private fun saveCallUri() { - if (BaresipService.uas.isNotEmpty() && aorSpinner.selectedItemPosition >= 0) { - val ua = BaresipService.uas[aorSpinner.selectedItemPosition] + if (uas.value.isNotEmpty() && viewModel.selectedAor.value != "") { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! if (ua.calls().isEmpty()) - ua.account.resumeUri = callUri.text.toString() + ua.account.resumeUri = callUri.value else ua.account.resumeUri = "" } } private fun startCallTimer(call: Call) { - callTimer.stop() - callTimer.base = SystemClock.elapsedRealtime() - (call.duration() * 1000L) - callTimer.start() + Handler(Looper.getMainLooper()).postDelayed({ + callTimer?.stop() + callTimer?.base = SystemClock.elapsedRealtime() - (call.duration() * 1000L) + callTimer?.start() + }, 100) } companion object { - - var accountRequest: ActivityResultLauncher? = null var activityAor = "" - } init { diff --git a/app/src/main/kotlin/com/tutpro/baresip/Message.kt b/app/src/main/kotlin/com/tutpro/baresip/Message.kt index 7d74c299..69284ac2 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Message.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Message.kt @@ -8,32 +8,76 @@ class Message(val aor: String, val peerUri: String, val message: String, val tim var new: Boolean): Serializable { fun add() { - BaresipService.messages.add(this) + val updatedMessages = BaresipService.messages.toMutableList() + updatedMessages.add(this) + BaresipService.messages = updatedMessages.toList() var count = 0 - var firstIndex = -1 - for (i in BaresipService.messages.indices) - if (BaresipService.messages[i].aor == this.aor) { - if (count == 0) firstIndex = i + var remove: Message? = null + for (message in BaresipService.messages) + if (message.aor == this.aor) { count++ if (count > MESSAGE_HISTORY_SIZE) { + remove = message break } } - if (count > MESSAGE_HISTORY_SIZE) - BaresipService.messages.removeAt(firstIndex) + if (remove != null) + updatedMessages.remove(remove) + BaresipService.messages = updatedMessages.toList() + save() + } + + fun delete() { + val updatedMessages = BaresipService.messages.toMutableList() + updatedMessages.remove(this) + BaresipService.messages = updatedMessages.toList() + save() } companion object { const val MESSAGE_HISTORY_SIZE = 100 - fun messages(): ArrayList { + fun messages(): List { return BaresipService.messages } - fun clear(aor: String) { - val it = BaresipService.messages.iterator() + fun clearMessagesOfAor(aor: String) { + val updatedMessages = BaresipService.messages.toMutableList() + val it = updatedMessages.iterator() while (it.hasNext()) if (it.next().aor == aor) it.remove() + BaresipService.messages = updatedMessages.toList() + } + + fun deleteAorMessage(aor: String, time: Long) { + val updatedMessages = BaresipService.messages.toMutableList() + for (message in updatedMessages) + if (message.aor == aor && message.timeStamp == time) { + updatedMessages.remove(message) + BaresipService.messages = updatedMessages.toList() + save() + return + } + } + + fun deleteAorPeerMessages(aor: String, peerUri: String) { + val updatedMessages = BaresipService.messages.toMutableList() + for (message in updatedMessages) + if (message.aor == aor && message.peerUri == peerUri) + updatedMessages.remove(message) + BaresipService.messages = updatedMessages.toList() + save() + } + + fun updateAorMessage(aor: String, time: Long) { + val updatedMessages = BaresipService.messages.toMutableList() + for (message in updatedMessages) + if (message.aor == aor && message.timeStamp == time) { + message.new = false + BaresipService.messages = updatedMessages.toList() + save() + return + } } fun save() { diff --git a/app/src/main/kotlin/com/tutpro/baresip/MessageListAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/MessageListAdapter.kt deleted file mode 100644 index ac16183d..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/MessageListAdapter.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.content.DialogInterface -import android.content.Intent -import android.graphics.Typeface -import android.os.Bundle -import android.text.format.DateUtils.isToday -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.LinearLayout -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.text.DateFormat -import java.util.* - -class MessageListAdapter(private val ctx: Context, private val peerUri: String, - private val chatPeer: String, private val rows: ArrayList) : - ArrayAdapter(ctx, R.layout.message, rows) { - - private val layoutInflater = LayoutInflater.from(context) - - private class ViewHolder(view: View?) { - val layoutView: LinearLayout = view?.findViewById(R.id.message)!! - val infoView: TextView = view?.findViewById(R.id.info)!! - val textView: TextView = view?.findViewById(R.id.text)!! - } - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.message, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val listener = View.OnClickListener { - val dialogClickListener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - val i = Intent(ctx, ContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", true) - b.putString("uri", peerUri) - i.putExtras(b) - ctx.startActivity(i) - } - DialogInterface.BUTTON_NEGATIVE -> { - Message.messages().remove(rows[position]) - Message.save() - rows.removeAt(position) - this.notifyDataSetChanged() - //if (rows.size == 0) { - // listView.removeFooterView(footerView) - //} - } - DialogInterface.BUTTON_NEUTRAL -> { - } - } - } - val builder = MaterialAlertDialogBuilder(ctx, R.style.AlertDialogTheme) - if (chatPeer == peerUri) - with (builder) { - setTitle(R.string.confirmation) - setMessage(String.format(ctx.getString(R.string.long_message_question), - peerUri)) - setNeutralButton(ctx.getString(R.string.cancel), dialogClickListener) - setNegativeButton(ctx.getString(R.string.delete), dialogClickListener) - setPositiveButton(ctx.getString(R.string.add_contact), dialogClickListener) - show() - } - else - with (builder) { - setTitle(R.string.confirmation) - setMessage(ctx.getText(R.string.short_message_question)) - setNeutralButton(ctx.getString(R.string.cancel), dialogClickListener) - setNegativeButton(ctx.getString(R.string.delete), dialogClickListener) - show() - } - } - - viewHolder.layoutView.setOnClickListener(listener) - viewHolder.infoView.setOnClickListener(listener) - viewHolder.textView.setOnClickListener(listener) - - val message = rows[position] - - val down = message.direction == MESSAGE_DOWN - val lp = viewHolder.layoutView.layoutParams as LinearLayout.LayoutParams - val peer: String = if (down) { - lp.setMargins(0, 10, 75, 10) - viewHolder.layoutView.setBackgroundResource(R.drawable.message_in_bg) - if (chatPeer.startsWith("sip:") && - (Utils.uriHostPart(message.peerUri) == Utils.uriHostPart(message.aor))) - Utils.uriUserPart(message.peerUri) - else - chatPeer - } else { - lp.setMargins(75, 10, 0, 10) - viewHolder.layoutView.setBackgroundResource(R.drawable.message_out_bg) - ctx.getString(R.string.you) - } - - viewHolder.layoutView.layoutParams = lp - - var info: String - val cal = GregorianCalendar() - cal.timeInMillis = message.timeStamp - val fmt: DateFormat = if (isToday(message.timeStamp)) - DateFormat.getTimeInstance(DateFormat.SHORT) - else - DateFormat.getDateInstance(DateFormat.SHORT) - info = fmt.format(cal.time) - if (info.length < 6) info = "${ctx.getString(R.string.today)} $info" - info = "$info - $peer" - if (message.direction == MESSAGE_UP_FAIL) { - info = if (message.responseCode != 0) - "$info - ${ctx.getString(R.string.message_failed)}: " + "${message.responseCode} ${message.responseReason}" - else - "$info - ${ctx.getString(R.string.sending_failed)}" - viewHolder.infoView.setTextColor(ContextCompat.getColor(ctx, R.color.colorAccent)) - } - viewHolder.infoView.text = info - - viewHolder.textView.text = message.message - if (message.new) - viewHolder.textView.setTypeface(null, Typeface.BOLD) - - return rowView - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/OnSwipeTouchListener.kt b/app/src/main/kotlin/com/tutpro/baresip/OnSwipeTouchListener.kt deleted file mode 100644 index e8ddb2e0..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/OnSwipeTouchListener.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.tutpro.baresip - -import android.annotation.SuppressLint -import android.content.Context -import android.view.GestureDetector -import android.view.MotionEvent -import android.view.View -import kotlin.math.abs - -open class OnSwipeTouchListener(ctx: Context) : View.OnTouchListener { - - private val gestureDetector: GestureDetector - - companion object { - private const val SWIPE_THRESHOLD = 100 - private const val SWIPE_VELOCITY_THRESHOLD = 100 - } - - init { - gestureDetector = GestureDetector(ctx, GestureListener()) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(view: View?, event: MotionEvent): Boolean { - return try { - gestureDetector.onTouchEvent(event) - } catch (e: Exception) { - Log.e(TAG, "onTouch exception $e") - e.printStackTrace() - false - } - } - - private inner class GestureListener : GestureDetector.SimpleOnGestureListener() { - - override fun onDown(event: MotionEvent): Boolean { - return true - } - - override fun onFling(e1: MotionEvent?, e2: MotionEvent, velX: Float, velY: Float): Boolean { - var result = false - try { - val diffY = e2.y - e1!!.y - val diffX = e2.x - e1.x - if (abs(diffX) > abs(diffY)) { - if (abs(diffX) > SWIPE_THRESHOLD && abs(velX) > SWIPE_VELOCITY_THRESHOLD) { - if (diffX > 0) { - onSwipeRight() - } else { - onSwipeLeft() - } - result = true - } - } else if (abs(diffY) > SWIPE_THRESHOLD && abs(velY) > SWIPE_VELOCITY_THRESHOLD) { - result = if (diffY > 0) { - onSwipeBottom() - false - } else { - onSwipeTop() - true - } - } - } catch (e: Exception) { - Log.e(TAG, "onFling exception $e") - // e.printStackTrace() - } - return result - } - - } - - open fun onSwipeRight() {} - - open fun onSwipeLeft() {} - - open fun onSwipeTop() {} - - open fun onSwipeBottom() {} -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/UaSpinnerAdapter.kt b/app/src/main/kotlin/com/tutpro/baresip/UaSpinnerAdapter.kt deleted file mode 100644 index 3f245d21..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/UaSpinnerAdapter.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.graphics.Typeface -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.TextView - -class UaSpinnerAdapter(cxt: Context, private val uas: ArrayList) : - ArrayAdapter(cxt, android.R.layout.simple_spinner_item, uas) { - - private val layoutInflater = LayoutInflater.from(context) - - private class ViewHolder(view: View?) { - val textView: TextView = view?.findViewById(R.id.spinnerText)!! - val imageView: ImageView = view?.findViewById(R.id.spinnerImage)!! - } - - override fun getDropDownView(position: Int, view: View?, parent: ViewGroup): View { - return getImageForPosition(position, view, parent) - } - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - return getImageForPosition(position, view, parent) - } - - private fun getImageForPosition(position: Int, view: View?, parent: ViewGroup): View { - - val viewHolder: ViewHolder - val rowView: View - - if (view == null) { - rowView = layoutInflater.inflate(R.layout.account_spinner, parent, false) - viewHolder = ViewHolder(rowView) - rowView.tag = viewHolder - } else { - rowView = view - viewHolder = rowView.tag as ViewHolder - } - - val ua = uas[position] - viewHolder.textView.text = if (ua.account.nickName != "") - ua.account.nickName - else - ua.account.aor.split(":")[1] - viewHolder.textView.textSize = 17f - if (BaresipService.uas.size > 1 && ua.calls().isNotEmpty()) - viewHolder.textView.setTypeface(null, Typeface.BOLD) - viewHolder.imageView.setImageResource(ua.status) - - return rowView - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt b/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt index e41e8e70..d4e731d5 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt @@ -1,5 +1,8 @@ package com.tutpro.baresip +import com.tutpro.baresip.BaresipService.Companion.uas +import com.tutpro.baresip.BaresipService.Companion.uasStatus + class UserAgent(val uap: Long) { val account = Account(Api.ua_account(uap)) @@ -10,11 +13,22 @@ class UserAgent(val uap: Long) { } fun add() { - BaresipService.uas.add(this) + val updatedUas = uas.value.toMutableList() + updatedUas.add(this) + uas.value = updatedUas.toList() + uasStatus.value = statusMap() } fun remove() { - BaresipService.uas.remove(this) + val updatedUas = uas.value.toMutableList() + updatedUas.remove(this) + uas.value = updatedUas.toList() + uasStatus.value = statusMap() + } + + fun uaUpdateStatus(status: Int) { + uas.value.find { it.uap == this.uap }?.status = status + uasStatus.value = statusMap() } fun calls(dir: String = ""): ArrayList { @@ -34,19 +48,19 @@ class UserAgent(val uap: Long) { companion object { fun ofAor(aor: String): UserAgent? { - for (ua in BaresipService.uas) + for (ua in uas.value) if (ua.account.aor == aor) return ua return null } fun ofDomain(domain: String): UserAgent? { - for (ua in BaresipService.uas) + for (ua in uas.value) if (Utils.aorDomain(ua.account.aor) == domain) return ua return null } fun ofUap(uap: Long): UserAgent? { - for (ua in BaresipService.uas) + for (ua in uas.value) if (ua.uap == uap) return ua return null } @@ -59,20 +73,27 @@ class UserAgent(val uap: Long) { } fun findAorIndex(aor: String): Int? { - for (i in BaresipService.uas.indices) { - if (BaresipService.uas[i].account.aor == aor) return i + for (i in uas.value.indices) { + if (uas.value[i].account.aor == aor) return i } return null } fun register() { - for (ua in BaresipService.uas) { + for (ua in uas.value) { if (ua.account.regint > 0) { if (Api.ua_register(ua.uap) != 0) Log.d(TAG, "Failed to register ${ua.account.aor}") } } } + + fun statusMap(): Map { + val result = emptyMap().toMutableMap() + for (ua in uas.value) + result[ua.account.aor] = ua.status + return result + } } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 1637381d..600f2f46 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -7,44 +7,60 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Bitmap -import android.graphics.Bitmap.createScaledBitmap import android.graphics.BitmapFactory import android.graphics.Canvas -import android.graphics.Color import android.media.AudioAttributes -import android.net.Uri -import android.net.wifi.WifiManager -import android.provider.DocumentsContract -import android.provider.MediaStore -import android.provider.OpenableColumns -import android.text.Editable -import android.text.TextWatcher -import android.text.format.DateUtils -import android.view.View -import android.widget.ImageView -import android.widget.TextView import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.MediaPlayer import android.media.audiofx.AcousticEchoCanceler -import android.os.* +import android.media.audiofx.AutomaticGainControl +import android.net.Uri +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.text.format.DateUtils +import android.view.View +import android.widget.ImageView +import android.widget.TextView import androidx.activity.result.ActivityResultLauncher import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.core.text.isDigitsOnly import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import java.io.* +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable import java.lang.reflect.Method import java.net.InetAddress import java.net.NetworkInterface import java.net.SocketException import java.security.SecureRandom import java.text.DateFormat -import java.util.* +import java.util.Calendar +import java.util.Enumeration +import java.util.GregorianCalendar +import java.util.Locale +import java.util.Random import java.util.concurrent.Executor import java.util.zip.ZipEntry import java.util.zip.ZipFile @@ -54,6 +70,7 @@ import javax.crypto.SecretKeyFactory import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec +import androidx.core.graphics.scale object Utils { @@ -161,7 +178,7 @@ object Utils { return u } - fun e164Uri(uri: String, countryCode: String): String { + private fun e164Uri(uri: String, countryCode: String): String { if (countryCode == "") return uri val scheme = uri.substring(0, 4) val userPart = uriUserPart(uri) @@ -488,30 +505,6 @@ object Utils { return result } - fun dtmfWatcher(callp: Long): TextWatcher { - return object : TextWatcher { - override fun beforeTextChanged(sequence: CharSequence, start: Int, count: Int, after: Int) {} - override fun onTextChanged(sequence: CharSequence, start: Int, before: Int, count: Int) { - val text = sequence.subSequence(start, start + count).toString() - if (text.isNotEmpty()) { - val digit = text[0] - val call = Call.ofCallp(callp) - if (call == null) { - Log.w(TAG, "dtmfWatcher did not find call $callp") - } else { - Log.d(TAG, "Got DTMF digit '$digit'") - if (((digit >= '0') && (digit <= '9')) || (digit == '*') || (digit == '#')) - call.sendDigit(digit) - } - } - } - override fun afterTextChanged(sequence: Editable) { - // KEYCODE_REL - // call_send_digit(callp, 4.toChar()) - } - } - } - fun checkPermissions(ctx: Context, permissions: Array) : Boolean { for (p in permissions) { if (ContextCompat.checkSelfPermission(ctx, p) != PackageManager.PERMISSION_GRANTED) { @@ -524,23 +517,6 @@ object Utils { return true } - fun View.showSnackBar( - view: View, - msg: String, - length: Int, - actionMessage: CharSequence?, - action: (View) -> Unit - ) { - val snackBar = Snackbar.make(view, msg, length) - if (actionMessage != null) { - snackBar.setAction(actionMessage) { - action(this) - }.show() - } else { - snackBar.show() - } - } - fun copyAssetToFile(context: Context, asset: String, path: String) { try { val `is` = context.assets.open(asset) @@ -649,7 +625,7 @@ object Utils { if (file.exists()) file.delete() try { val out = FileOutputStream(file) - val scaledBitmap = createScaledBitmap(bitmap, 96, 96, true) + val scaledBitmap = bitmap.scale(96, 96) scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) out.flush() out.close() @@ -663,7 +639,6 @@ object Utils { class Crypto(val salt: ByteArray, val iter: Int, val iv: ByteArray, val data: ByteArray): Serializable { companion object { - @Suppress("ConstPropertyName") private const val serialVersionUID: Long = -29238082928391L } } @@ -862,7 +837,7 @@ object Utils { fun randomColor(): Int { val rnd = Random() - return Color.argb(255, rnd.nextInt(256), rnd.nextInt(256), + return android.graphics.Color.argb(255, rnd.nextInt(256), rnd.nextInt(256), rnd.nextInt(256)) } @@ -877,6 +852,19 @@ object Utils { } + fun isThemeDark(ctx: Context) : Boolean { + return Preferences(ctx).displayTheme == AppCompatDelegate.MODE_NIGHT_YES || + ctx.resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + } + + @Composable + fun isNightMode() = when (AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.MODE_NIGHT_NO -> false + AppCompatDelegate.MODE_NIGHT_YES -> true + else -> isSystemInDarkTheme() + } + fun relativeTime(ctx: Context, time: GregorianCalendar): String { return if (DateUtils.isToday(time.timeInMillis)) { val fmt = DateFormat.getTimeInstance(DateFormat.SHORT) @@ -894,8 +882,8 @@ object Utils { } } - fun bitmapFromView(view: View): Bitmap { - val bitmap = Bitmap.createBitmap(view.layoutParams.width, view.layoutParams.height, Bitmap.Config.ARGB_8888) + private fun bitmapFromView(view: View): Bitmap { + val bitmap = createBitmap(view.layoutParams.width, view.layoutParams.height) val canvas = Canvas(bitmap) view.layout(0, 0, view.layoutParams.width, view.layoutParams.height) view.draw(canvas) @@ -1044,32 +1032,41 @@ object Utils { } } - fun isDarkTheme(ctx: Context): Boolean { - return ctx.resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - } + fun aecAgcCheck() { - fun isAecSupported(): Boolean { - if (!AcousticEchoCanceler.isAvailable()) { - Log.i(TAG, "Hardware AEC is NOT available") - return false - } - - val sessionId = Api.create_AAudio_SessionId() + val sessionId = Api.AAudio_open_stream() if (sessionId == -1) { - Log.e(TAG, "Failed to create AAudio stream for session ID") - return false + Log.e(TAG, "Failed to open AAudio stream") + return } - val aec = AcousticEchoCanceler.create(sessionId) - return if (aec != null) { - aec.release() - Log.i(TAG, "Hardware AEC is supported") - true - } else { - Log.w(TAG, "Hardware AEC is NOT supported") - false + if (AcousticEchoCanceler.isAvailable()) { + val aec = AcousticEchoCanceler.create(sessionId) + if (aec != null) { + BaresipService.aecAvailable = true + aec.release() + Log.d(TAG, "Creation of hardware AEC for $sessionId succeeded") + } else { + Log.w(TAG, "Creation of hardware AEC for $sessionId failed") + } } + else + Log.i(TAG, "Hardware AEC is NOT available") + + if (AutomaticGainControl.isAvailable()) { + val agc = AcousticEchoCanceler.create(sessionId) + if (agc != null) { + BaresipService.agcAvailable = true + agc.release() + Log.d(TAG, "Creation of hardware AGC for $sessionId succeeded") + } else { + Log.w(TAG, "Creation of hardware AGC for $sessionId failed") + } + } + else + Log.i(TAG, "Hardware AGC is NOT available") + + Api.AAudio_close_stream() + } - } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt b/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt new file mode 100644 index 00000000..1fa879d2 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt @@ -0,0 +1,17 @@ +package com.tutpro.baresip + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ViewModel(application: Application) : AndroidViewModel(application) { + + private val _selectedAor = MutableStateFlow("") + val selectedAor: StateFlow = _selectedAor.asStateFlow() + + fun updateSelectedAor(newValue: String) { + _selectedAor.value = newValue + } +} diff --git a/app/src/main/res/drawable-night/message_in_bg.xml b/app/src/main/res/drawable-night/message_in_bg.xml deleted file mode 100644 index 41c222c5..00000000 --- a/app/src/main/res/drawable-night/message_in_bg.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable-night/message_out_bg.xml b/app/src/main/res/drawable-night/message_out_bg.xml deleted file mode 100644 index ca170a1f..00000000 --- a/app/src/main/res/drawable-night/message_out_bg.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable-night/spinner_bg.xml b/app/src/main/res/drawable-night/spinner_bg.xml deleted file mode 100644 index 3bbad565..00000000 --- a/app/src/main/res/drawable-night/spinner_bg.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow_drop_down.xml b/app/src/main/res/drawable/arrow_drop_down.xml new file mode 100644 index 00000000..b7f553c2 --- /dev/null +++ b/app/src/main/res/drawable/arrow_drop_down.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/call_hold.xml b/app/src/main/res/drawable/call_hold.xml index 9844ec93..3d5e045d 100644 --- a/app/src/main/res/drawable/call_hold.xml +++ b/app/src/main/res/drawable/call_hold.xml @@ -4,6 +4,6 @@ android:viewportWidth="960" android:viewportHeight="960" > diff --git a/app/src/main/res/drawable/call_transfer.xml b/app/src/main/res/drawable/call_transfer.xml index 51cf2ad4..df3d8eee 100644 --- a/app/src/main/res/drawable/call_transfer.xml +++ b/app/src/main/res/drawable/call_transfer.xml @@ -4,6 +4,6 @@ android:viewportWidth="960" android:viewportHeight="960" > diff --git a/app/src/main/res/drawable/calls_missed.xml b/app/src/main/res/drawable/calls_missed.xml index 9fe57e63..77ab4138 100644 --- a/app/src/main/res/drawable/calls_missed.xml +++ b/app/src/main/res/drawable/calls_missed.xml @@ -1,6 +1,5 @@ - + diff --git a/app/src/main/res/drawable/visibility.xml b/app/src/main/res/drawable/visibility.xml new file mode 100644 index 00000000..f843e291 --- /dev/null +++ b/app/src/main/res/drawable/visibility.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/visibility_off.xml b/app/src/main/res/drawable/visibility_off.xml new file mode 100644 index 00000000..5993ca39 --- /dev/null +++ b/app/src/main/res/drawable/visibility_off.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/account_row.xml b/app/src/main/res/layout/account_row.xml deleted file mode 100644 index 2a3a70c3..00000000 --- a/app/src/main/res/layout/account_row.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/account_spinner.xml b/app/src/main/res/layout/account_spinner.xml deleted file mode 100644 index 0e7efd93..00000000 --- a/app/src/main/res/layout/account_spinner.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml deleted file mode 100644 index 4b512e89..00000000 --- a/app/src/main/res/layout/activity_about.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml deleted file mode 100644 index 70a39ac3..00000000 --- a/app/src/main/res/layout/activity_account.xml +++ /dev/null @@ -1,510 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_accounts.xml b/app/src/main/res/layout/activity_accounts.xml deleted file mode 100644 index a58ae25d..00000000 --- a/app/src/main/res/layout/activity_accounts.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_android_contact.xml b/app/src/main/res/layout/activity_android_contact.xml deleted file mode 100644 index 97ff7968..00000000 --- a/app/src/main/res/layout/activity_android_contact.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_audio.xml b/app/src/main/res/layout/activity_audio.xml deleted file mode 100644 index 3c39878b..00000000 --- a/app/src/main/res/layout/activity_audio.xml +++ /dev/null @@ -1,264 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_call_details.xml b/app/src/main/res/layout/activity_call_details.xml deleted file mode 100644 index 8a60fdee..00000000 --- a/app/src/main/res/layout/activity_call_details.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_calls.xml b/app/src/main/res/layout/activity_calls.xml deleted file mode 100644 index bf0a9716..00000000 --- a/app/src/main/res/layout/activity_calls.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml deleted file mode 100644 index c3280719..00000000 --- a/app/src/main/res/layout/activity_chat.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_chats.xml b/app/src/main/res/layout/activity_chats.xml deleted file mode 100644 index 34309feb..00000000 --- a/app/src/main/res/layout/activity_chats.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_codecs.xml b/app/src/main/res/layout/activity_codecs.xml deleted file mode 100644 index 2db15744..00000000 --- a/app/src/main/res/layout/activity_codecs.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/activity_config.xml b/app/src/main/res/layout/activity_config.xml deleted file mode 100644 index 9445fbbe..00000000 --- a/app/src/main/res/layout/activity_config.xml +++ /dev/null @@ -1,384 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_contact.xml b/app/src/main/res/layout/activity_contact.xml deleted file mode 100644 index 85b309da..00000000 --- a/app/src/main/res/layout/activity_contact.xml +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_contacts.xml b/app/src/main/res/layout/activity_contacts.xml deleted file mode 100644 index 1bd03da8..00000000 --- a/app/src/main/res/layout/activity_contacts.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 16743a1c..00000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,403 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/alert_title.xml b/app/src/main/res/layout/alert_title.xml deleted file mode 100644 index 3ff963ab..00000000 --- a/app/src/main/res/layout/alert_title.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/layout/android_uri_row.xml b/app/src/main/res/layout/android_uri_row.xml deleted file mode 100644 index a5da582d..00000000 --- a/app/src/main/res/layout/android_uri_row.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/call_detail_row.xml b/app/src/main/res/layout/call_detail_row.xml deleted file mode 100644 index fc2e876c..00000000 --- a/app/src/main/res/layout/call_detail_row.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/call_row.xml b/app/src/main/res/layout/call_row.xml deleted file mode 100644 index ec473a65..00000000 --- a/app/src/main/res/layout/call_row.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/call_transfer_dialog.xml b/app/src/main/res/layout/call_transfer_dialog.xml deleted file mode 100644 index 133aa939..00000000 --- a/app/src/main/res/layout/call_transfer_dialog.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/chat_row.xml b/app/src/main/res/layout/chat_row.xml deleted file mode 100644 index ea34e7d0..00000000 --- a/app/src/main/res/layout/chat_row.xml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/codec.xml b/app/src/main/res/layout/codec.xml deleted file mode 100644 index 6f0db4e8..00000000 --- a/app/src/main/res/layout/codec.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/contact_row.xml b/app/src/main/res/layout/contact_row.xml deleted file mode 100644 index 7e4ef8c0..00000000 --- a/app/src/main/res/layout/contact_row.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/message.xml b/app/src/main/res/layout/message.xml deleted file mode 100644 index 513805e8..00000000 --- a/app/src/main/res/layout/message.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 0dc0f901..8b146e36 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -131,7 +131,11 @@ Стартирайте автоматично Ако е отметнато, baresip се стартира автоматично след включване на устройството. Слушане на адрес - IP адрес и порт на формата \'адрес: порт\', на който baresip слуша за входящи SIP заявки. Ако IP адресът е IPv6 адрес, той трябва да бъде написан вътре в скоби []. IPv4 адрес 0.0.0.0 или IPv6 адрес [::] позволява на baresip да слуша всички налични адреси. Ако се остави празно (фабрично по подразбиране), baresip ще слуша на порт 5060 от всички налични адреси. + IP адрес и порт на формата \'адрес: порт\', на който baresip слуша + за входящи SIP заявки. Ако IP адресът е IPv6 адрес, той трябва да бъде написан вътре в скоби []. + IPv4 адрес 0.0.0.0 или IPv6 адрес [::] позволява на baresip да слуша всички налични адреси. + Ако се остави празно (фабрично по подразбиране), baresip ще слуша на порт 5060 от всички налични адреси. + Невалиден адрес за слушане DNS сървъри Списък разделен със запетая на адреси на DNS сървъри. Ако не е зададено, @@ -147,7 +151,6 @@ TLS CA File Ако е поставено отметка, файл \'ca_certs.crt\' съдържащи TLS сертификати на Сертифициращите органи е бил или ще бъде зареден от директорията за Изтегляне. Акустично отменяне на ехото - Ако е поставена отметка, се прави опит за отмяна на ехо при аудио повикване. Opus Bit Rate Средна максимална битова скорост, използвана от аудио поток на Opus. Валидните стойности са 6000-510000. Фабрично по подразбиране е 28000. @@ -254,4 +257,4 @@ Разговори не могат да бъдат осъществени без разрешен достъп до микрофона. Порта и транспортния протокол на акаунта могат да бъдат посочени по избор, когато се създаде нов акаунт: потребителско име@домейн[:port] [;transport=udp|tcp|tls]. Ако е даден порт и транспортният протокол не е даден, транспортният протокол по подразбиране е udp. Ако порт не е даден и транспортният протокол е даден, портът по подразбиране е 5060 или 5061 (tls). Ако нито едното не е посочено и не е посочен изходящ прокси, регистраторът на акаунта (ако има такъв) се определя единствено въз основа на DNS информация от домейна. Нов Акаунт - + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 86de18e0..55c0dd4e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -237,7 +237,6 @@ volání Pokud je zaškrtnuto, byl nebo bude načten soubor obsahující certifikáty TLS takových certifikačních autorit, které nejsou součástí operačního systému Android. Ve verzi systému Android 9 se ze složky Download načte soubor s názvem \'ca_certs.crt\'. Nastavení zvuku - Pokud je zaškrtnuto, používá se rozšířený filtr pro potlačení ozvěny. Průměrná maximální přenosová rychlost používaná zvukovým tokem Opus. Platné hodnoty jsou 6000-510000. Výchozí hodnota je 28000. Očekávaná ztráta paketů Opus Očekávaná procentuální ztráta paketů zvukového toku Opus od 0 do 100. Výchozí hodnota je 1. Hodnota 0 také vypíná funkci Opus Forward Error Correction (FEC). @@ -246,9 +245,7 @@ Výchozí hlasitost volání Vynutit tmavé téma Nepodařilo se načíst modul. - Pokud je zaškrtnuto, dojde u hovoru k pokusu o potlačení ozvěny. Zvukové kodeky poskytované zaškrtnutými moduly jsou k dispozici pro použití v účtech. - Rozšířený filtr AEC Neplatné procento ztráty paketů Opus Neplatný datový tok Opus Pokud je nastaveno, je výchozí hlasitost zvuku hovoru na stupnici 1-10. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 32269b5a..9764a868 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -359,7 +359,6 @@ Video Codecs Audioeinstellungen Sicherungskopie - Falls angekreuzt, wird versucht, Echounterdrückung auf den Anruf anzuwenden. RTCP Multiplexverfahren Ungültiger Opus Paketverlustprozentsatz Standardmäßige Anruflautstärke @@ -381,8 +380,6 @@ Falls angekreuzt, wird die Lautsprecherfunktion automatisch aktiviert, wenn der Anruf beginnt. Audiomodule Audio Codecs, die von den ausgewählten Modulen bereitgestellt werden, sind für die Benutzer verfügbar. - AEC Erweiterter Filter - Falls angekreuzt, nutzt die Echounterdrückung den erweiterten Filter. Opus Bitrate Durchschnittliche maximale Bitrate, die vom Opus Audiostream genutzt wird. Gültige Werte sind 6000-510000. Die Werkseinstellung beträgt 28000. Erwarteter Paketverlust von Opus diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4734675b..28d01e71 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -139,7 +139,12 @@ Comenzar automáticamente Si está marcado, baresip se inicia automáticamente después de que el dispositivo se (re)inicia. Dirección de escucha - Dirección IP y puerto de formulario \'address:port\' en el que escucha baresip para solicitudes SIP entrantes. Si la dirección IP es una dirección IPv6, debe escribirse dentro soportes []. La dirección IPv4 0.0.0.0 o la dirección IPv6 [::] hace que la escucha de baresip sea Direcciones disponibles. Si se deja vacío (predeterminado de fábrica), baresip escucha en el puerto 5060 de todas las direcciones disponibles. + Dirección IP y puerto de formulario \'address:port\' en el que escucha baresip + para solicitudes SIP entrantes. Si la dirección IP es una dirección IPv6, debe escribirse dentro + soportes []. La dirección IPv4 0.0.0.0 o la dirección IPv6 [::] hace que la escucha de baresip sea + Direcciones disponibles. Si se deja vacío (predeterminado de fábrica), baresip escucha en el puerto 5060 de + todas las direcciones disponibles. + Dirección de escucha no válida Servidores DNS Lista separada por comas de direcciones de servidores DNS. Si no se da, @@ -155,7 +160,6 @@ Archivo de CA de TLS Si está marcada, se ha cargado o se cargará un archivo que contiene certificados TLS de dichas Autoridades de Certificación que no están incluidas en el SO Android. En las versión de Android 9, se carga un archivo llamado \'ca_certs.crt\' desde la carpeta Download. Cancelación de eco acústico - Si está marcada, se intenta la cancelación del eco en el audio de la llamada. Tasa de bits de Opus Velocidad de bits máxima promedio utilizada por la transmisión de audio Opus. Los valores válidos son 6000-510000. El valor predeterminado de fábrica es 28000. @@ -284,8 +288,6 @@ Tamaño del fotograma de vídeo Forzar el tema de la pantalla oscura Tema oscuro - Si está marcada, la cancelación de eco está utilizando el filtro extendido. - Filtro extendido AEC Si está marcada, baresip verifica los certificados TLS del Agente de Usuario SIP y de los Servidores Proxy SIP cuando se utiliza el transporte TLS. Verificar los certificados del servidor Solicitud de transferencia de llamada a @@ -451,4 +453,4 @@ También se pueden comunicar problemas. Multiplica el volumen del micrófono por este número decimal. El valor mínimo es 1.0 (predeterminado de fábrica) que desactiva la ganancia del micrófono. Los valores más altos pueden afectar negativamente a la calidad del audio. Ganancia del micrófono Valor de ganancia de micrófono no válido - + \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index fa9546c7..3799d23d 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -6,8 +6,10 @@ Baresip-kirjastoon perustuva, äänipuhelut ja pikaviestit mahdollistava SIP-sovellus +

Juha Heinanen <jh@tutpro.com>

Versio %1$s

+

Käyttövihjeitä

  • Tarkista, että baresip-sovelluksen Asetukset vastaavat tarpeitasi. Kunkin otsikon kosketus tarjoaa apua.
  • @@ -17,7 +19,6 @@ valkoinen (rekisteröintiä ei ole aktivoitu).
  • Pisteen kosketus johtaa suoraan tilin asetuksiin.
  • Pyyhkäisy alas aikaansaa parhaillaan näkyvissä olevan tilin uudelleen rekisteröinnin.
  • Parhaillaan näkyvissä olevan tilin pitkä kosketus aktivoi tai passivoi tilin rekisteröinnin.
  • -
  • Pyyhkäisyllä vasemmalle/oikealle voit vaihtaa näkyvissä olevaa tiliä.
  • Voit valita edellisen puhelun uudelleen koskettamalla virheää luuria silloin, kun puhelun kohde on tyhjä.
  • Voit lisätä puheluiden ja viestien kohteet yhteystietoihin pitkällä kosketuksella.
  • Pitkillä kosketuksilla voit myös poistaa puheluita, viestiketjuja, viestejä ja yhteystietoja.
  • @@ -25,10 +26,12 @@
  • Katso lisätietoja Wiki-sivulta.

Tietosuoja

- Sovelluksen tietosuojakäytäntö on luettavissa täältä. +

Sovelluksen tietosuojakäytäntö on luettavissa täältä.

+

Lähdekoodi

- Lähdekoodi on saatavilla GitHub:ssa, - missä voi myös raportoida virheistä. +

Lähdekoodi on saatavilla GitHub:ssa, + missä voi myös raportoida virheistä.

+

Lisenssit

  • BSD-3-Clause except the following:
  • @@ -223,9 +226,9 @@ Tilit - Uusi tili - Kun uusi tili luodaan, voidaan haluttaessa antaa myös porttinumero - ja tiedonsiirtoprotokolla: <käyttäjä>@<domain>[:<portti>][;transport=udp|tcp|tls]. + Uuden tilin SIP URI + Uuden tilin SIP URI muotoa + <käyttäjä>@<domain>[:<portti>][;transport=udp|tcp|tls]. Jos <portti> on annettu, mutta protokollaa ei ole annettu, protokolla on udp. Jos <portti> ei ole annettu, mutta protokolla on annettu, portti on joko 5060 tai 5061 (tls). Jos kumpaakaan ei ole annettu eikä välityspalvelinta ole määritelty, tilin mahdollinen @@ -293,11 +296,11 @@ Audion koodausmenetelmät Luettelo audion koodausmenetelmistä prioriteettijärjestyksessä. - Järjestä raahaamalla ja pudottamalla, ota käyttöön tai poista käytöstä pyyhkäisemällä oikealle. + Järjestä raahaamalla ja pudottamalla, ota käyttöön tai poista käytöstä pitkällä painalluksella. Videon koodausmenetelmät Luettelo videon koodausmenetelmistä prioriteettijärjestyksessä. - Järjestä raahaamalla ja pudottamalla, ota käyttöön tai poista käytöstä pyyhkäisemällä oikealle. + Järjestä raahaamalla ja pudottamalla, ota käyttöön tai poista käytöstä pitkällä painalluksella. Järjestä @@ -365,9 +368,6 @@ Modulin lataaminen epäonnistui. Akustinen kaiun poisto (AKP) - Jos merkitty, kaikua yritetään poistaa ohjelmiston avulla. - Laitteellasi ei voida poistaa kaikua laitteiston avulla. - AKP laajennettu suodatin Mikrofonin äänikerroin Kertoo mikrofonin äänen voimakkuuden tällä desimaaliluvulla. Minimikerroin on 1.0 (tehdasasetus), mikä ei muuta mikrofonin äänen voimakkuutta. Suuremmat @@ -433,6 +433,8 @@ SIP tai tel URI käyttäjä@domain tai puhelinnumero Suosikki + If ruksattu, kontakti näytetään muiden suosikkien + kanssa yhteystietolistan alussa Virheellinen yhteystiedon nimi \'%1$s\' Yhteystieto \'%1$s\' on jo olemassa. Virheellinen SIP:n URI @@ -470,6 +472,8 @@ Virheellinen SIP tai puh. URI \'%1$s\' baresip Android + Laitteisto + Ohjelmisto Tallenna Palauta diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 89850e85..94cdcb20 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -130,7 +130,12 @@ Do you want to add \'%1$s\' to contacts or delete %2$s from call history? Do you want to delete chat with peer \'%1$s\' or add peer to contacts? - IP address and port of form \'address:port\' at which baresip listens for incoming SIP requests. If IP address is an IPv6 address, it must be written inside brackets []. IPv4 address 0.0.0.0 or IPv6 address [::] makes baresip listen at all available addresses. If left empty (factory default), baresip listens at port 5060 of all available addresses. + IP address and port of form \'address:port\' at which baresip listens + for incoming SIP requests. If IP address is an IPv6 address, it must be written inside + brackets []. IPv4 address 0.0.0.0 or IPv6 address [::] makes baresip listen at all + available addresses. If left empty (factory default), baresip listens at port 5060 of + all available addresses. + Comma separated list of addresses of DNS servers. If not given, DNS server addresses are obtained dynamically from the system. Each DNS address is of form \'ip:port\' or \'ip\'. If port is omitted, it defaults to 53. If ip is an IPv6 address and @@ -290,8 +295,6 @@ TLS CA証明書ファイル オーディオモジュール モジュールの読み込みに失敗しました - チェックを入れると、通話音声のエコーキャンセルを試みます - 音響エコーキャンセル拡張フィルタ Opusのビットレート 期待されるOpusのパケットロス Opusのビットレートが無効です @@ -346,7 +349,6 @@ 着信した電話にどのように応答するかを選択します。 チェックを入れると、デバイスの再起動後に自動的にbaresipが起動します 音響エコーキャンセル - チェックを入れると、エコーキャンセルは拡張フィルタを使用しています。 設定されている場合、デフォルトの通話音声の音量は1~10段階です ダークテーマを強制する チェックを入れると、デバッグおよび情報レベルのログメッセージをLogcatに提供します。 @@ -363,4 +365,4 @@  %1$s への通話転送を許可しますか? アプリケーションデータがダウンロードフォルダ %1$s にバックアップされました マイクへの権限付与がなく電話をかけたり、応答したりすることはできません。 - + \ No newline at end of file diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 9666a566..467e23a4 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -130,7 +130,6 @@ Kommainndelt liste over adresser til DNS-tjenere. Hvis ikke angitt, vil DNS-tjeneradresser hentes dynamisk fra systemet. Hver DNS-adresse er utførelsen \"ip:port\" eller \"ip\". Hvis porten utelates, brukes forvalget 53. Hvis IP-en er en IPv6-adresse, og port også angis, må IP-en skrives i hakeparenteser []. Som etksempel, list \'8.8.8.8:53,[2001:4860:4860::8888]:53\' peker til en IPv4-adresser og IPv6-adresser for offentlige Google DNS-tjenere. Klarte ikke å laste inn modul. Kansellering av akustisk ekko - Prøver å unngå ekko under samtaler. Gjennomsnittlig maksimal bitrate ved bruk av Opus-lydstrøm. Gyldig verdi er 6000-510000. Fabrikksforvalg er 28000. Forventet Opus-pakketap Forventet prosentvis pakketap for Opus-lydstrøm. Gyldige verdier fra 0–100. Fabrikksforvalg er 0, som skrur av Opus-fremtidsfeilkorrigering (FEC). @@ -231,4 +230,4 @@ Tid Varighet Lydinnstillinger - + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1e9434b7..1f5426ff 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -91,7 +91,6 @@ Perda de pacotes Opus esperada Taxa de bits máxima média usada pelo fluxo de áudio do Opus. Os valores válidos são 6000-510000. O padrão de fábrica é 28000. Taxa de Bits Opus - Se estiver marcado, o cancelamento de eco baseado em software está em uso. Cancelamento de Eco Acústico Falha ao carregar módulo. Codecs de áudio providos pelos módulos selecionados estão disponíveis para uso das contas. @@ -249,8 +248,6 @@ Confirmação Tamanho dos quadros de vídeo que foram transmitidos (largura x altura) Tamanho do Quadro do Vídeo - Caso esteja marcado, o cancelamento do eco estará utilizando um filtro estendido. - Filtro Estendido do AEC Senha inválida \'%1$s\' Senha, caso seja necessário pelo servidor STUN/TURN Senha STUN/TURN @@ -425,5 +422,4 @@ Valor inválido do ganho do microfone Ganho do microfone Multiplique o volume do microfone por esse número decimal. O valor mínimo é 1,0 (padrão de fábrica), que desativa o ganho do microfone. Valores maiores podem afetar negativamente a qualidade do áudio. - O cancelador de eco baseado em hardware não pode ser usado em seu dispositivo. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e6e6527f..6abd4430 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -43,7 +43,6 @@ Mensagem do Chat Quer ligar ou enviar mensagem para \'%1$s\'\? Ficheiro TLS CA - Caso esteja marcado, o cancelamento do eco estará utilizando um filtro estendido. URI SIP de outro Servidor Proxy Contato \'%1$s\' já existe. URI SIP do Servidor Proxy @@ -122,14 +121,12 @@ Não tem mensagens Duração: %1$d (segs) Transferindo chamada para … - Se marcado, cancelamento de eco é tentado no áudio da chamada. Informações da Chamada Histórico de Mensagens A chamada é SEGURA e o par é VERIFICADO! Deseja desverificar o par\? Taxa de bits máxima média usada pelo fluxo de áudio do Opus. Os valores válidos são 6000-510000. A predefinição de fábrica é 28000. Solicitação para Reinicialização Registo - Filtro Estendido do AEC Restaurar Perda de pacotes Opus esperada Aceita transferir esta chamada para \'%1$s\'\? @@ -325,4 +322,4 @@ Toque da chamada do país, espera e tom de chamada ocupada Respostas provisórias confiáveis Se for marcado, indica suporte para respostas provisórias confiáveis (RFC 3262). - + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index a210951c..ddca2759 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -62,7 +62,6 @@ Adresă de conectare Servere DNS Anularea acustică a ecoului - Dacă este bifat, se încearcă eliminarea ecoului în timpul apelurilor. Rată de eșantionare Opus Volum implicit de apel Dacă este setat, volumul audio de apel implicit între 1–10. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 64076c65..cfe08996 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -134,7 +134,6 @@ Профиль \'%1$s\' уже существует. Профили Подавление акустического эха - Если выбрано - работает подавление эха. и Режим ответа Выберите как отвечать на входящие вызовы. @@ -181,8 +180,6 @@ Ожидаемая потеря пакетов Opus Средняя максимальная скорость передачи данных, используемая в аудиопотоке Opus. Допустимые значения: 6000-510000. Значение по умолчанию - 28000. Скорость передачи данных Opus - Если этот флажок установлен, для подавления эха используется расширенный фильтр. - Расширенный фильтр AEC Аудиокодеки, предоставленные выбранными модулями, доступны для использования в аккаунтах. При отметке файл с TLS-сертификатами необходимых центров сертификации был или будет загружен. В Android версии 9 и ниже файл называется \'ca_certs.crt\' и берётся из каталога загрузок. Файл TLS CA @@ -328,4 +325,4 @@ Надёжные предварительные ответы Для автоматического запуска требуется разрешение отображения поверх других приложений. Кадровая частота - + \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 597e9c06..f44b6a79 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -219,12 +219,9 @@ Kopplingsbegäran Kryssa i för att använda en förvald ljudvolym, 1–10, vid samtal. Acceptera - Utökat filter vid ekoutsläckning Kryssa i för att lägga till kontakten till dina Android-kontakter. Kryssa i för att inkludera SIP-meddelanden i Logcat-meddelanden när Felsökning är aktiverat. Funktionen inaktiveras automatiskt när baresip startar. Kryssa i för att skicka felsöknings- och info-meddelanden till Logcat. - Kryssa i för att använda ett utökat filter vid ekoutsläckning. - Kryssa i för att aktivera ekoutsläckning för samtalsljud. Kryssa i för att ladda TLS-certifikat för certifikatauktoriteter från fil. I Android 9 och tidigare versioner laddas en fil med namnet \'ca_certs.crt\' från katalogen Nedladdningar. Kryssa i om baresip ska verifiera TLS-certifikat för SIP user agent och SIP proxyserver när transportprotokollet TLS används. TLS CA-fil måste då också laddas. Kryssa i för att ladda TLS-certifikat och privat nyckel för den här baresip-instansen från fil. I Android 9 och tidigare versioner laddas en fil med namnet \'cert.pem\' från katalogen Nedladdningar. Av säkerhetsskäl tas filen bort efter att den lästs in. @@ -312,4 +309,4 @@ Avgör hur ofta (i sekunder) baresip skickar REGISTER-förfrågningar. Giltiga värden är från 60 till 3600. Tid (i millisekunder) att vänta på ljud från motparten när ett samtal upprättats. Sätt till ett högre värde om du saknar ljud från motparten i början av samtalet. baresip+ behöver behörigheten \"Mikrofon\" för röstsamtal, behörigheten \"Kamera\" för videosamtal, behörigheten \"Enheter i närheten\" för upptäckt av mikrofon/högtalare via blåtand och behörigheten \"Aviseringar\" för att skicka aviseringar. - + \ No newline at end of file diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 7b838f49..92765459 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -171,8 +171,6 @@ ஆடியோ அமைப்புகள் சரிபார்க்கப்பட்டால், ஆண்ட்ராய்டு OS இல் சேர்க்கப்படாத அத்தகைய சான்றிதழ் அதிகாரிகளின் TLS சான்றிதழ்களைக் கொண்ட ஒரு கோப்பு அல்லது ஏற்றப்படும். ஆண்ட்ராய்டு பதிப்பு 9 இல், \'Ca_certs.crt\' என்ற கோப்பு பதிவிறக்க கோப்புறையிலிருந்து ஏற்றப்படுகிறது. அவைத்தலைவர் தொலைபேசி - AEC நீட்டிக்கப்பட்ட வடிகட்டி - சரிபார்க்கப்பட்டால், எதிரொலி ரத்துசெய்தல் நீட்டிக்கப்பட்ட வடிப்பானைப் பயன்படுத்துகிறது. இந்த தசம எண்ணால் மைக்ரோஃபோன் அளவைப் பெருக்கவும். மைக்ரோஃபோன் ஆதாயத்தை முடக்கும் குறைந்தபட்ச மதிப்பு 1.0 (தொழிற்சாலை இயல்புநிலை) ஆகும். பெரிய மதிப்புகள் ஆடியோ தரத்தை எதிர்மறையாக பாதிக்கலாம். சரிபார்க்கப்பட்டால், அழைப்பு தொடங்கும் போது ச்பீக்கர் தொலைபேசி தானாக இயக்கப்படும். ஆடியோ தொகுதிகள் diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a55d9123..7c17b4b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,10 @@ Baresip library based SIP User Agent +

    Juha Heinanen <jh@tutpro.com>

    Version %1$s

    +

    Usage Hints

    • Check that default values in baresip\'s Settings meet your needs @@ -21,19 +23,19 @@
    • Touch on the dot leads directly to account configuration.
    • Swipe down gesture causes re-registration of the currently shown account.
    • Long touch on currently shown account enables or disables account\'s registration.
    • -
    • Swipe left/right gesture toggles between the accounts.
    • Previous call party can be reselect by touching the call icon when Callee is empty.
    • Peers of calls and messages can be added to contacts by long touches.
    • Long touches can also be used to remove calls, chats, messages, and contacts.
    • Touch/long touch of contact icon can be used to install/remove image avatar.
    • -
    • See Wiki for more - information.
    • +
    • See Wiki for more information.

    Privacy Policy

    - Privacy policy is available here. +

    Privacy policy is available here.

    +

    Source code

    - Source code is available at GitHub, - where also issues can be reported. +

    Source code is available at GitHub, + where also issues can be reported.

    +

    Licenses

    • BSD-3-Clause except the following:
    • @@ -210,10 +212,10 @@ Accounts - New Account - When a new account is created, account\'s port number and transport - protocol may be optionally given: <user>@<domain>[:<port>][;transport=udp|tcp|tls]. - If <port> is given and transport protocol is not given, transport protocol defaults to udp. + SIP URI of New Account + SIP URI of new account of form + <user>@<domain>[:<port>][;transport=udp|tcp|tls]. + If <port> is given and transport protocol is not given, transport protocol defaults to UDP. If <port> is not given and transport protocol is given, <port> defaults to 5060 or 5061 (TLS). If neither is given and no outbound proxy is specified, account\'s registrar (if any) is determined solely based on domain\'s DNS information. @@ -278,10 +280,10 @@ Audio Codecs List of audio codecs in priority order. Drag to reorder, - swipe right to enable or disable. + long click to enable or disable. Video Codecs List of video codecs in priority order. Drag to reorder, - swipe right to enable or disable. + long click to enable or disable. Reorder Settings @@ -340,10 +342,6 @@ available for use by the accounts. Failed to load module. Acoustic Echo Cancellation - If checked, software based echo cancellation is in use. - Hardware based echo canceller cannot be used on you device. - AEC Extended Filter - If checked, echo cancellation is using extended filter. Microphone Gain Multiply microphone volume by this decimal number. Minimum value is 1.0 (factory default) that disables microphone gain. Larger values may negatively @@ -413,6 +411,8 @@ SIP or tel URI user@domain or telephone number Favorite + If checked, contact is shown among other favorites + on top of contacts list. Invalid contact name \'%1$s\' Contact \'%1$s\' already exists. Invalid SIP URI @@ -452,6 +452,8 @@ Invalid SIP or tel URI \'%1$s\' baresip Android + Hardware + Software Backup Restore diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 0965e420..625e3fff 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,50 +1,23 @@ - - - - - - - - - - + + - - - - diff --git a/build.gradle b/build.gradle index 6f65ce56..18348cee 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { // noinspection AndroidGradlePluginVersion - classpath 'com.android.tools.build:gradle:8.8.1' + classpath libs.gradle.v881 classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..93b22f1a --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,68 @@ +[versions] +activityCompose = "1.10.1" +appcompat = "1.7.0" +cardview = "1.0.0" +coilCompose = "2.5.0" +composeBom = "2025.03.01" +constraintlayout = "2.2.1" +coreKtx = "1.15.0" +exifinterface = "1.4.0" +foundation = "1.7.8" +fragmentKtx = "1.8.6" +gradle = "8.9.1" +gradleVersion = "8.9.1" +kotlin = "2.0.21" +kotlinStdlibJdk8 = "2.1.0" +kotlinxCoroutinesAndroid = "1.9.0" +lifecycleExtensions = "2.2.0" +lifecycleViewmodelCompose = "2.8.7" +localbroadcastmanager = "1.1.0" +material = "1.12.0" +media = "1.7.0" +preferenceKtx = "1.2.1" +swiperefreshlayout = "1.1.0" +ui = "1.8.0-rc02" +foundationAndroid = "1.7.8" +toolsCore = "1.0.0-alpha13" +runtimeLivedata = "1.7.8" +uiTestAndroid = "1.7.8" +composeMaterial = "1.7.8" +composeMaterial3 = "1.3.1" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityCompose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-cardview = { module = "androidx.cardview:cardview", version.ref = "cardview" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } +androidx-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycleExtensions" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-localbroadcastmanager = { module = "androidx.localbroadcastmanager:localbroadcastmanager", version.ref = "localbroadcastmanager" } +androidx-media = { module = "androidx.media:media", version.ref = "media" } +androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } +androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } +androidx-ui = { module = "androidx.compose.ui:ui", version.ref = "ui" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" } +gradle-v881 = { module = "com.android.tools.build:gradle", version.ref = "gradleVersion" } +kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlinStdlibJdk8" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +material = { module = "com.google.android.material:material", version.ref = "material" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-foundation-android = { group = "androidx.compose.foundation", name = "foundation-android", version.ref = "foundationAndroid" } +androidx-tools-core = { group = "androidx.privacysandbox.tools", name = "tools-core", version.ref = "toolsCore" } +androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } +androidx-ui-test-android = { group = "androidx.compose.ui", name = "ui-test-android", version.ref = "uiTestAndroid" } +androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "composeMaterial" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "composeMaterial3" } + +[plugins] +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 36659689..3c6cb998 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Jan 30 05:45:34 EET 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists