diff --git a/app/gradle.lockfile b/app/gradle.lockfile
index e12389934..7cb77a04a 100644
--- a/app/gradle.lockfile
+++ b/app/gradle.lockfile
@@ -9,10 +9,8 @@ androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debug
androidx.annotation:annotation-jvm:1.9.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.annotation:annotation:1.9.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
-androidx.appcompat:appcompat-resources:1.7.1=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.appcompat:appcompat-resources:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.appcompat:appcompat:1.7.1=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.appcompat:appcompat:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
+androidx.appcompat:appcompat-resources:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.appcompat:appcompat:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.autofill:autofill:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
@@ -138,42 +136,26 @@ androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileCl
androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.paging:paging-common-android:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.paging:paging-common-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.paging:paging-common:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.paging:paging-common:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.paging:paging-compose-android:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.paging:paging-compose-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.paging:paging-compose:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.paging:paging-compose:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
+androidx.paging:paging-common-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.paging:paging-common:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.paging:paging-compose-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.paging:paging-compose:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.print:print:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
androidx.recyclerview:recyclerview:1.1.0=debugAndroidTestRuntimeClasspath
androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-common-jvm:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-common-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath
-androidx.room:room-common:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-common:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath
-androidx.room:room-compiler-processing:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath
-androidx.room:room-compiler-processing:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath
-androidx.room:room-compiler:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath
-androidx.room:room-compiler:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath
-androidx.room:room-external-antlr:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath
-androidx.room:room-external-antlr:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath
-androidx.room:room-ktx:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-ktx:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.room:room-migration-jvm:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath
-androidx.room:room-migration-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath
-androidx.room:room-migration:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath
-androidx.room:room-migration:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath
-androidx.room:room-paging-android:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-paging-android:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.room:room-paging:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-paging:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.room:room-runtime-android:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-runtime-android:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-androidx.room:room-runtime:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-androidx.room:room-runtime:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
+androidx.room:room-common-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.room:room-common:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.room:room-compiler-processing:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
+androidx.room:room-compiler:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
+androidx.room:room-external-antlr:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
+androidx.room:room-ktx:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.room:room-migration-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
+androidx.room:room-migration:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
+androidx.room:room-paging-android:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.room:room-paging:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.room:room-runtime-android:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+androidx.room:room-runtime:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.room:room-testing-android:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath
androidx.room:room-testing:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath
androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
@@ -205,7 +187,6 @@ androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroid
androidx.window:window-core-android:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
androidx.window:window-core:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
androidx.window:window:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
-androidx.work:work-runtime-ktx:2.11.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.work:work-runtime:2.11.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
androidx.work:work-testing:2.11.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
ch.qos.logback:logback-classic:1.3.16=ktlint
@@ -267,8 +248,7 @@ com.google.dagger:hilt-android:2.58=debugAndroidTestCompileClasspath,debugCompil
com.google.dagger:hilt-compiler:2.58=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.dagger:hilt-core:2.58=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
com.google.devtools.ksp:symbol-processing-api:2.2.20-2.0.3=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
-com.google.devtools.ksp:symbol-processing-api:2.3.11=kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable
-com.google.devtools.ksp:symbol-processing-api:2.3.12=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest
+com.google.devtools.ksp:symbol-processing-api:2.3.12=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable
com.google.errorprone:error_prone_annotation:2.41.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
com.google.errorprone:error_prone_annotations:2.26.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
@@ -323,10 +303,8 @@ com.squareup.moshi:moshi-kotlin:1.15.2=debugAndroidTestCompileClasspath,debugCom
com.squareup.moshi:moshi:1.15.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-com.squareup.okio:okio-jvm:3.17.0=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-com.squareup.okio:okio-jvm:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
-com.squareup.okio:okio:3.17.0=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
-com.squareup.okio:okio:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
+com.squareup.okio:okio-jvm:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
+com.squareup.okio:okio:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
com.squareup:javapoet:1.13.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.squareup:kotlinpoet-javapoet:2.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.squareup:kotlinpoet-jvm:2.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f0c58add2..57b16c0e2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -137,6 +137,14 @@
android:name=".service.CallerIdOverlayService"
android:exported="false" />
+
+
+
+
+
+
= emptyList(),
val regionBlockEnabled: Boolean = false,
diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/BackupSettingsPreferences.kt b/app/src/main/java/com/sysadmindoc/callshield/data/BackupSettingsPreferences.kt
index 015012340..d3b3d8749 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/data/BackupSettingsPreferences.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/data/BackupSettingsPreferences.kt
@@ -3,12 +3,19 @@ package com.sysadmindoc.callshield.data
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import com.sysadmindoc.callshield.data.BackupRestore.BackupSettings
+import com.sysadmindoc.callshield.service.AnswerHangUpController
// Settings half of a backup: reading the DataStore snapshot into a
// BackupSettings, bounding what a (possibly hostile) backup carries, and
// writing it back. Pure functions over Preferences, kept out of BackupRestore
// so each file stays readable.
+internal fun clampHangUpDelaySeconds(value: Int?): Int =
+ value?.coerceIn(
+ AnswerHangUpController.MIN_DELAY_SECONDS,
+ AnswerHangUpController.MAX_DELAY_SECONDS,
+ ) ?: AnswerHangUpController.DEFAULT_DELAY_SECONDS
+
@Suppress("LongMethod")
internal fun Preferences.toBackupSettings(): BackupSettings =
BackupSettings(
@@ -51,6 +58,8 @@ internal fun Preferences.toBackupSettings(): BackupSettings =
rcsFilterEnabled = this[SpamRepository.KEY_RCS_FILTER] ?: true,
postCallScreenEnabled = this[SpamRepository.KEY_POST_CALL_SCREEN] ?: false,
silentVoicemailEnabled = this[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false,
+ answerHangUpEnabled = this[SpamRepository.KEY_ANSWER_HANG_UP] ?: false,
+ hangUpDelaySeconds = clampHangUpDelaySeconds(this[SpamRepository.KEY_HANG_UP_DELAY_SECONDS]),
pushAlertEnabled = this[SpamRepository.KEY_PUSH_ALERT] ?: true,
pushAlertDisabledPackages = (this[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet()).sorted(),
regionBlockEnabled = this[SpamRepository.KEY_REGION_BLOCK] ?: false,
@@ -94,6 +103,7 @@ internal fun BackupSettings.sanitized(): BackupSettings =
timeBlockEndHour = BackupRestore.sanitizeScheduleHour(timeBlockEndHour),
frequencyThreshold = frequencyThreshold.coerceIn(1, 25),
cleanupDays = cleanupDays.coerceIn(1, 365),
+ hangUpDelaySeconds = clampHangUpDelaySeconds(hangUpDelaySeconds),
pushAlertDisabledPackages =
pushAlertDisabledPackages
.map { it.trim() }
@@ -162,6 +172,8 @@ internal fun BackupSettings.writeTo(preferences: MutablePreferences) {
preferences[SpamRepository.KEY_RCS_FILTER] = rcsFilterEnabled
preferences[SpamRepository.KEY_POST_CALL_SCREEN] = postCallScreenEnabled
preferences[SpamRepository.KEY_SILENT_VOICEMAIL] = silentVoicemailEnabled
+ preferences[SpamRepository.KEY_ANSWER_HANG_UP] = answerHangUpEnabled
+ preferences[SpamRepository.KEY_HANG_UP_DELAY_SECONDS] = hangUpDelaySeconds
preferences[SpamRepository.KEY_PUSH_ALERT] = pushAlertEnabled
if (pushAlertDisabledPackages.isEmpty()) {
preferences.remove(SpamRepository.KEY_PUSH_ALERT_DISABLED)
diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt b/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt
index 9dee87539..85b634533 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt
@@ -83,6 +83,7 @@ class CallbackDetector
windowMinutes: Int = 5,
threshold: Int = 2,
minRetryIntervalMillis: Long = MIN_URGENT_RETRY_INTERVAL_MILLIS,
+ minIncomingDurationSeconds: Int = 0,
): Boolean =
withContext(Dispatchers.IO) {
val digits = filterAsciiDigitsLast(number, 10)
@@ -90,7 +91,13 @@ class CallbackDetector
val last7 = digits.takeLast(7)
try {
- val query = buildRepeatedUrgentCallQuery(System.currentTimeMillis(), windowMinutes, last7)
+ val query =
+ buildRepeatedUrgentCallQuery(
+ nowMillis = System.currentTimeMillis(),
+ windowMinutes = windowMinutes,
+ last7Digits = last7,
+ minIncomingDurationSeconds = minIncomingDurationSeconds,
+ )
val cursor =
context.contentResolver.query(
CallLog.Calls.CONTENT_URI,
@@ -251,15 +258,32 @@ class CallbackDetector
nowMillis: Long,
windowMinutes: Int,
last7Digits: String,
+ minIncomingDurationSeconds: Int = 0,
): CallLogQuery {
val safeWindowMinutes = windowMinutes.coerceAtLeast(1)
val cutoff = (nowMillis - safeWindowMinutes * 60_000L).toString()
+ if (minIncomingDurationSeconds <= 0) {
+ return CallLogQuery(
+ selection = "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?",
+ selectionArgs =
+ arrayOf(
+ CallLog.Calls.INCOMING_TYPE.toString(),
+ CallLog.Calls.MISSED_TYPE.toString(),
+ cutoff,
+ "%$last7Digits",
+ ),
+ )
+ }
return CallLogQuery(
- selection = "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?",
+ selection =
+ "(${CallLog.Calls.TYPE} = ? OR (${CallLog.Calls.TYPE} = ? AND " +
+ "${CallLog.Calls.DURATION} >= ?)) AND ${CallLog.Calls.DATE} > ? " +
+ "AND ${CallLog.Calls.NUMBER} LIKE ?",
selectionArgs =
arrayOf(
- CallLog.Calls.INCOMING_TYPE.toString(),
CallLog.Calls.MISSED_TYPE.toString(),
+ CallLog.Calls.INCOMING_TYPE.toString(),
+ minIncomingDurationSeconds.toString(),
cutoff,
"%$last7Digits",
),
@@ -320,6 +344,9 @@ class CallbackDetector
const val DEFAULT_ANSWERED_CALLER_THRESHOLD = 2
const val DEFAULT_EMERGENCY_CALLBACK_WINDOW_MINUTES = 240
const val MIN_ANSWERED_CALL_DURATION_SECONDS = 1
+
+ // Answer-and-hang-up creates 1-11 second incoming rows; SpamBlocker's trust rule uses this 15-second floor.
+ const val AUTO_ANSWER_MIN_TRUSTED_SECONDS = 15
const val MIN_URGENT_RETRY_INTERVAL_MILLIS = 15_000L
private const val MILLIS_PER_DAY = 86_400_000L
private const val MILLIS_PER_MINUTE = 60_000L
@@ -341,6 +368,7 @@ class CallbackDetector
windowMinutes: Int = 5,
threshold: Int = 2,
minRetryIntervalMillis: Long = MIN_URGENT_RETRY_INTERVAL_MILLIS,
+ minIncomingDurationSeconds: Int = 0,
): Boolean =
shared.isRepeatedUrgentCall(
context,
@@ -348,6 +376,7 @@ class CallbackDetector
windowMinutes,
threshold,
minRetryIntervalMillis,
+ minIncomingDurationSeconds,
)
suspend fun wasAnsweredRepeatedly(
@@ -373,7 +402,14 @@ class CallbackDetector
nowMillis: Long,
windowMinutes: Int,
last7Digits: String,
- ): CallLogQuery = shared.buildRepeatedUrgentCallQuery(nowMillis, windowMinutes, last7Digits)
+ minIncomingDurationSeconds: Int = 0,
+ ): CallLogQuery =
+ shared.buildRepeatedUrgentCallQuery(
+ nowMillis,
+ windowMinutes,
+ last7Digits,
+ minIncomingDurationSeconds,
+ )
internal fun hasUrgentRetrySpacing(
timestamps: List,
diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt b/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt
index c3761ea5b..663c20e37 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt
@@ -252,6 +252,8 @@ class SpamRepository(
// reaches voicemail, user can review later without the interruption
// or the missed-call entry from a rejection.
val KEY_SILENT_VOICEMAIL = booleanPreferencesKey("silent_voicemail_mode")
+ val KEY_ANSWER_HANG_UP = booleanPreferencesKey("answer_hang_up_mode")
+ val KEY_HANG_UP_DELAY_SECONDS = intPreferencesKey("hang_up_delay_seconds")
// A3 push-alert bridge — master toggle. When off, the registry is
// not fed by RcsNotificationListener and PushAlertChecker returns
@@ -422,6 +424,8 @@ class SpamRepository(
val postCallScreenEnabled: Flow = settingsRepository.postCallScreenEnabled
val notificationScreeningPackages: Flow> = settingsRepository.notificationScreeningPackages
val silentVoicemailEnabled: Flow = settingsRepository.silentVoicemailEnabled
+ val answerHangUpEnabled: Flow = settingsRepository.answerHangUpEnabled
+ val hangUpDelaySeconds: Flow = settingsRepository.hangUpDelaySeconds
val pushAlertEnabled: Flow = settingsRepository.pushAlertEnabled
val pushAlertDisabledPackages: Flow> = settingsRepository.pushAlertDisabledPackages
internal val smsMessageCapabilityStatus: Flow = settingsRepository.smsMessageCapabilityStatus
@@ -519,6 +523,10 @@ class SpamRepository(
suspend fun setSilentVoicemail(enabled: Boolean) = settingsRepository.setSilentVoicemail(enabled)
+ suspend fun setAnswerHangUpEnabled(enabled: Boolean) = settingsRepository.setAnswerHangUpEnabled(enabled)
+
+ suspend fun setHangUpDelaySeconds(seconds: Int) = settingsRepository.setHangUpDelaySeconds(seconds)
+
suspend fun setPushAlert(enabled: Boolean) = settingsRepository.setPushAlert(enabled)
suspend fun togglePushAlertPackage(
diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt b/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt
index a8e7cdcf4..48d1d9974 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt
@@ -579,7 +579,23 @@ internal class AnsweredCallerChecker(
?: CallbackDetector.DEFAULT_ANSWERED_CALLER_WINDOW_DAYS
).coerceAtLeast(1)
- return if (callbackDetector.wasAnsweredRepeatedly(appContext, ctx.number, windowDays, threshold)) {
+ val wasAnsweredRepeatedly =
+ if (ctx.prefs[SpamRepository.KEY_ANSWER_HANG_UP] == true) {
+ callbackDetector.wasAnsweredRepeatedly(
+ appContext,
+ ctx.number,
+ windowDays,
+ threshold,
+ maxOf(
+ CallbackDetector.MIN_ANSWERED_CALL_DURATION_SECONDS,
+ CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS,
+ ),
+ )
+ } else {
+ callbackDetector.wasAnsweredRepeatedly(appContext, ctx.number, windowDays, threshold)
+ }
+
+ return if (wasAnsweredRepeatedly) {
BlockResult.allow("answered_caller")
} else {
null
@@ -619,7 +635,18 @@ internal class RepeatedUrgentChecker(
override val name = "repeated_urgent"
override suspend fun check(ctx: CheckContext): BlockResult? =
- if (callbackDetector.isRepeatedUrgentCall(appContext, ctx.number)) {
+ if (
+ callbackDetector.isRepeatedUrgentCall(
+ appContext,
+ ctx.number,
+ minIncomingDurationSeconds =
+ if (ctx.prefs[SpamRepository.KEY_ANSWER_HANG_UP] == true) {
+ CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS
+ } else {
+ 0
+ },
+ )
+ ) {
BlockResult.allow("repeated_urgent")
} else {
null
diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt b/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt
index 89bd46d16..bc841d65a 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt
@@ -29,6 +29,7 @@ import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription
import com.sysadmindoc.callshield.data.model.HotDataHealth
import com.sysadmindoc.callshield.data.model.HotDataHealthUpdate
import com.sysadmindoc.callshield.data.remote.FeedMirror
+import com.sysadmindoc.callshield.service.AnswerHangUpController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -182,6 +183,12 @@ class SettingsRepository(
}
val silentVoicemailEnabled: Flow =
dataStore.data.map { it[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false }
+ val answerHangUpEnabled: Flow =
+ dataStore.data.map { it[SpamRepository.KEY_ANSWER_HANG_UP] ?: false }
+ val hangUpDelaySeconds: Flow =
+ dataStore.data.map {
+ AnswerHangUpController.clampDelaySeconds(it[SpamRepository.KEY_HANG_UP_DELAY_SECONDS])
+ }
val pushAlertEnabled: Flow = dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT] ?: true }
val pushAlertDisabledPackages: Flow> =
dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet() }
@@ -352,6 +359,13 @@ class SettingsRepository(
suspend fun setSilentVoicemail(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_SILENT_VOICEMAIL] = enabled }
+ suspend fun setAnswerHangUpEnabled(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_ANSWER_HANG_UP] = enabled }
+
+ suspend fun setHangUpDelaySeconds(seconds: Int) =
+ dataStore.edit {
+ it[SpamRepository.KEY_HANG_UP_DELAY_SECONDS] = AnswerHangUpController.clampDelaySeconds(seconds)
+ }
+
suspend fun setPushAlert(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_PUSH_ALERT] = enabled }
internal suspend fun recordMessageCapability(status: MessageCapabilityStatus) =
diff --git a/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt b/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt
index 543952922..bc9b3f079 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt
@@ -104,8 +104,14 @@ object CallShieldPermissions {
* Runtime grants requested during first-run setup. Keeping this list next
* to the capability contract prevents onboarding from silently omitting a
* permission that the live call path expects on some Android builds.
+ *
+ * ANSWER_PHONE_CALLS is deliberately NOT requested here: it is asked for
+ * only when the user enables "Answer & hang up blocked calls" in
+ * Settings, so users who never enable that feature are never prompted
+ * for it.
*/
- val onboardingRuntimePermissions = (corePermissions + compatibilityPermissions).distinct()
+ val onboardingRuntimePermissions =
+ (corePermissions + listOf(Manifest.permission.READ_PHONE_STATE)).distinct()
val permissionCapabilityContracts =
listOf(
diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt
new file mode 100644
index 000000000..8caddb0e0
--- /dev/null
+++ b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt
@@ -0,0 +1,377 @@
+package com.sysadmindoc.callshield.service
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.telecom.TelecomManager
+import android.telecom.VideoProfile
+import android.telephony.TelephonyManager
+import android.util.Log
+
+/**
+ * Coordinates the short answer-and-hang-up sequence for one screened call.
+ *
+ * Telecom and phone-state callbacks are not guaranteed to arrive in a fixed order, so this
+ * process-wide controller owns both the pending identity and all delayed work.
+ */
+object AnswerHangUpController {
+ const val DEFAULT_DELAY_SECONDS = 1
+ const val MIN_DELAY_SECONDS = 1
+ const val MAX_DELAY_SECONDS = 10
+
+ private const val TAG = "AnswerHangUp"
+ private const val ARM_EXPIRY_MS = 5_000L
+ private const val ANSWER_TIMEOUT_MS = 5_000L
+ private const val POLL_INTERVAL_MS = 250L
+
+ internal enum class Phase {
+ NONE,
+ ARMED,
+ ANSWERING,
+ HANGING_UP,
+ }
+
+ internal interface CallControl {
+ fun acceptRingingCallAudioOnly(): Boolean
+
+ fun endCall(): Boolean
+
+ fun currentCallState(): Int
+ }
+
+ internal interface Scheduler {
+ fun postDelayed(
+ delayMillis: Long,
+ task: () -> Unit,
+ )
+
+ fun cancelAll()
+ }
+
+ private data class PendingCall(
+ val number: String,
+ val armedAt: Long,
+ val delayMillis: Long,
+ var phase: Phase,
+ var acceptedAt: Long? = null,
+ var hangUpDeadline: Long? = null,
+ )
+
+ private val lock = Any()
+ private val timerToken = Any()
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ private val productionScheduler =
+ object : Scheduler {
+ override fun postDelayed(
+ delayMillis: Long,
+ task: () -> Unit,
+ ) {
+ mainHandler.postAtTime(
+ Runnable { task() },
+ timerToken,
+ SystemClock.uptimeMillis() + delayMillis,
+ )
+ }
+
+ override fun cancelAll() {
+ mainHandler.removeCallbacksAndMessages(timerToken)
+ }
+ }
+
+ private var applicationContext: Context? = null
+ private var clock: () -> Long = SystemClock::elapsedRealtime
+ private var scheduler: Scheduler = productionScheduler
+ private var callControl: CallControl = productionCallControl()
+ private var pendingCall: PendingCall? = null
+
+ /** Stores the context used by the production telecom implementation. */
+ internal fun configure(context: Context) {
+ applicationContext = context.applicationContext
+ }
+
+ fun clampDelaySeconds(value: Int?): Int = value?.coerceIn(MIN_DELAY_SECONDS, MAX_DELAY_SECONDS) ?: DEFAULT_DELAY_SECONDS
+
+ /** Arms this process for the next matching PHONE_STATE broadcast when no call is in flight. */
+ fun tryArm(
+ rawNumber: String?,
+ delaySeconds: Int,
+ ): Boolean =
+ synchronized(lock) {
+ if (pendingCall?.phase == Phase.ANSWERING || pendingCall?.phase == Phase.HANGING_UP) {
+ return@synchronized false
+ }
+ clearLocked()
+ pendingCall =
+ PendingCall(
+ number = digitsOnly(rawNumber),
+ armedAt = clock(),
+ delayMillis = clampDelaySeconds(delaySeconds) * 1_000L,
+ phase = Phase.ARMED,
+ )
+ scheduleArmedExpiryLocked()
+ true
+ }
+
+ fun disarm() {
+ synchronized(lock) {
+ clearLocked()
+ }
+ }
+
+ /** A new screening callback invalidates a stale pending, unaccepted call. */
+ fun onIncomingScreeningStarted() {
+ synchronized(lock) {
+ if (pendingCall?.phase == Phase.ARMED) {
+ clearLocked()
+ }
+ }
+ }
+
+ /** A user-initiated outgoing call must never inherit a pending hang-up timer. */
+ fun onOutgoingCallStarted() {
+ synchronized(lock) {
+ clearLocked()
+ }
+ }
+
+ fun hasPendingCall(): Boolean = synchronized(lock) { pendingCall != null }
+
+ fun isBusy(): Boolean =
+ synchronized(lock) {
+ pendingCall?.phase == Phase.ANSWERING || pendingCall?.phase == Phase.HANGING_UP
+ }
+
+ /** Processes the protected PHONE_STATE broadcast. */
+ fun onPhoneState(
+ state: String?,
+ incomingNumber: String?,
+ hasIncomingNumber: Boolean = true,
+ ) {
+ synchronized(lock) {
+ val pending = pendingCall ?: return
+ when (state) {
+ TelephonyManager.EXTRA_STATE_RINGING -> {
+ if (pending.phase == Phase.ARMED) {
+ handleRingingLocked(pending, incomingNumber, hasIncomingNumber)
+ }
+ }
+
+ TelephonyManager.EXTRA_STATE_OFFHOOK -> {
+ handleOffhookLocked(pending, incomingNumber)
+ }
+
+ TelephonyManager.EXTRA_STATE_IDLE -> {
+ if (pending.phase == Phase.ARMED) {
+ clearLocked()
+ }
+ }
+ }
+ }
+ }
+
+ private fun handleRingingLocked(
+ pending: PendingCall,
+ incomingNumber: String?,
+ hasIncomingNumber: Boolean,
+ ) {
+ if (!hasIncomingNumber) return
+ if (!isWithinArmWindow(pending)) {
+ clearLocked()
+ return
+ }
+ if (!numbersMatch(pending.number, digitsOnly(incomingNumber))) return
+
+ pending.phase = Phase.ANSWERING
+ pending.acceptedAt = clock()
+ try {
+ if (!callControl.acceptRingingCallAudioOnly()) {
+ clearLocked()
+ return
+ }
+ } catch (exception: RuntimeException) {
+ Log.w(TAG, "Unable to answer screened call", exception)
+ clearLocked()
+ return
+ }
+ schedulePollLocked()
+ }
+
+ private fun handleOffhookLocked(
+ pending: PendingCall,
+ incomingNumber: String?,
+ ) {
+ if (pending.phase == Phase.ANSWERING &&
+ (digitsOnly(incomingNumber).isEmpty() || numbersMatch(pending.number, digitsOnly(incomingNumber)))
+ ) {
+ enterHangingUpLocked(pending)
+ }
+ }
+
+ private fun enterHangingUpLocked(pending: PendingCall) {
+ if (pending.phase != Phase.ANSWERING) return
+ pending.phase = Phase.HANGING_UP
+ pending.hangUpDeadline = clock() + pending.delayMillis
+ schedulePollLocked()
+ }
+
+ private fun scheduleArmedExpiryLocked() {
+ scheduler.cancelAll()
+ scheduler.postDelayed(ARM_EXPIRY_MS) {
+ synchronized(lock) {
+ val pending = pendingCall
+ if (pending?.phase == Phase.ARMED && !isWithinArmWindow(pending)) {
+ clearLocked()
+ }
+ }
+ }
+ }
+
+ private fun schedulePollLocked() {
+ scheduler.cancelAll()
+ scheduler.postDelayed(POLL_INTERVAL_MS) { pollCallState() }
+ }
+
+ private fun pollCallState() {
+ synchronized(lock) {
+ val pending = pendingCall ?: return
+ val callState =
+ try {
+ callControl.currentCallState()
+ } catch (exception: RuntimeException) {
+ Log.w(TAG, "Unable to read screened call state", exception)
+ clearLocked()
+ return
+ }
+ when (pending.phase) {
+ Phase.ANSWERING -> {
+ when (callState) {
+ TelephonyManager.CALL_STATE_OFFHOOK -> {
+ enterHangingUpLocked(pending)
+ }
+
+ TelephonyManager.CALL_STATE_IDLE -> {
+ clearLocked()
+ }
+
+ else -> {
+ val acceptedAt = pending.acceptedAt ?: pending.armedAt
+ if (clock() - acceptedAt >= ANSWER_TIMEOUT_MS) {
+ clearLocked()
+ }
+ }
+ }
+ }
+
+ Phase.HANGING_UP -> {
+ when (callState) {
+ TelephonyManager.CALL_STATE_IDLE,
+ TelephonyManager.CALL_STATE_RINGING,
+ -> {
+ clearLocked()
+ }
+
+ else -> {
+ if (clock() >= (pending.hangUpDeadline ?: Long.MAX_VALUE)) {
+ try {
+ callControl.endCall()
+ } catch (exception: RuntimeException) {
+ Log.w(TAG, "Unable to hang up screened call", exception)
+ }
+ clearLocked()
+ }
+ }
+ }
+ }
+
+ Phase.NONE,
+ Phase.ARMED,
+ -> {
+ Unit
+ }
+ }
+ if (pendingCall?.phase == Phase.ANSWERING || pendingCall?.phase == Phase.HANGING_UP) {
+ schedulePollLocked()
+ }
+ }
+ }
+
+ private fun isWithinArmWindow(pending: PendingCall): Boolean = clock() - pending.armedAt < ARM_EXPIRY_MS
+
+ private fun clearLocked() {
+ scheduler.cancelAll()
+ pendingCall = null
+ }
+
+ private fun productionCallControl(): CallControl =
+ object : CallControl {
+ // The preceding permission gate is real, but lint does not trace this helper.
+ @SuppressLint("MissingPermission")
+ @Suppress("DEPRECATION")
+ override fun acceptRingingCallAudioOnly(): Boolean {
+ if (!hasAnswerPhoneCallsPermission()) return false
+ telecomManager().acceptRingingCall(VideoProfile.STATE_AUDIO_ONLY)
+ return true
+ }
+
+ // The preceding permission gate is real, but lint does not trace this helper.
+ @SuppressLint("MissingPermission")
+ @Suppress("DEPRECATION")
+ override fun endCall(): Boolean {
+ if (!hasAnswerPhoneCallsPermission()) return false
+ return telecomManager().endCall()
+ }
+
+ @Suppress("DEPRECATION")
+ override fun currentCallState(): Int = applicationContext?.getSystemService(TelephonyManager::class.java)?.callState ?: -1
+
+ private fun hasAnswerPhoneCallsPermission(): Boolean =
+ applicationContext?.checkSelfPermission(android.Manifest.permission.ANSWER_PHONE_CALLS) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED
+
+ private fun telecomManager(): TelecomManager =
+ requireNotNull(applicationContext) { "AnswerHangUpController is not configured" }
+ .getSystemService(TelecomManager::class.java)
+ }
+
+ private fun digitsOnly(value: String?): String = value.orEmpty().filter(Char::isDigit)
+
+ private fun numbersMatch(
+ first: String,
+ second: String,
+ ): Boolean {
+ if (first.isEmpty() && second.isEmpty()) return true
+ if (first.isEmpty() || second.isEmpty()) return false
+ if (first == second) return true
+ val shorter = if (first.length <= second.length) first else second
+ val longer = if (first.length <= second.length) second else first
+ return shorter.length >= 7 && longer.endsWith(shorter)
+ }
+
+ internal fun setTestDependencies(
+ clock: () -> Long,
+ callControl: CallControl,
+ scheduler: Scheduler,
+ ) {
+ synchronized(lock) {
+ clearLocked()
+ this.clock = clock
+ this.callControl = callControl
+ this.scheduler = scheduler
+ }
+ }
+
+ internal fun resetForTest() {
+ synchronized(lock) {
+ clearLocked()
+ applicationContext = null
+ clock = SystemClock::elapsedRealtime
+ callControl = productionCallControl()
+ scheduler = productionScheduler
+ }
+ }
+
+ internal fun phaseForTest(): Phase = synchronized(lock) { pendingCall?.phase ?: Phase.NONE }
+}
diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt
new file mode 100644
index 000000000..8a4968448
--- /dev/null
+++ b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt
@@ -0,0 +1,26 @@
+package com.sysadmindoc.callshield.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.telephony.TelephonyManager
+
+/** Forwards protected PHONE_STATE broadcasts to the pending answer-and-hang-up sequence. */
+class AnswerHangUpReceiver : BroadcastReceiver() {
+ @Suppress("DEPRECATION")
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ if (intent.action != TelephonyManager.ACTION_PHONE_STATE_CHANGED ||
+ !AnswerHangUpController.hasPendingCall()
+ ) {
+ return
+ }
+ AnswerHangUpController.onPhoneState(
+ state = intent.getStringExtra(TelephonyManager.EXTRA_STATE),
+ incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER),
+ hasIncomingNumber = intent.hasExtra(TelephonyManager.EXTRA_INCOMING_NUMBER),
+ )
+ }
+}
diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt b/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt
index deda7cc51..bbc0a07ca 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt
@@ -101,6 +101,7 @@ class CallShieldScreeningService : CallScreeningService() {
// feedback, or respond to an outgoing call.
when (callDetails.callDirection) {
Call.Details.DIRECTION_OUTGOING -> {
+ AnswerHangUpController.onOutgoingCallStarted()
handleOutgoingCall(callDetails)
return
}
@@ -119,6 +120,8 @@ class CallShieldScreeningService : CallScreeningService() {
}
}
+ AnswerHangUpController.onIncomingScreeningStarted()
+
// Run on the process-wide appScope instead of a service-scoped one.
// CallScreeningService is frequently unbound moments after we reply,
// and a service-scoped coroutine could be cancelled mid-decision —
@@ -372,14 +375,74 @@ class CallShieldScreeningService : CallScreeningService() {
val categoryAction =
CategoryCallPolicy.parseMatchSource(reason)?.action
?: CategoryCallAction.INHERIT
- val response =
- buildBlockResponse(
- prefs = prefs,
+ val silenceWins =
+ shouldSilence(
+ silentVoicemailEnabled = prefs[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false,
+ autoMuteLowConfidenceEnabled = prefs[SpamRepository.KEY_AUTOMUTE_LOW_CONFIDENCE] ?: false,
confidence = confidence,
categoryAction = categoryAction,
- silenceOnly = reason == MeetingModeChecker.MATCH_SOURCE,
)
- responseGate.respond(response)
+ val answerHangUpEnabled = prefs[SpamRepository.KEY_ANSWER_HANG_UP] ?: false
+ val answerAndHangUp =
+ if (answerHangUpEnabled && !silenceWins) {
+ try {
+ val permissionsGranted = hasAnswerHangUpPermissions()
+ shouldAnswerAndHangUp(
+ enabled = true,
+ silenceWins = false,
+ permissionsGranted = permissionsGranted,
+ busy = !permissionsGranted || isAnswerHangUpBusy(),
+ )
+ } catch (_: RuntimeException) {
+ false
+ }
+ } else {
+ false
+ }
+ val response =
+ if (answerAndHangUp) {
+ buildBlockResponse(
+ prefs = prefs,
+ confidence = confidence,
+ categoryAction = categoryAction,
+ silenceOnly = true,
+ )
+ } else {
+ // Keep the existing response construction untouched for every setting-off path.
+ buildBlockResponse(
+ prefs = prefs,
+ confidence = confidence,
+ categoryAction = categoryAction,
+ silenceOnly = reason == MeetingModeChecker.MATCH_SOURCE,
+ )
+ }
+ if (answerAndHangUp) {
+ AnswerHangUpController.configure(applicationContext)
+ val armed =
+ AnswerHangUpController.tryArm(
+ rawNumber = number,
+ delaySeconds =
+ AnswerHangUpController.clampDelaySeconds(
+ prefs[SpamRepository.KEY_HANG_UP_DELAY_SECONDS],
+ ),
+ )
+ if (armed && responseGate.hasResponded) {
+ AnswerHangUpController.disarm()
+ } else if (armed) {
+ responseGate.respond(response)
+ } else {
+ responseGate.respond(
+ buildBlockResponse(
+ prefs = prefs,
+ confidence = confidence,
+ categoryAction = categoryAction,
+ silenceOnly = reason == MeetingModeChecker.MATCH_SOURCE,
+ ),
+ )
+ }
+ } else {
+ responseGate.respond(response)
+ }
applicationScope.launch {
try {
@@ -473,6 +536,31 @@ class CallShieldScreeningService : CallScreeningService() {
}
}
+ private fun hasAnswerHangUpPermissions(): Boolean =
+ listOf(
+ android.Manifest.permission.ANSWER_PHONE_CALLS,
+ android.Manifest.permission.READ_PHONE_STATE,
+ android.Manifest.permission.READ_CALL_LOG,
+ ).all { permission ->
+ checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun isAnswerHangUpBusy(): Boolean =
+ try {
+ @Suppress("DEPRECATION")
+ val callState = getSystemService(android.telephony.TelephonyManager::class.java)?.callState
+ val telephonyBusy = callState == android.telephony.TelephonyManager.CALL_STATE_OFFHOOK
+ if (telephonyBusy || AnswerHangUpController.isBusy()) {
+ true
+ } else {
+ val audioMode = getSystemService(android.media.AudioManager::class.java)?.mode
+ audioMode == android.media.AudioManager.MODE_IN_CALL ||
+ audioMode == android.media.AudioManager.MODE_IN_COMMUNICATION
+ }
+ } catch (_: SecurityException) {
+ true
+ }
+
private fun handleDirectBootCall(
callDetails: Call.Details,
responseGate: ScreeningResponseGate,
@@ -628,6 +716,13 @@ class CallShieldScreeningService : CallScreeningService() {
}
}
+ internal fun shouldAnswerAndHangUp(
+ enabled: Boolean,
+ silenceWins: Boolean,
+ permissionsGranted: Boolean,
+ busy: Boolean,
+ ): Boolean = enabled && !silenceWins && permissionsGranted && !busy
+
fun shouldSuppressAfterCallFeedback(matchSource: String): Boolean = matchSource == "emergency_callback"
}
diff --git a/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt b/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt
index 0d1e62061..4033bf8ee 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt
@@ -53,6 +53,7 @@ import com.sysadmindoc.callshield.domain.model.SpamCheckResult
import com.sysadmindoc.callshield.domain.usecase.ExportLogsUseCase
import com.sysadmindoc.callshield.domain.usecase.ManageBlocklistUseCase
import com.sysadmindoc.callshield.domain.usecase.SyncDatabaseUseCase
+import com.sysadmindoc.callshield.service.AnswerHangUpController
import com.sysadmindoc.callshield.service.AppUpdateWorker
import com.sysadmindoc.callshield.service.CallLogScanner
import com.sysadmindoc.callshield.service.NotificationHelper
@@ -484,6 +485,13 @@ class MainViewModel
com.sysadmindoc.callshield.data.NotificationScreeningSources.defaultEnabledPackages,
)
val silentVoicemailEnabled = repo.silentVoicemailEnabled.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+ val answerHangUpEnabled = repo.answerHangUpEnabled.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+ val hangUpDelaySeconds =
+ repo.hangUpDelaySeconds.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5000),
+ AnswerHangUpController.DEFAULT_DELAY_SECONDS,
+ )
val pushAlertEnabled = repo.pushAlertEnabled.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val pushAlertDisabledPackages =
repo.pushAlertDisabledPackages
@@ -1184,6 +1192,10 @@ class MainViewModel
fun setSilentVoicemail(v: Boolean) = viewModelScope.launch { repo.setSilentVoicemail(v) }
+ fun setAnswerHangUpEnabled(v: Boolean) = viewModelScope.launch { repo.setAnswerHangUpEnabled(v) }
+
+ fun setHangUpDelaySeconds(seconds: Int) = viewModelScope.launch { repo.setHangUpDelaySeconds(seconds) }
+
fun setPushAlert(v: Boolean) = viewModelScope.launch { repo.setPushAlert(v) }
/** Per-package opt-in/out for the A3 allowlist editor. */
diff --git a/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt
index bde78dadb..638e5d9ab 100644
--- a/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt
@@ -82,6 +82,7 @@ import com.sysadmindoc.callshield.data.repository.EMERGENCY_CALLBACK_WINDOW_MINU
import com.sysadmindoc.callshield.data.repository.FREQ_THRESHOLD_MAX
import com.sysadmindoc.callshield.data.repository.FREQ_THRESHOLD_MIN
import com.sysadmindoc.callshield.permissions.CallShieldPermissions
+import com.sysadmindoc.callshield.service.AnswerHangUpController
import com.sysadmindoc.callshield.ui.AppLanguage
import com.sysadmindoc.callshield.ui.DurationTtsText
import com.sysadmindoc.callshield.ui.MainViewModel
@@ -168,6 +169,8 @@ fun SettingsScreen(viewModel: MainViewModel) {
val postCallScreen by viewModel.postCallScreenEnabled.collectAsStateWithLifecycle()
val notificationScreeningPackages by viewModel.notificationScreeningPackages.collectAsStateWithLifecycle()
val silentVoicemail by viewModel.silentVoicemailEnabled.collectAsStateWithLifecycle()
+ val answerHangUpEnabled by viewModel.answerHangUpEnabled.collectAsStateWithLifecycle()
+ val hangUpDelaySeconds by viewModel.hangUpDelaySeconds.collectAsStateWithLifecycle()
val pushAlertEnabled by viewModel.pushAlertEnabled.collectAsStateWithLifecycle()
val pushAlertDisabledPackages by viewModel.pushAlertDisabledPackages.collectAsStateWithLifecycle()
val externalBlocklists by viewModel.externalBlocklistSubscriptions.collectAsStateWithLifecycle()
@@ -227,6 +230,22 @@ fun SettingsScreen(viewModel: MainViewModel) {
smsEnabled = blockSms,
)
}
+ val answerHangUpPermissions =
+ remember {
+ listOf(
+ Manifest.permission.ANSWER_PHONE_CALLS,
+ Manifest.permission.READ_PHONE_STATE,
+ Manifest.permission.READ_CALL_LOG,
+ )
+ }
+ val answerHangUpPermissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
+ val allGranted =
+ answerHangUpPermissions.all { permission ->
+ grants[permission] == true || CallShieldPermissions.isPermissionGranted(context, permission)
+ }
+ viewModel.setAnswerHangUpEnabled(allGranted)
+ }
val notificationLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
notificationsGranted = CallShieldPermissions.hasNotificationPermission(context)
@@ -778,6 +797,43 @@ fun SettingsScreen(viewModel: MainViewModel) {
Icons.Default.Voicemail,
autoMuteLowConfidence,
) { viewModel.setAutoMuteLowConfidence(it) }
+ GradientDivider()
+ SettingsToggle(
+ stringResource(R.string.settings_answer_hang_up),
+ stringResource(R.string.settings_answer_hang_up_desc),
+ Icons.Default.CallEnd,
+ answerHangUpEnabled,
+ ) { enabled ->
+ if (!enabled) {
+ viewModel.setAnswerHangUpEnabled(false)
+ return@SettingsToggle
+ }
+ val missingPermissions =
+ answerHangUpPermissions.filterNot { permission ->
+ CallShieldPermissions.isPermissionGranted(context, permission)
+ }
+ if (missingPermissions.isEmpty()) {
+ viewModel.setAnswerHangUpEnabled(true)
+ } else {
+ answerHangUpPermissionLauncher.launch(missingPermissions.toTypedArray())
+ }
+ }
+ if (answerHangUpEnabled) {
+ Spacer(Modifier.height(8.dp))
+ SettingsNumberStepper(
+ label = stringResource(R.string.settings_hang_up_delay),
+ valueText =
+ pluralStringResource(
+ R.plurals.settings_hang_up_delay_value,
+ hangUpDelaySeconds,
+ hangUpDelaySeconds,
+ ),
+ value = hangUpDelaySeconds,
+ minValue = AnswerHangUpController.MIN_DELAY_SECONDS,
+ maxValue = AnswerHangUpController.MAX_DELAY_SECONDS,
+ onValueChange = viewModel::setHangUpDelaySeconds,
+ )
+ }
}
// Feature 9: Time-based blocking
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d948ee639..41107f5e1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -662,6 +662,9 @@
Done
Silent voicemail mode
Blocked calls go to voicemail silently. Your phone stays quiet while callers hear normal ringing.
+ Answer & hang up blocked calls
+ Instead of rejecting, CallShield answers blocked calls and hangs up so they can\'t leave a voicemail. Silent Voicemail and low-confidence auto-mute still send calls to voicemail. If you\'re already on a call, the blocked call is rejected. Your screen may light up briefly. Trade-offs: answering tells autodialers the number is live, so some callers redial; some carriers need a longer delay to skip voicemail, raise it if spam still lands in voicemail.
+ Hang up after
Quiet hours
Block unknowns during quiet hours
Block all non-contact calls during set hours
@@ -1367,6 +1370,10 @@
- %1$d call
- %1$d calls
+
+ - %d second
+ - %d seconds
+
- %1$d day
- %1$d days
diff --git a/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt b/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt
new file mode 100644
index 000000000..7699d424f
--- /dev/null
+++ b/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt
@@ -0,0 +1,53 @@
+package com.sysadmindoc.callshield.data
+
+import com.sysadmindoc.callshield.data.BackupRestore.Backup
+import com.sysadmindoc.callshield.data.BackupRestore.BackupSettings
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BackupRestoreAnswerHangUpTest {
+ @Test
+ fun `answer hang up settings round trip through backup JSON`() {
+ val backup =
+ Backup(
+ settings =
+ BackupSettings(
+ answerHangUpEnabled = true,
+ hangUpDelaySeconds = 7,
+ ),
+ )
+
+ val validation = BackupRestore.validateBackupJson(BackupRestore.backupToJson(backup))
+
+ assertTrue(validation is BackupRestore.RestoreValidation.Valid)
+ val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings
+ assertEquals(true, settings?.answerHangUpEnabled)
+ assertEquals(7, settings?.hangUpDelaySeconds)
+ }
+
+ @Test
+ fun `older backup without answer hang up settings restores defaults`() {
+ val validation =
+ BackupRestore.validateBackupJson(
+ """{"version":9,"app":"CallShield","settings":{"blockCallsEnabled":false}}""",
+ )
+
+ assertTrue(validation is BackupRestore.RestoreValidation.Valid)
+ val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings
+ assertFalse(settings?.answerHangUpEnabled ?: true)
+ assertEquals(1, settings?.hangUpDelaySeconds)
+ }
+
+ @Test
+ fun `restored hang up delay is clamped`() {
+ val backup = Backup(settings = BackupSettings(hangUpDelaySeconds = 100))
+
+ val validation = BackupRestore.validateBackupJson(BackupRestore.backupToJson(backup))
+
+ assertTrue(validation is BackupRestore.RestoreValidation.Valid)
+ val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings
+ assertEquals(10, settings?.hangUpDelaySeconds)
+ }
+}
diff --git a/app/src/test/java/com/sysadmindoc/callshield/data/CallbackDetectorAnswerHangUpTest.kt b/app/src/test/java/com/sysadmindoc/callshield/data/CallbackDetectorAnswerHangUpTest.kt
new file mode 100644
index 000000000..c29a54279
--- /dev/null
+++ b/app/src/test/java/com/sysadmindoc/callshield/data/CallbackDetectorAnswerHangUpTest.kt
@@ -0,0 +1,74 @@
+package com.sysadmindoc.callshield.data
+
+import android.provider.CallLog
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CallbackDetectorAnswerHangUpTest {
+ @Test
+ fun `repeated urgent query remains unchanged when answer hang up is off`() {
+ val query =
+ CallbackDetector.buildRepeatedUrgentCallQuery(
+ nowMillis = 120_000L,
+ windowMinutes = 5,
+ last7Digits = "5551234",
+ minIncomingDurationSeconds = 0,
+ )
+
+ assertEquals(
+ "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?",
+ query.selection,
+ )
+ assertArrayEquals(
+ arrayOf(
+ CallLog.Calls.INCOMING_TYPE.toString(),
+ CallLog.Calls.MISSED_TYPE.toString(),
+ (120_000L - 300_000L).toString(),
+ "%5551234",
+ ),
+ query.selectionArgs,
+ )
+ }
+
+ @Test
+ fun `answered caller query uses fifteen second floor while answer hang up is on`() {
+ val query =
+ CallbackDetector.buildAnsweredCallerQuery(
+ nowMillis = 3_000_000_000L,
+ windowDays = 30,
+ minDurationSeconds = CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS,
+ last7Digits = "5551234",
+ )
+
+ assertEquals("15", query.selectionArgs[2])
+ }
+
+ @Test
+ fun `repeated urgent query preserves missed calls and filters short incoming calls`() {
+ val query =
+ CallbackDetector.buildRepeatedUrgentCallQuery(
+ nowMillis = 120_000L,
+ windowMinutes = 5,
+ last7Digits = "5551234",
+ minIncomingDurationSeconds = CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS,
+ )
+
+ assertEquals(
+ "(${CallLog.Calls.TYPE} = ? OR (${CallLog.Calls.TYPE} = ? AND " +
+ "${CallLog.Calls.DURATION} >= ?)) AND ${CallLog.Calls.DATE} > ? " +
+ "AND ${CallLog.Calls.NUMBER} LIKE ?",
+ query.selection,
+ )
+ assertArrayEquals(
+ arrayOf(
+ CallLog.Calls.MISSED_TYPE.toString(),
+ CallLog.Calls.INCOMING_TYPE.toString(),
+ "15",
+ (120_000L - 300_000L).toString(),
+ "%5551234",
+ ),
+ query.selectionArgs,
+ )
+ }
+}
diff --git a/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt b/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt
index e125316f7..0051058b9 100644
--- a/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt
+++ b/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt
@@ -11,11 +11,20 @@ import org.junit.Test
class CallShieldPermissionsTest {
@Test
fun `first-run runtime bundle covers core and compatibility grants`() {
+ // ANSWER_PHONE_CALLS is requested only when "Answer & hang up
+ // blocked calls" is enabled in Settings, never during onboarding.
assertEquals(
- (CallShieldPermissions.corePermissions + CallShieldPermissions.compatibilityPermissions).toSet(),
+ (
+ CallShieldPermissions.corePermissions +
+ listOf(android.Manifest.permission.READ_PHONE_STATE)
+ ).toSet(),
CallShieldPermissions.onboardingRuntimePermissions.toSet(),
)
- assertEquals(6, CallShieldPermissions.onboardingRuntimePermissions.size)
+ assertEquals(5, CallShieldPermissions.onboardingRuntimePermissions.size)
+ assertFalse(
+ android.Manifest.permission.ANSWER_PHONE_CALLS in
+ CallShieldPermissions.onboardingRuntimePermissions,
+ )
}
@Test
diff --git a/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt b/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt
new file mode 100644
index 000000000..e6c6038f3
--- /dev/null
+++ b/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt
@@ -0,0 +1,344 @@
+package com.sysadmindoc.callshield.service
+
+import android.content.Intent
+import android.telephony.TelephonyManager
+import androidx.test.core.app.ApplicationProvider
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class AnswerHangUpControllerTest {
+ private lateinit var clock: FakeClock
+ private lateinit var callControl: FakeCallControl
+ private lateinit var scheduler: FakeScheduler
+
+ @Before
+ fun setUp() {
+ clock = FakeClock()
+ callControl = FakeCallControl()
+ scheduler = FakeScheduler(clock)
+ AnswerHangUpController.setTestDependencies(clock::now, callControl, scheduler)
+ }
+
+ @After
+ fun tearDown() {
+ AnswerHangUpController.resetForTest()
+ }
+
+ @Test
+ fun `matching ringing accepts audio only once and duplicate ringing is ignored`() {
+ arm("5551234567", 1)
+
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+
+ assertEquals(1, callControl.audioOnlyAcceptCalls)
+ assertEquals(AnswerHangUpController.Phase.ANSWERING, AnswerHangUpController.phaseForTest())
+ }
+
+ @Test
+ fun `different ringing number does not answer`() {
+ arm("5551234567", 1)
+
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5557654321")
+
+ assertEquals(0, callControl.audioOnlyAcceptCalls)
+ assertTrue(AnswerHangUpController.hasPendingCall())
+ }
+
+ @Test
+ fun `ringing after arm expiry does not answer`() {
+ arm("5551234567", 1)
+ clock.advanceBy(5_000)
+
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+
+ assertEquals(0, callControl.audioOnlyAcceptCalls)
+ assertFalse(AnswerHangUpController.hasPendingCall())
+ }
+
+ @Test
+ fun `offhook ends exactly once at the delay deadline`() {
+ arm("5551234567", 2)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567")
+ callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK
+
+ repeat(7) { scheduler.advanceBy(250) }
+ assertEquals(0, callControl.endCalls)
+
+ scheduler.advanceBy(250)
+ assertEquals(1, callControl.endCalls)
+ scheduler.advanceBy(1_000)
+ assertEquals(1, callControl.endCalls)
+ }
+
+ @Test
+ fun `idle before deadline never ends the call`() {
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_IDLE, null)
+ callControl.currentState = TelephonyManager.CALL_STATE_IDLE
+
+ scheduler.advanceBy(2_000)
+
+ assertEquals(0, callControl.endCalls)
+ assertFalse(AnswerHangUpController.hasPendingCall())
+ }
+
+ @Test
+ fun `stale ringing and idle broadcasts during hangup do not prevent the deadline hangup`() {
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567")
+ callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK
+
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5557654321")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_IDLE, null)
+ repeat(4) { scheduler.advanceBy(250) }
+
+ assertEquals(1, callControl.endCalls)
+ assertFalse(AnswerHangUpController.hasPendingCall())
+ }
+
+ @Test
+ fun `polled offhook without broadcast still hangs up`() {
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK
+
+ scheduler.advanceBy(250)
+ repeat(4) { scheduler.advanceBy(250) }
+
+ assertEquals(1, callControl.endCalls)
+ }
+
+ @Test
+ fun `withheld number accepts null ringing number`() {
+ arm(null, 1)
+
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, null)
+
+ assertEquals(1, callControl.audioOnlyAcceptCalls)
+ }
+
+ @Test
+ fun `north american prefix variants match on digits`() {
+ arm("+1 (555) 123-4567", 1)
+
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+
+ assertEquals(1, callControl.audioOnlyAcceptCalls)
+ }
+
+ @Test
+ fun `new incoming screening clears an unaccepted arm`() {
+ arm("5551234567", 1)
+
+ AnswerHangUpController.onIncomingScreeningStarted()
+
+ assertFalse(AnswerHangUpController.hasPendingCall())
+ }
+
+ @Test
+ @Suppress("DEPRECATION")
+ fun `numberless ringing duplicate does not accept but numbered ringing does`() {
+ arm("5551234567", 1)
+ val context = ApplicationProvider.getApplicationContext()
+
+ AnswerHangUpReceiver().onReceive(
+ context,
+ Intent(TelephonyManager.ACTION_PHONE_STATE_CHANGED).apply {
+ putExtra(TelephonyManager.EXTRA_STATE, TelephonyManager.EXTRA_STATE_RINGING)
+ },
+ )
+ assertEquals(0, callControl.audioOnlyAcceptCalls)
+
+ AnswerHangUpReceiver().onReceive(
+ context,
+ Intent(TelephonyManager.ACTION_PHONE_STATE_CHANGED).apply {
+ putExtra(TelephonyManager.EXTRA_STATE, TelephonyManager.EXTRA_STATE_RINGING)
+ putExtra(TelephonyManager.EXTRA_INCOMING_NUMBER, "5551234567")
+ },
+ )
+ assertEquals(1, callControl.audioOnlyAcceptCalls)
+ }
+
+ @Test
+ fun `extra present empty number accepts a withheld call`() {
+ arm(null, 1)
+
+ AnswerHangUpController.onPhoneState(
+ state = TelephonyManager.EXTRA_STATE_RINGING,
+ incomingNumber = null,
+ hasIncomingNumber = true,
+ )
+
+ assertEquals(1, callControl.audioOnlyAcceptCalls)
+ }
+
+ @Test
+ fun `outgoing call clears every phase without ending a call`() {
+ arm("5551234567", 1)
+ AnswerHangUpController.onOutgoingCallStarted()
+ assertFalse(AnswerHangUpController.hasPendingCall())
+
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ AnswerHangUpController.onOutgoingCallStarted()
+ assertFalse(AnswerHangUpController.hasPendingCall())
+
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567")
+ AnswerHangUpController.onOutgoingCallStarted()
+ assertFalse(AnswerHangUpController.hasPendingCall())
+ assertEquals(0, callControl.endCalls)
+ }
+
+ @Test
+ fun `try arm refuses while answering or hanging up`() {
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ assertFalse(AnswerHangUpController.tryArm("5557654321", 1))
+ assertEquals(AnswerHangUpController.Phase.ANSWERING, AnswerHangUpController.phaseForTest())
+
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567")
+ assertFalse(AnswerHangUpController.tryArm("5557654321", 1))
+ assertEquals(AnswerHangUpController.Phase.HANGING_UP, AnswerHangUpController.phaseForTest())
+ }
+
+ @Test
+ fun `call control runtime exceptions clear without propagating`() {
+ callControl.throwOnAccept = true
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ assertFalse(AnswerHangUpController.hasPendingCall())
+
+ callControl.throwOnAccept = false
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ callControl.throwOnStateRead = true
+ scheduler.advanceBy(250)
+ assertFalse(AnswerHangUpController.hasPendingCall())
+
+ callControl.throwOnStateRead = false
+ arm("5551234567", 1)
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567")
+ AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567")
+ callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK
+ callControl.throwOnEnd = true
+ repeat(4) { scheduler.advanceBy(250) }
+ assertFalse(AnswerHangUpController.hasPendingCall())
+ }
+
+ @Test
+ @Suppress("DEPRECATION")
+ fun `receiver ignores an action other than phone state`() {
+ arm("5551234567", 1)
+ val intent =
+ Intent("com.sysadmindoc.callshield.UNRELATED").apply {
+ putExtra(TelephonyManager.EXTRA_STATE, TelephonyManager.EXTRA_STATE_RINGING)
+ putExtra(TelephonyManager.EXTRA_INCOMING_NUMBER, "5551234567")
+ }
+
+ AnswerHangUpReceiver().onReceive(ApplicationProvider.getApplicationContext(), intent)
+
+ assertEquals(0, callControl.audioOnlyAcceptCalls)
+ assertEquals(AnswerHangUpController.Phase.ARMED, AnswerHangUpController.phaseForTest())
+ }
+
+ @Test
+ fun `delay clamp enforces supported range`() {
+ assertEquals(1, AnswerHangUpController.clampDelaySeconds(0))
+ assertEquals(10, AnswerHangUpController.clampDelaySeconds(99))
+ assertEquals(1, AnswerHangUpController.clampDelaySeconds(null))
+ }
+
+ private fun arm(
+ number: String?,
+ delaySeconds: Int,
+ ) {
+ assertTrue(AnswerHangUpController.tryArm(number, delaySeconds))
+ }
+
+ private class FakeClock {
+ private var currentMillis = 0L
+
+ fun now(): Long = currentMillis
+
+ fun advanceBy(millis: Long) {
+ currentMillis += millis
+ }
+ }
+
+ private class FakeCallControl : AnswerHangUpController.CallControl {
+ var audioOnlyAcceptCalls = 0
+ var endCalls = 0
+ var currentState = TelephonyManager.CALL_STATE_RINGING
+ var throwOnAccept = false
+ var throwOnEnd = false
+ var throwOnStateRead = false
+
+ override fun acceptRingingCallAudioOnly(): Boolean {
+ if (throwOnAccept) error("accept")
+ audioOnlyAcceptCalls += 1
+ return true
+ }
+
+ override fun endCall(): Boolean {
+ if (throwOnEnd) error("end")
+ endCalls += 1
+ return true
+ }
+
+ override fun currentCallState(): Int {
+ if (throwOnStateRead) error("state")
+ return currentState
+ }
+ }
+
+ private class FakeScheduler(
+ private val clock: FakeClock,
+ ) : AnswerHangUpController.Scheduler {
+ private data class ScheduledTask(
+ val dueAt: Long,
+ val task: () -> Unit,
+ )
+
+ private val scheduled = mutableListOf()
+
+ override fun postDelayed(
+ delayMillis: Long,
+ task: () -> Unit,
+ ) {
+ scheduled += ScheduledTask(clock.now() + delayMillis, task)
+ }
+
+ override fun cancelAll() {
+ scheduled.clear()
+ }
+
+ fun advanceBy(millis: Long) {
+ clock.advanceBy(millis)
+ runDueTasks()
+ }
+
+ private fun runDueTasks() {
+ while (true) {
+ val next = scheduled.filter { it.dueAt <= clock.now() }.minByOrNull { it.dueAt } ?: return
+ scheduled.remove(next)
+ next.task()
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/com/sysadmindoc/callshield/service/CallShieldScreeningServiceAnswerHangUpTest.kt b/app/src/test/java/com/sysadmindoc/callshield/service/CallShieldScreeningServiceAnswerHangUpTest.kt
new file mode 100644
index 000000000..5c04b80c8
--- /dev/null
+++ b/app/src/test/java/com/sysadmindoc/callshield/service/CallShieldScreeningServiceAnswerHangUpTest.kt
@@ -0,0 +1,27 @@
+package com.sysadmindoc.callshield.service
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CallShieldScreeningServiceAnswerHangUpTest {
+ @Test
+ fun `answer and hang up decision has full truth table`() {
+ listOf(false, true).forEach { enabled ->
+ listOf(false, true).forEach { silenceWins ->
+ listOf(false, true).forEach { permissionsGranted ->
+ listOf(false, true).forEach { busy ->
+ assertEquals(
+ enabled && !silenceWins && permissionsGranted && !busy,
+ CallShieldScreeningService.shouldAnswerAndHangUp(
+ enabled = enabled,
+ silenceWins = silenceWins,
+ permissionsGranted = permissionsGranted,
+ busy = busy,
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/settings-gradle.lockfile b/settings-gradle.lockfile
index 709a43f74..968892a98 100644
--- a/settings-gradle.lockfile
+++ b/settings-gradle.lockfile
@@ -1,4 +1,4 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-empty=incomingCatalogForLibs0
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+empty=incomingCatalogForLibs0