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