├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── menu │ │ │ │ ├── menu_rule_context.xml │ │ │ │ ├── menu_log_viewer.xml │ │ │ │ └── menu_log_context.xml │ │ │ ├── values-night │ │ │ │ └── colors.xml │ │ │ ├── drawable-night │ │ │ │ ├── ic_check_green_24dp.xml │ │ │ │ ├── ic_block_red_24dp.xml │ │ │ │ ├── ic_drag_indicator_18dp.xml │ │ │ │ └── ic_drag_indicator_disabled_18dp.xml │ │ │ ├── drawable │ │ │ │ ├── ic_check_green_24dp.xml │ │ │ │ ├── ic_error_outline_black_24dp.xml │ │ │ │ ├── ic_block_red_24dp.xml │ │ │ │ ├── ic_drag_indicator_18dp.xml │ │ │ │ ├── ic_drag_indicator_disabled_18dp.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── values │ │ │ │ ├── dimens.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── styles.xml │ │ │ │ └── strings.xml │ │ │ ├── layout │ │ │ │ ├── form_rule_area_code.xml │ │ │ │ ├── activity_log_list.xml │ │ │ │ ├── form_rule_match.xml │ │ │ │ ├── activity_rule_list.xml │ │ │ │ ├── content_log_entity.xml │ │ │ │ ├── form_rule.xml │ │ │ │ └── content_rule_entity.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── novyr │ │ │ │ └── callfilter │ │ │ │ ├── model │ │ │ │ ├── Contact.java │ │ │ │ ├── Log.java │ │ │ │ └── Rule.java │ │ │ │ ├── formatter │ │ │ │ ├── DateFormatter.java │ │ │ │ ├── MessageFormatter.java │ │ │ │ ├── LogDateFormatter.java │ │ │ │ └── LogMessageFormatter.java │ │ │ │ ├── permissions │ │ │ │ ├── NotificationHandlerInterface.java │ │ │ │ ├── checker │ │ │ │ │ ├── CheckerWithErrorsInterface.java │ │ │ │ │ ├── CheckerInterface.java │ │ │ │ │ ├── CallScreeningRoleChecker.java │ │ │ │ │ └── AndroidPermissionChecker.java │ │ │ │ └── PermissionChecker.java │ │ │ │ ├── telephony │ │ │ │ ├── HandlerInterface.java │ │ │ │ ├── HandlerFactory.java │ │ │ │ ├── AndroidPieHandler.java │ │ │ │ └── AndroidLegacyHandler.java │ │ │ │ ├── db │ │ │ │ ├── entity │ │ │ │ │ ├── enums │ │ │ │ │ │ ├── RuleAction.java │ │ │ │ │ │ ├── LogAction.java │ │ │ │ │ │ └── RuleType.java │ │ │ │ │ ├── LogEntity.java │ │ │ │ │ └── RuleEntity.java │ │ │ │ ├── dao │ │ │ │ │ ├── LogDao.java │ │ │ │ │ └── RuleDao.java │ │ │ │ ├── converter │ │ │ │ │ ├── RuleTypeConverter.java │ │ │ │ │ ├── LogActionConverter.java │ │ │ │ │ ├── RuleActionConverter.java │ │ │ │ │ └── CalendarConverter.java │ │ │ │ ├── LogRepository.java │ │ │ │ ├── RuleRepository.java │ │ │ │ └── CallFilterDatabase.java │ │ │ │ ├── rules │ │ │ │ ├── RuleHandlerInterface.java │ │ │ │ ├── UnmatchedRuleHandler.java │ │ │ │ ├── PrivateRuleHandler.java │ │ │ │ ├── exception │ │ │ │ │ └── InvalidValueException.java │ │ │ │ ├── RuleHandlerWithFormInterface.java │ │ │ │ ├── VerificationFailedRuleHandler.java │ │ │ │ ├── VerificationPassedRuleHandler.java │ │ │ │ ├── RecognizedRuleHandler.java │ │ │ │ ├── UnrecognizedRuleHandler.java │ │ │ │ ├── RuleHandlerManager.java │ │ │ │ ├── AreaCodeRuleHandler.java │ │ │ │ └── MatchRuleHandler.java │ │ │ │ ├── CallFilterApplication.java │ │ │ │ ├── RuleCheckerFactory.java │ │ │ │ ├── viewmodel │ │ │ │ ├── LogViewModel.java │ │ │ │ └── RuleViewModel.java │ │ │ │ ├── ui │ │ │ │ ├── rulelist │ │ │ │ │ ├── RuleViewHolderFactory.java │ │ │ │ │ ├── RuleListAdapterTouchHelper.java │ │ │ │ │ ├── RuleListAdapter.java │ │ │ │ │ ├── RuleListActivity.java │ │ │ │ │ ├── RuleListActionHelper.java │ │ │ │ │ └── RuleViewHolder.java │ │ │ │ └── loglist │ │ │ │ │ ├── LogListAdapter.java │ │ │ │ │ ├── LogViewHolder.java │ │ │ │ │ ├── LogListMenuHandler.java │ │ │ │ │ └── LogListActivity.java │ │ │ │ ├── RuleChecker.java │ │ │ │ ├── AreaCodeExtractor.java │ │ │ │ ├── CallDetails.java │ │ │ │ ├── CallFilterService.java │ │ │ │ ├── ContactFinder.java │ │ │ │ └── CallReceiver.java │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── novyr │ │ │ └── callfilter │ │ │ ├── rules │ │ │ ├── PrivateRuleHandlerTest.java │ │ │ ├── VerificationFailedRuleHandlerTest.java │ │ │ ├── VerificationPassedRuleHandlerTest.java │ │ │ ├── RecognizedRuleHandlerTest.java │ │ │ ├── UnrecognizedRuleHandlerTest.java │ │ │ ├── AreaCodeRuleHandlerTest.java │ │ │ └── MatchRuleHandlerTest.java │ │ │ ├── db │ │ │ └── converter │ │ │ │ ├── RuleTypeConverterTest.java │ │ │ │ ├── RuleActionConverterTest.java │ │ │ │ ├── LogActionConverterTest.java │ │ │ │ └── CalendarConverterTest.java │ │ │ ├── AreaCodeExtractorTest.java │ │ │ ├── formatter │ │ │ ├── LogDateFormatterTest.java │ │ │ └── LogMessageFormatterTest.java │ │ │ └── RuleCheckerTest.java │ └── androidTest │ │ └── java │ │ └── com │ │ └── novyr │ │ └── callfilter │ │ └── db │ │ ├── LiveDataTestUtil.java │ │ └── dao │ │ └── LogDaoTest.java ├── proguard-rules.pro ├── schemas │ └── com.novyr.callfilter.db.CallFilterDatabase │ │ ├── 1.json │ │ └── 2.json └── build.gradle ├── settings.gradle ├── .docs ├── log_list.png ├── rule_edit.png └── rule_list.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── README.md ├── LICENSE ├── gradle.properties ├── .scripts ├── README.md ├── call-emulator.ps1 ├── install-as-system-app.ps1 └── Run as system app.run.xml └── gradlew.bat /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | rootProject.name = "AndroidCallFilter" 3 | -------------------------------------------------------------------------------- /.docs/log_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/.docs/log_list.png -------------------------------------------------------------------------------- /.docs/rule_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/.docs/rule_edit.png -------------------------------------------------------------------------------- /.docs/rule_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/.docs/rule_list.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-perri/android-call-filter/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/model/Contact.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.model; 2 | 3 | public interface Contact { 4 | String getId(); 5 | 6 | String getName(); 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/formatter/DateFormatter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.formatter; 2 | 3 | import com.novyr.callfilter.db.entity.LogEntity; 4 | 5 | public interface DateFormatter { 6 | String formatDate(LogEntity entity); 7 | } 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/formatter/MessageFormatter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.formatter; 2 | 3 | import com.novyr.callfilter.db.entity.LogEntity; 4 | 5 | public interface MessageFormatter { 6 | String formatMessage(LogEntity entity); 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/permissions/NotificationHandlerInterface.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.permissions; 2 | 3 | import java.util.List; 4 | 5 | public interface NotificationHandlerInterface { 6 | void setErrors(List errors); 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/telephony/HandlerInterface.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.telephony; 2 | 3 | public interface HandlerInterface { 4 | /** 5 | * @return Whether the call was successfully ended 6 | */ 7 | boolean endCall(); 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/permissions/checker/CheckerWithErrorsInterface.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.permissions.checker; 2 | 3 | import androidx.annotation.Nullable; 4 | 5 | import java.util.List; 6 | 7 | public interface CheckerWithErrorsInterface { 8 | @Nullable 9 | List getErrors(); 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_rule_context.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | #BDBDBD 5 | 6 | #81C784 7 | #E57373 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/entity/enums/RuleAction.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.entity.enums; 2 | 3 | public enum RuleAction { 4 | BLOCK(1), 5 | ALLOW(2); 6 | 7 | private final int mCode; 8 | 9 | RuleAction(int code) { 10 | mCode = code; 11 | } 12 | 13 | public int getCode() { 14 | return mCode; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/model/Log.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.model; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | 6 | import java.util.Calendar; 7 | 8 | public interface Log { 9 | int getId(); 10 | 11 | @NonNull 12 | Calendar getCreated(); 13 | 14 | @Nullable 15 | String getNumber(); 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/RuleHandlerInterface.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | 6 | import com.novyr.callfilter.CallDetails; 7 | 8 | public interface RuleHandlerInterface { 9 | boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue); 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-night/ic_check_green_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/entity/enums/LogAction.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.entity.enums; 2 | 3 | public enum LogAction { 4 | BLOCKED(0), 5 | ALLOWED(1), 6 | FAILED(2); 7 | 8 | private final int code; 9 | 10 | LogAction(int code) { 11 | this.code = code; 12 | } 13 | 14 | public int getCode() { 15 | return code; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check_green_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/UnmatchedRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | 6 | import com.novyr.callfilter.CallDetails; 7 | 8 | public class UnmatchedRuleHandler implements RuleHandlerInterface { 9 | @Override 10 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 11 | return true; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_log_viewer.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16dp 4 | 16dp 5 | 8dp 6 | 16dp 7 | 2dp 8 | 10dp 9 | 16dp 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/PrivateRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | 6 | import com.novyr.callfilter.CallDetails; 7 | 8 | public class PrivateRuleHandler implements RuleHandlerInterface { 9 | @Override 10 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 11 | return details.getPhoneNumber() == null; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/telephony/HandlerFactory.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.telephony; 2 | 3 | import android.content.Context; 4 | import android.os.Build; 5 | 6 | public class HandlerFactory { 7 | public static HandlerInterface create(Context context) { 8 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 9 | return new AndroidPieHandler(context); 10 | } 11 | 12 | return new AndroidLegacyHandler(context); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/formatter/LogDateFormatter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.formatter; 2 | 3 | import com.novyr.callfilter.db.entity.LogEntity; 4 | 5 | import java.text.DateFormat; 6 | 7 | public class LogDateFormatter implements DateFormatter { 8 | public String formatDate(LogEntity entity) { 9 | DateFormat format = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT); 10 | return format.format(entity.getCreated().getTime()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | #757575 5 | 6 | @color/log_list_message 7 | @color/log_list_date 8 | 9 | #388E3C 10 | #B71C1C 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_log_context.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/exception/InvalidValueException.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules.exception; 2 | 3 | import androidx.annotation.StringRes; 4 | 5 | public class InvalidValueException extends Throwable { 6 | private final int mLabelResource; 7 | 8 | public InvalidValueException(@StringRes int labelResource) { 9 | mLabelResource = labelResource; 10 | } 11 | 12 | @StringRes 13 | public int getLabelResource() { 14 | return mLabelResource; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_error_outline_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/model/Rule.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.model; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | 6 | import com.novyr.callfilter.db.entity.enums.RuleAction; 7 | import com.novyr.callfilter.db.entity.enums.RuleType; 8 | 9 | public interface Rule { 10 | int getId(); 11 | 12 | @NonNull 13 | RuleType getType(); 14 | 15 | @Nullable 16 | String getValue(); 17 | 18 | @NonNull 19 | RuleAction getAction(); 20 | 21 | boolean isEnabled(); 22 | 23 | int getOrder(); 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-night/ic_block_red_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_block_red_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/RuleHandlerWithFormInterface.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.view.View; 4 | 5 | import androidx.annotation.LayoutRes; 6 | 7 | import com.novyr.callfilter.db.entity.RuleEntity; 8 | import com.novyr.callfilter.rules.exception.InvalidValueException; 9 | 10 | public interface RuleHandlerWithFormInterface { 11 | @LayoutRes 12 | int getEditDialogLayout(); 13 | 14 | void loadFormValues(View view, RuleEntity rule); 15 | 16 | void saveFormValues(View view, RuleEntity rule) throws InvalidValueException; 17 | } 18 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/rules/PrivateRuleHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import com.novyr.callfilter.CallDetails; 4 | 5 | import org.junit.Test; 6 | 7 | import static org.junit.Assert.assertFalse; 8 | import static org.junit.Assert.assertTrue; 9 | 10 | public class PrivateRuleHandlerTest { 11 | @Test 12 | public void checkNormalMatch() { 13 | PrivateRuleHandler checker = new PrivateRuleHandler(); 14 | 15 | assertFalse(checker.isMatch(new CallDetails("8005551234"), null)); 16 | assertTrue(checker.isMatch(new CallDetails(null), null)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | 13 | - name: set up JDK 1.8 14 | uses: actions/setup-java@v1 15 | with: 16 | java-version: 1.8 17 | 18 | - name: Unit Test 19 | run: ./gradlew testDebugUnitTest 20 | continue-on-error: true # IMPORTANT: allow pipeline to continue to Android Test Report step 21 | 22 | - name: Android Test Report 23 | uses: asadmansr/android-test-report-action@v1.2.0 24 | if: ${{ always() }} # IMPORTANT: run Android Test Report regardless 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/dao/LogDao.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.dao; 2 | 3 | import androidx.lifecycle.LiveData; 4 | import androidx.room.Dao; 5 | import androidx.room.Delete; 6 | import androidx.room.Insert; 7 | import androidx.room.Query; 8 | 9 | import com.novyr.callfilter.db.entity.LogEntity; 10 | 11 | import java.util.List; 12 | 13 | @Dao 14 | public interface LogDao { 15 | @Insert 16 | void insert(LogEntity entity); 17 | 18 | @Delete 19 | void delete(LogEntity entity); 20 | 21 | @Query("DELETE FROM log_entity") 22 | void deleteAll(); 23 | 24 | @Query("SELECT * from log_entity ORDER BY id DESC") 25 | LiveData> findAll(); 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/VerificationFailedRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.os.Build; 4 | 5 | import androidx.annotation.NonNull; 6 | import androidx.annotation.Nullable; 7 | import androidx.annotation.RequiresApi; 8 | 9 | import com.novyr.callfilter.CallDetails; 10 | 11 | @RequiresApi(api = Build.VERSION_CODES.R) 12 | public class VerificationFailedRuleHandler implements RuleHandlerInterface { 13 | @Override 14 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 15 | if (details.isNotVerified()) { 16 | return false; 17 | } 18 | 19 | return details.isVerificationFailed(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/VerificationPassedRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.os.Build; 4 | 5 | import androidx.annotation.NonNull; 6 | import androidx.annotation.Nullable; 7 | import androidx.annotation.RequiresApi; 8 | 9 | import com.novyr.callfilter.CallDetails; 10 | 11 | @RequiresApi(api = Build.VERSION_CODES.R) 12 | public class VerificationPassedRuleHandler implements RuleHandlerInterface { 13 | @Override 14 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 15 | if (details.isNotVerified()) { 16 | return false; 17 | } 18 | 19 | return details.isVerificationPassed(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/CallFilterApplication.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import android.app.Application; 4 | 5 | import com.novyr.callfilter.db.CallFilterDatabase; 6 | import com.novyr.callfilter.db.LogRepository; 7 | import com.novyr.callfilter.db.RuleRepository; 8 | 9 | public class CallFilterApplication extends Application { 10 | private CallFilterDatabase getDatabase() { 11 | return CallFilterDatabase.getDatabase(this); 12 | } 13 | 14 | public LogRepository getLogRepository() { 15 | return LogRepository.getInstance(getDatabase()); 16 | } 17 | 18 | public RuleRepository getRuleRepository() { 19 | return RuleRepository.getInstance(getDatabase()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/RuleCheckerFactory.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import android.content.Context; 4 | 5 | import com.novyr.callfilter.db.RuleRepository; 6 | import com.novyr.callfilter.db.entity.RuleEntity; 7 | import com.novyr.callfilter.rules.RuleHandlerManager; 8 | 9 | public class RuleCheckerFactory { 10 | public static RuleChecker create(final Context context) { 11 | RuleRepository repo = ((CallFilterApplication) context.getApplicationContext()) 12 | .getRuleRepository(); 13 | return new RuleChecker( 14 | new RuleHandlerManager(new ContactFinder(context)), 15 | repo.findEnabled().toArray(new RuleEntity[0]) 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_drag_indicator_18dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/permissions/checker/CheckerInterface.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.permissions.checker; 2 | 3 | import android.app.Activity; 4 | 5 | public interface CheckerInterface { 6 | /** 7 | * @param activity The application activity 8 | * @return Whether the checker has the access it requires 9 | */ 10 | boolean hasAccess(Activity activity); 11 | 12 | /** 13 | * @param activity The application activity 14 | * @param forceAttempt Whether to force the attempt event if it looks like we have access 15 | * @return Whether a request was made that needs to be handled before continuing 16 | */ 17 | boolean requestAccess(Activity activity, boolean forceAttempt); 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-night/ic_drag_indicator_18dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_drag_indicator_disabled_18dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-night/ic_drag_indicator_disabled_18dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/converter/RuleTypeConverter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import androidx.room.TypeConverter; 4 | 5 | import com.novyr.callfilter.db.entity.enums.RuleType; 6 | 7 | public class RuleTypeConverter { 8 | @TypeConverter 9 | public static RuleType toRuleType(int numeral) { 10 | for (RuleType type : RuleType.values()) { 11 | if (type.getCode() == numeral) { 12 | return type; 13 | } 14 | } 15 | return null; 16 | } 17 | 18 | @TypeConverter 19 | public static Integer fromRuleType(RuleType type) { 20 | if (type != null) { 21 | return type.getCode(); 22 | } 23 | 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/converter/LogActionConverter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import androidx.room.TypeConverter; 4 | 5 | import com.novyr.callfilter.db.entity.enums.LogAction; 6 | 7 | public class LogActionConverter { 8 | @TypeConverter 9 | public static LogAction toLogAction(int numeral) { 10 | for (LogAction action : LogAction.values()) { 11 | if (action.getCode() == numeral) { 12 | return action; 13 | } 14 | } 15 | return null; 16 | } 17 | 18 | @TypeConverter 19 | public static Integer fromLogAction(LogAction action) { 20 | if (action != null) { 21 | return action.getCode(); 22 | } 23 | 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/converter/RuleActionConverter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import androidx.room.TypeConverter; 4 | 5 | import com.novyr.callfilter.db.entity.enums.RuleAction; 6 | 7 | public class RuleActionConverter { 8 | @TypeConverter 9 | public static RuleAction toRuleAction(int numeral) { 10 | for (RuleAction action : RuleAction.values()) { 11 | if (action.getCode() == numeral) { 12 | return action; 13 | } 14 | } 15 | return null; 16 | } 17 | 18 | @TypeConverter 19 | public static Integer fromRuleAction(RuleAction action) { 20 | if (action != null) { 21 | return action.getCode(); 22 | } 23 | 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/converter/CalendarConverter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import androidx.room.TypeConverter; 4 | 5 | import java.util.Calendar; 6 | import java.util.TimeZone; 7 | 8 | public class CalendarConverter { 9 | @TypeConverter 10 | public static Calendar toCalendar(Long timestamp) { 11 | if (timestamp == null) { 12 | return null; 13 | } 14 | 15 | Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); 16 | calendar.setTimeInMillis(timestamp); 17 | return calendar; 18 | } 19 | 20 | @TypeConverter 21 | public static Long fromCalendar(Calendar calendar) { 22 | if (calendar == null) { 23 | return null; 24 | } 25 | 26 | return calendar.getTime().getTime(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Call Filter 2 | 3 | An Android app to reject calls from numbers matching various conditions. 4 | 5 | Rule list Rule edit Log list 6 | 7 | ## Build 8 | 9 | Use [Android Studio](https://developer.android.com/studio) 10 | 11 | ## Issues 12 | 13 | * On Android versions before Q (API v29) the app does not always get notified about a call to reject it before the ringer can start. This is fixed in Q with the [CallScreeningService](https://developer.android.com/reference/android/telecom/CallScreeningService.html) API. 14 | * On Android versions before Lollipop (API v21) the app must run as a system app to block calls. 15 | 16 | ## License 17 | 18 | [MIT](https://opensource.org/licenses/MIT) 19 | -------------------------------------------------------------------------------- /app/src/main/res/layout/form_rule_area_code.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/RecognizedRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | 6 | import com.novyr.callfilter.CallDetails; 7 | import com.novyr.callfilter.ContactFinder; 8 | 9 | public class RecognizedRuleHandler implements RuleHandlerInterface { 10 | @NonNull 11 | private final ContactFinder mContactFinder; 12 | 13 | public RecognizedRuleHandler(@NonNull ContactFinder contactFinder) { 14 | mContactFinder = contactFinder; 15 | } 16 | 17 | @Override 18 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 19 | String number = details.getPhoneNumber(); 20 | if (number == null) { 21 | return false; 22 | } 23 | 24 | return mContactFinder.findContactId(number) != null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/db/converter/RuleTypeConverterTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import com.novyr.callfilter.db.entity.enums.RuleType; 4 | 5 | import org.junit.Test; 6 | 7 | import static org.junit.Assert.assertEquals; 8 | import static org.junit.Assert.assertNull; 9 | 10 | public class RuleTypeConverterTest { 11 | @Test 12 | public void testToType() { 13 | assertEquals(RuleType.UNMATCHED, RuleTypeConverter.toRuleType(1)); 14 | assertEquals(RuleType.MATCH, RuleTypeConverter.toRuleType(6)); 15 | assertNull(RuleTypeConverter.toRuleType(99)); 16 | assertNull(RuleTypeConverter.toRuleType(-1)); 17 | } 18 | 19 | @Test 20 | public void testFromType() { 21 | assertEquals((Integer) 1, RuleTypeConverter.fromRuleType(RuleType.UNMATCHED)); 22 | assertEquals((Integer) 6, RuleTypeConverter.fromRuleType(RuleType.MATCH)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_log_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/db/converter/RuleActionConverterTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import com.novyr.callfilter.db.entity.enums.RuleAction; 4 | 5 | import org.junit.Test; 6 | 7 | import static org.junit.Assert.assertEquals; 8 | import static org.junit.Assert.assertNull; 9 | 10 | public class RuleActionConverterTest { 11 | @Test 12 | public void testToAction() { 13 | assertEquals(RuleAction.BLOCK, RuleActionConverter.toRuleAction(1)); 14 | assertEquals(RuleAction.ALLOW, RuleActionConverter.toRuleAction(2)); 15 | assertNull(RuleActionConverter.toRuleAction(3)); 16 | assertNull(RuleActionConverter.toRuleAction(-1)); 17 | } 18 | 19 | @Test 20 | public void testFromAction() { 21 | assertEquals((Integer) 1, RuleActionConverter.fromRuleAction(RuleAction.BLOCK)); 22 | assertEquals((Integer) 2, RuleActionConverter.fromRuleAction(RuleAction.ALLOW)); 23 | assertNull(RuleActionConverter.fromRuleAction(null)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/viewmodel/LogViewModel.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.viewmodel; 2 | 3 | import android.app.Application; 4 | 5 | import androidx.lifecycle.AndroidViewModel; 6 | import androidx.lifecycle.LiveData; 7 | 8 | import com.novyr.callfilter.CallFilterApplication; 9 | import com.novyr.callfilter.db.LogRepository; 10 | import com.novyr.callfilter.db.entity.LogEntity; 11 | 12 | import java.util.List; 13 | 14 | public class LogViewModel extends AndroidViewModel { 15 | private final LogRepository mRepository; 16 | 17 | public LogViewModel(Application application) { 18 | super(application); 19 | 20 | mRepository = ((CallFilterApplication) application).getLogRepository(); 21 | } 22 | 23 | public LiveData> findAll() { 24 | return mRepository.findAll(); 25 | } 26 | 27 | public void deleteAll() { 28 | mRepository.deleteAll(); 29 | } 30 | 31 | public void delete(LogEntity entity) { 32 | mRepository.delete(entity); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/dao/RuleDao.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.dao; 2 | 3 | import androidx.lifecycle.LiveData; 4 | import androidx.room.Dao; 5 | import androidx.room.Delete; 6 | import androidx.room.Insert; 7 | import androidx.room.Query; 8 | import androidx.room.Update; 9 | 10 | import com.novyr.callfilter.db.entity.RuleEntity; 11 | 12 | import java.util.List; 13 | 14 | @Dao 15 | public interface RuleDao { 16 | @Insert 17 | void insert(RuleEntity entity); 18 | 19 | @Update 20 | void update(RuleEntity entity); 21 | 22 | @Delete 23 | void delete(RuleEntity entity); 24 | 25 | @Query("DELETE FROM rule_entity") 26 | void deleteAll(); 27 | 28 | @Query("SELECT MAX(`order`) FROM rule_entity") 29 | LiveData highestOrder(); 30 | 31 | @Query("SELECT * from rule_entity ORDER BY `order` DESC") 32 | LiveData> findAll(); 33 | 34 | @Query("SELECT * from rule_entity WHERE `enabled` = 1 ORDER BY `order` DESC") 35 | List findEnabled(); 36 | } 37 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/rules/VerificationFailedRuleHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.telecom.Connection; 4 | 5 | import com.novyr.callfilter.CallDetails; 6 | 7 | import org.junit.Test; 8 | 9 | import static org.junit.Assert.assertFalse; 10 | import static org.junit.Assert.assertTrue; 11 | 12 | public class VerificationFailedRuleHandlerTest { 13 | @Test 14 | public void checkHandler() { 15 | VerificationFailedRuleHandler checker = new VerificationFailedRuleHandler(); 16 | 17 | assertFalse(checker.isMatch( 18 | new CallDetails("8005551234", Connection.VERIFICATION_STATUS_NOT_VERIFIED), 19 | null 20 | )); 21 | 22 | assertTrue(checker.isMatch( 23 | new CallDetails("8005551234", Connection.VERIFICATION_STATUS_FAILED), 24 | null 25 | )); 26 | 27 | assertFalse(checker.isMatch( 28 | new CallDetails("8005551234", Connection.VERIFICATION_STATUS_PASSED), 29 | null 30 | )); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/rules/VerificationPassedRuleHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.telecom.Connection; 4 | 5 | import com.novyr.callfilter.CallDetails; 6 | 7 | import org.junit.Test; 8 | 9 | import static org.junit.Assert.assertFalse; 10 | import static org.junit.Assert.assertTrue; 11 | 12 | public class VerificationPassedRuleHandlerTest { 13 | @Test 14 | public void checkHandler() { 15 | VerificationPassedRuleHandler checker = new VerificationPassedRuleHandler(); 16 | 17 | assertFalse(checker.isMatch( 18 | new CallDetails("8005551234", Connection.VERIFICATION_STATUS_NOT_VERIFIED), 19 | null 20 | )); 21 | 22 | assertFalse(checker.isMatch( 23 | new CallDetails("8005551234", Connection.VERIFICATION_STATUS_FAILED), 24 | null 25 | )); 26 | 27 | assertTrue(checker.isMatch( 28 | new CallDetails("8005551234", Connection.VERIFICATION_STATUS_PASSED), 29 | null 30 | )); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/res/layout/form_rule_match.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 22 | 23 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/telephony/AndroidPieHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.telephony; 2 | 3 | import android.Manifest; 4 | import android.annotation.SuppressLint; 5 | import android.content.Context; 6 | import android.content.pm.PackageManager; 7 | import android.os.Build; 8 | import android.telecom.TelecomManager; 9 | 10 | import androidx.annotation.RequiresApi; 11 | import androidx.core.content.ContextCompat; 12 | 13 | @RequiresApi(api = Build.VERSION_CODES.P) 14 | public class AndroidPieHandler implements HandlerInterface { 15 | private TelecomManager mTelecomManager = null; 16 | 17 | AndroidPieHandler(Context context) { 18 | if (ContextCompat.checkSelfPermission( 19 | context, 20 | Manifest.permission.ANSWER_PHONE_CALLS 21 | ) == PackageManager.PERMISSION_GRANTED) { 22 | mTelecomManager = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); 23 | } 24 | } 25 | 26 | @SuppressLint("MissingPermission") 27 | public boolean endCall() { 28 | return mTelecomManager != null && mTelecomManager.endCall(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/UnrecognizedRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | 6 | import com.novyr.callfilter.CallDetails; 7 | import com.novyr.callfilter.ContactFinder; 8 | 9 | public class UnrecognizedRuleHandler implements RuleHandlerInterface { 10 | @NonNull 11 | private final ContactFinder mContactFinder; 12 | 13 | public UnrecognizedRuleHandler(@NonNull ContactFinder contactFinder) { 14 | mContactFinder = contactFinder; 15 | } 16 | 17 | @Override 18 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 19 | // If the number is private the PrivateChecker should handle it despite being unrecognized 20 | // TODO Should we handle it anyway? Would someone really want to block unrecognized but not 21 | // private numbers? 22 | String number = details.getPhoneNumber(); 23 | if (number == null) { 24 | return false; 25 | } 26 | 27 | return mContactFinder.findContactId(number) == null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Erik Perri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/db/converter/LogActionConverterTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import com.novyr.callfilter.db.entity.enums.LogAction; 4 | 5 | import org.junit.Test; 6 | 7 | import static org.junit.Assert.assertEquals; 8 | import static org.junit.Assert.assertNull; 9 | 10 | public class LogActionConverterTest { 11 | @Test 12 | public void testToAction() { 13 | assertEquals(LogAction.BLOCKED, LogActionConverter.toLogAction(0)); 14 | assertEquals(LogAction.ALLOWED, LogActionConverter.toLogAction(1)); 15 | assertEquals(LogAction.FAILED, LogActionConverter.toLogAction(2)); 16 | assertNull(LogActionConverter.toLogAction(3)); 17 | assertNull(LogActionConverter.toLogAction(-1)); 18 | } 19 | 20 | @Test 21 | public void testFromAction() { 22 | assertEquals((Integer) 0, LogActionConverter.fromLogAction(LogAction.BLOCKED)); 23 | assertEquals((Integer) 1, LogActionConverter.fromLogAction(LogAction.ALLOWED)); 24 | assertEquals((Integer) 2, LogActionConverter.fromLogAction(LogAction.FAILED)); 25 | assertNull(LogActionConverter.fromLogAction(null)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/rulelist/RuleViewHolderFactory.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.rulelist; 2 | 3 | import android.content.Context; 4 | import android.view.LayoutInflater; 5 | import android.view.View; 6 | import android.view.ViewGroup; 7 | 8 | import androidx.annotation.Nullable; 9 | 10 | import com.novyr.callfilter.R; 11 | 12 | public class RuleViewHolderFactory { 13 | private final LayoutInflater mInflater; 14 | private final RuleListActionHelper mRuleListActionHelper; 15 | private final RuleViewHolder.OnStartDragListener mDragListener; 16 | 17 | RuleViewHolderFactory( 18 | Context context, 19 | RuleListActionHelper ruleListActionHelper, 20 | RuleViewHolder.OnStartDragListener dragListener 21 | ) { 22 | mInflater = LayoutInflater.from(context); 23 | mRuleListActionHelper = ruleListActionHelper; 24 | mDragListener = dragListener; 25 | } 26 | 27 | public RuleViewHolder create(@Nullable ViewGroup parent) { 28 | View itemView = mInflater.inflate(R.layout.content_rule_entity, parent, false); 29 | 30 | return new RuleViewHolder(itemView, mRuleListActionHelper, mDragListener); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/RuleChecker.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import com.novyr.callfilter.db.entity.RuleEntity; 4 | import com.novyr.callfilter.db.entity.enums.RuleAction; 5 | import com.novyr.callfilter.rules.RuleHandlerInterface; 6 | import com.novyr.callfilter.rules.RuleHandlerManager; 7 | 8 | public class RuleChecker { 9 | private final RuleHandlerManager mHandlerManager; 10 | private final RuleEntity[] mRules; 11 | 12 | RuleChecker(RuleHandlerManager handlerManager, RuleEntity[] rules) { 13 | mHandlerManager = handlerManager; 14 | mRules = rules; 15 | } 16 | 17 | public boolean allowCall(CallDetails details) { 18 | for (RuleEntity rule : mRules) { 19 | if (!rule.isEnabled()) { 20 | continue; 21 | } 22 | 23 | RuleHandlerInterface ruleHandler = mHandlerManager.findHandler(rule.getType()); 24 | if (ruleHandler == null) { 25 | continue; 26 | } 27 | 28 | if (ruleHandler.isMatch(details, rule.getValue())) { 29 | return rule.getAction() == RuleAction.ALLOW; 30 | } 31 | } 32 | 33 | // If no rules processed we don't block anything 34 | return true; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | android.defaults.buildfeatures.buildconfig=true 21 | android.nonTransitiveRClass=false 22 | android.nonFinalResIds=false 23 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/rules/RecognizedRuleHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import com.novyr.callfilter.CallDetails; 4 | import com.novyr.callfilter.ContactFinder; 5 | 6 | import org.junit.Test; 7 | 8 | import static org.junit.Assert.assertFalse; 9 | import static org.junit.Assert.assertTrue; 10 | import static org.mockito.Mockito.mock; 11 | import static org.mockito.Mockito.when; 12 | 13 | public class RecognizedRuleHandlerTest { 14 | private final String RECOGNIZED_NUMBER = "8005551234"; 15 | private final String UNRECOGNIZED_NUMBER = "9005554321"; 16 | 17 | private ContactFinder createFinderMock() { 18 | ContactFinder finder = mock(ContactFinder.class); 19 | 20 | when(finder.findContactId(RECOGNIZED_NUMBER)).thenReturn("1"); 21 | when(finder.findContactId(UNRECOGNIZED_NUMBER)).thenReturn(null); 22 | 23 | return finder; 24 | } 25 | 26 | @Test 27 | public void checkPrivateMatch() { 28 | RecognizedRuleHandler checker = new RecognizedRuleHandler(createFinderMock()); 29 | 30 | assertFalse(checker.isMatch(new CallDetails(null), null)); 31 | } 32 | 33 | @Test 34 | public void checkNormalMatch() { 35 | RecognizedRuleHandler checker = new RecognizedRuleHandler(createFinderMock()); 36 | 37 | assertFalse(checker.isMatch(new CallDetails(UNRECOGNIZED_NUMBER), null)); 38 | assertTrue(checker.isMatch(new CallDetails(RECOGNIZED_NUMBER), null)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.scripts/README.md: -------------------------------------------------------------------------------- 1 | # Helper Scripts 2 | 3 | ## call-emulator.ps1 4 | 5 | This calls the emulator via telnet. If no argument is 6 | provided the call will be from a private number, if an 7 | argument is provided that number will be used (can only 8 | contain 0-9 # +). 9 | 10 | ## install-as-system-app.ps1 11 | 12 | This installs the application on the emulator as a system 13 | app. This is used to test Android versions < 21 where the 14 | app must be a system app to cancel calls. It should not be 15 | used on newer versions. 16 | 17 | ## Run as system app.run.xml 18 | 19 | This is an Android Studio run configuration which runs the 20 | install-as-system-app script before launch. For it to work 21 | you must setup the external tool named "Install as System 22 | App". 23 | 24 | Location on Windows: 25 | `%AppData%\Google\AndroidStudioX.X\tools\External Tools.xml` 26 | 27 | ```xml 28 | 29 | 30 | 31 | 35 | 36 | 37 | ``` 38 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/AreaCodeExtractorTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import com.google.i18n.phonenumbers.PhoneNumberUtil; 4 | 5 | import org.junit.Test; 6 | 7 | import static org.junit.Assert.assertEquals; 8 | import static org.junit.Assert.assertNull; 9 | 10 | public class AreaCodeExtractorTest { 11 | @Test 12 | public void checkInvalid() { 13 | AreaCodeExtractor extractor = new AreaCodeExtractor(PhoneNumberUtil.getInstance()); 14 | 15 | assertNull(extractor.extract(null)); 16 | assertNull(extractor.extract("1")); 17 | assertNull(extractor.extract("invalid")); 18 | assertNull(extractor.extract("230589-9843276723")); 19 | } 20 | 21 | @Test 22 | public void checkLocal() { 23 | AreaCodeExtractor extractor = new AreaCodeExtractor(PhoneNumberUtil.getInstance()); 24 | 25 | assertNull(extractor.extract("5551234")); 26 | } 27 | 28 | @Test 29 | public void checkVariant() { 30 | AreaCodeExtractor extractor = new AreaCodeExtractor(PhoneNumberUtil.getInstance()); 31 | 32 | assertEquals(extractor.extract("18005551234"), "800"); 33 | assertEquals(extractor.extract("8005551234"), "800"); 34 | assertEquals(extractor.extract("800.555.1234"), "800"); 35 | assertEquals(extractor.extract("800-555-1234"), "800"); 36 | assertEquals(extractor.extract("(800) 555-1234"), "800"); 37 | assertEquals(extractor.extract("1 (800) 555 1234"), "800"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/rules/UnrecognizedRuleHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import com.novyr.callfilter.CallDetails; 4 | import com.novyr.callfilter.ContactFinder; 5 | 6 | import org.junit.Test; 7 | 8 | import static org.junit.Assert.assertFalse; 9 | import static org.junit.Assert.assertTrue; 10 | import static org.mockito.Mockito.mock; 11 | import static org.mockito.Mockito.when; 12 | 13 | public class UnrecognizedRuleHandlerTest { 14 | private final String RECOGNIZED_NUMBER = "8005551234"; 15 | private final String UNRECOGNIZED_NUMBER = "9005554321"; 16 | 17 | private ContactFinder createFinderMock() { 18 | ContactFinder finder = mock(ContactFinder.class); 19 | 20 | when(finder.findContactId(RECOGNIZED_NUMBER)).thenReturn("1"); 21 | when(finder.findContactId(UNRECOGNIZED_NUMBER)).thenReturn(null); 22 | 23 | return finder; 24 | } 25 | 26 | @Test 27 | public void checkPrivateMatch() { 28 | UnrecognizedRuleHandler checker = new UnrecognizedRuleHandler(createFinderMock()); 29 | 30 | assertFalse(checker.isMatch(new CallDetails(null), null)); 31 | } 32 | 33 | @Test 34 | public void checkNormalMatch() { 35 | UnrecognizedRuleHandler checker = new UnrecognizedRuleHandler(createFinderMock()); 36 | 37 | assertTrue(checker.isMatch(new CallDetails(UNRECOGNIZED_NUMBER), null)); 38 | assertFalse(checker.isMatch(new CallDetails(RECOGNIZED_NUMBER), null)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/LogRepository.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db; 2 | 3 | import androidx.lifecycle.LiveData; 4 | 5 | import com.novyr.callfilter.db.dao.LogDao; 6 | import com.novyr.callfilter.db.entity.LogEntity; 7 | 8 | import java.util.List; 9 | 10 | public class LogRepository { 11 | private static LogRepository sInstance; 12 | 13 | private final LogDao mDao; 14 | private final LiveData> mEntities; 15 | 16 | private LogRepository(CallFilterDatabase database) { 17 | mDao = database.logDao(); 18 | mEntities = mDao.findAll(); 19 | } 20 | 21 | public static LogRepository getInstance(final CallFilterDatabase database) { 22 | if (sInstance == null) { 23 | synchronized (LogRepository.class) { 24 | if (sInstance == null) { 25 | sInstance = new LogRepository(database); 26 | } 27 | } 28 | } 29 | return sInstance; 30 | } 31 | 32 | public LiveData> findAll() { 33 | return mEntities; 34 | } 35 | 36 | public void insert(LogEntity entity) { 37 | CallFilterDatabase.databaseWriteExecutor.execute(() -> mDao.insert(entity)); 38 | } 39 | 40 | public void deleteAll() { 41 | CallFilterDatabase.databaseWriteExecutor.execute(mDao::deleteAll); 42 | } 43 | 44 | public void delete(LogEntity entity) { 45 | CallFilterDatabase.databaseWriteExecutor.execute(() -> mDao.delete(entity)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_rule_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 23 | 24 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/entity/enums/RuleType.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.entity.enums; 2 | 3 | import android.os.Build; 4 | 5 | import androidx.annotation.StringRes; 6 | 7 | import com.novyr.callfilter.R; 8 | 9 | public enum RuleType { 10 | UNMATCHED(1, R.string.rule_type_unmatched), 11 | RECOGNIZED(2, R.string.rule_type_recognized), 12 | UNRECOGNIZED(3, R.string.rule_type_unrecognized), 13 | PRIVATE(4, R.string.rule_type_private), 14 | AREA_CODE(5, R.string.rule_type_area_code), 15 | MATCH(6, R.string.rule_type_match), 16 | VERIFICATION_FAILED(7, R.string.rule_type_verification_failed, Build.VERSION_CODES.R), 17 | VERIFICATION_PASSED(8, R.string.rule_type_verification_passed, Build.VERSION_CODES.R); 18 | 19 | private final int mCode; 20 | private final int mDisplayNameResource; 21 | private final int mMinSdkVersion; 22 | 23 | RuleType(int code, @StringRes int displayNameResource) { 24 | mCode = code; 25 | mDisplayNameResource = displayNameResource; 26 | mMinSdkVersion = 0; 27 | } 28 | 29 | RuleType(int code, @StringRes int displayNameResource, int minSdkVersion) { 30 | mCode = code; 31 | mDisplayNameResource = displayNameResource; 32 | mMinSdkVersion = minSdkVersion; 33 | } 34 | 35 | public int getCode() { 36 | return mCode; 37 | } 38 | 39 | public int getDisplayNameResource() { 40 | return mDisplayNameResource; 41 | } 42 | 43 | public int getMinSdkVersion() { 44 | return mMinSdkVersion; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.scripts/call-emulator.ps1: -------------------------------------------------------------------------------- 1 | $sendFrom = '#' # hash = Private number 2 | $emuHost = "localhost" 3 | $emuPort = 5554 4 | $authToken = [IO.File]::ReadAllText("$env:USERPROFILE\.emulator_console_auth_token") 5 | 6 | if ($args[0] -ne $null) { 7 | $sendFrom = $args[0] 8 | } 9 | 10 | $tcpConnection = New-Object System.Net.Sockets.TcpClient($emuHost, $emuPort) 11 | $tcpStream = $tcpConnection.GetStream() 12 | $reader = New-Object System.IO.StreamReader($tcpStream) 13 | $writer = New-Object System.IO.StreamWriter($tcpStream) 14 | $writer.AutoFlush = $true 15 | 16 | if ($tcpConnection.Connected -ne $true) { 17 | Write-Host "Failed to connect to $emuHost@$emuPort" 18 | Exit 1 19 | } 20 | 21 | $response = "" 22 | while ($tcpStream.DataAvailable -or $reader.Peek() -ne -1) { 23 | $response = $response + "`n" + $reader.ReadLine() 24 | } 25 | if (!$response.Contains('Authentication required')) { 26 | Write-Host "Initial response did not contain expected authentication request" 27 | Write-Host $response 28 | Exit 1 29 | } 30 | 31 | $commands = @( 32 | "auth $authToken", 33 | "gsm call $sendFrom", 34 | "exit" 35 | ) 36 | 37 | foreach ($command in $commands) { 38 | if ($tcpConnection.Connected -ne $true) { 39 | Write-Host "Lost connection to emulator before running all commands" 40 | Exit 1 41 | } 42 | 43 | $writer.WriteLine($command) 44 | Start-Sleep -Milliseconds 100 45 | 46 | $response = "" 47 | while ($tcpStream.DataAvailable -or $reader.Peek() -ne -1) { 48 | $response = $response + "`n" + $reader.ReadLine() 49 | } 50 | if ($response.Length -gt 0 -and !$response.Contains("OK")) { 51 | Write-Host "Unexpected response during command $command" 52 | Write-Host $response 53 | Exit 1 54 | } 55 | } 56 | 57 | $client.Close | Out-Null 58 | Exit 0 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/RuleRepository.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db; 2 | 3 | import androidx.lifecycle.LiveData; 4 | 5 | import com.novyr.callfilter.db.dao.RuleDao; 6 | import com.novyr.callfilter.db.entity.RuleEntity; 7 | 8 | import java.util.List; 9 | 10 | public class RuleRepository { 11 | private static RuleRepository sInstance; 12 | 13 | private final RuleDao mDao; 14 | private final LiveData> mEntities; 15 | 16 | private RuleRepository(CallFilterDatabase database) { 17 | mDao = database.ruleDao(); 18 | mEntities = mDao.findAll(); 19 | } 20 | 21 | public static RuleRepository getInstance(final CallFilterDatabase database) { 22 | if (sInstance == null) { 23 | synchronized (RuleRepository.class) { 24 | if (sInstance == null) { 25 | sInstance = new RuleRepository(database); 26 | } 27 | } 28 | } 29 | return sInstance; 30 | } 31 | 32 | public LiveData> findAll() { 33 | return mEntities; 34 | } 35 | 36 | public List findEnabled() { 37 | return mDao.findEnabled(); 38 | } 39 | 40 | public void insert(RuleEntity entity) { 41 | CallFilterDatabase.databaseWriteExecutor.execute(() -> mDao.insert(entity)); 42 | } 43 | 44 | public void delete(RuleEntity entity) { 45 | CallFilterDatabase.databaseWriteExecutor.execute(() -> mDao.delete(entity)); 46 | } 47 | 48 | public void update(RuleEntity entity) { 49 | CallFilterDatabase.databaseWriteExecutor.execute(() -> mDao.update(entity)); 50 | } 51 | 52 | public LiveData highestOrder() { 53 | return mDao.highestOrder(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/AreaCodeExtractor.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import android.util.Log; 4 | 5 | import androidx.annotation.NonNull; 6 | 7 | import com.google.i18n.phonenumbers.NumberParseException; 8 | import com.google.i18n.phonenumbers.PhoneNumberUtil; 9 | import com.google.i18n.phonenumbers.Phonenumber; 10 | 11 | import java.util.regex.Matcher; 12 | import java.util.regex.Pattern; 13 | 14 | public class AreaCodeExtractor { 15 | private static final String TAG = AreaCodeExtractor.class.getName(); 16 | 17 | @NonNull 18 | private final PhoneNumberUtil mPhoneNumberUtil; 19 | 20 | public AreaCodeExtractor(@NonNull PhoneNumberUtil phoneNumberUtil) { 21 | mPhoneNumberUtil = phoneNumberUtil; 22 | } 23 | 24 | public String extract(String number) { 25 | Phonenumber.PhoneNumber parsedNumber; 26 | 27 | try { 28 | parsedNumber = mPhoneNumberUtil.parse(number, "US"); 29 | } catch (NumberParseException e) { 30 | Log.d(TAG, "Failed to parse number: " + e.toString()); 31 | return null; 32 | } 33 | 34 | String formatted = mPhoneNumberUtil.format( 35 | parsedNumber, 36 | PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL 37 | ); 38 | 39 | // If we were provided with a valid US or Canadian (non-local) phone number the 40 | // international format should be +1 ###-###-#### 41 | Pattern countryCodePattern = Pattern.compile("^\\+[0-9] ([0-9]{3})-[0-9]{3}-[0-9]{4}$"); 42 | Matcher countryMatcher = countryCodePattern.matcher(formatted); 43 | 44 | if (!countryMatcher.find()) { 45 | return null; 46 | } 47 | 48 | return countryMatcher.group(1); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/viewmodel/RuleViewModel.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.viewmodel; 2 | 3 | import android.app.Application; 4 | 5 | import androidx.lifecycle.AndroidViewModel; 6 | import androidx.lifecycle.LiveData; 7 | 8 | import com.novyr.callfilter.CallFilterApplication; 9 | import com.novyr.callfilter.db.RuleRepository; 10 | import com.novyr.callfilter.db.entity.RuleEntity; 11 | 12 | import java.util.List; 13 | 14 | public class RuleViewModel extends AndroidViewModel { 15 | private final RuleRepository mRepository; 16 | 17 | public RuleViewModel(Application application) { 18 | super(application); 19 | 20 | mRepository = ((CallFilterApplication) application).getRuleRepository(); 21 | } 22 | 23 | public LiveData> findAll() { 24 | return mRepository.findAll(); 25 | } 26 | 27 | public void delete(RuleEntity entity) { 28 | mRepository.delete(entity); 29 | } 30 | 31 | public void save(RuleEntity entity) { 32 | if (entity.getId() > 0) { 33 | mRepository.update(entity); 34 | } else { 35 | mRepository.insert(entity); 36 | } 37 | } 38 | 39 | public void reorder(RuleEntity[] entities) { 40 | // We increment the order by 2, leaving a space between each, so when we restore a deleted 41 | // item it is easier to put it back in place (by subtracting 1 from the order it was) 42 | for (int index = entities.length - 1, order = 0; index >= 0; index--, order += 2) { 43 | RuleEntity entity = entities[index]; 44 | 45 | if (entity.getOrder() != order) { 46 | entity.setOrder(order); 47 | save(entity); 48 | } 49 | } 50 | } 51 | 52 | public LiveData highestOrder() { 53 | return mRepository.highestOrder(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/novyr/callfilter/db/LiveDataTestUtil.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db; 2 | 3 | /* 4 | * Copyright (C) 2017 The Android Open Source Project 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | import androidx.annotation.Nullable; 20 | import androidx.lifecycle.LiveData; 21 | import androidx.lifecycle.Observer; 22 | 23 | import java.util.concurrent.CountDownLatch; 24 | import java.util.concurrent.TimeUnit; 25 | 26 | public class LiveDataTestUtil { 27 | 28 | /** 29 | * Get the value from a LiveData object. We're waiting for LiveData to emit, for 2 seconds. 30 | * Once we got a notification via onChanged, we stop observing. 31 | */ 32 | public static T getValue(final LiveData liveData) throws InterruptedException { 33 | final Object[] data = new Object[1]; 34 | final CountDownLatch latch = new CountDownLatch(1); 35 | Observer observer = new Observer() { 36 | @Override 37 | public void onChanged(@Nullable T o) { 38 | data[0] = o; 39 | latch.countDown(); 40 | liveData.removeObserver(this); 41 | } 42 | }; 43 | liveData.observeForever(observer); 44 | latch.await(2, TimeUnit.SECONDS); 45 | //noinspection unchecked 46 | return (T) data[0]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/db/converter/CalendarConverterTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.converter; 2 | 3 | import org.junit.Test; 4 | 5 | import java.util.Calendar; 6 | import java.util.TimeZone; 7 | 8 | import static org.junit.Assert.assertEquals; 9 | import static org.junit.Assert.assertNull; 10 | 11 | public class CalendarConverterTest { 12 | @Test 13 | public void testToCalendar() { 14 | assertEquals( 15 | buildCalendar(1970, 0, 1, 0, 0, 0), 16 | CalendarConverter.toCalendar(0L) 17 | ); 18 | assertEquals( 19 | buildCalendar(2020, Calendar.DECEMBER, 25, 1, 20, 30), 20 | CalendarConverter.toCalendar(1608859230L * 1000L) 21 | ); 22 | assertNull(CalendarConverter.toCalendar(null)); 23 | } 24 | 25 | @Test 26 | public void testFromCalendar() { 27 | assertEquals( 28 | (Long) 0L, 29 | CalendarConverter.fromCalendar(buildCalendar(1970, 0, 1, 0, 0, 0)) 30 | ); 31 | assertEquals( 32 | (Long) (1608887355L * 1000L), 33 | CalendarConverter.fromCalendar(buildCalendar(2020, Calendar.DECEMBER, 25, 9, 9, 15)) 34 | ); 35 | assertNull(CalendarConverter.fromCalendar(null)); 36 | } 37 | 38 | private Calendar buildCalendar(int year, int month, int day, int hour, int minute, int second) { 39 | Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); 40 | calendar.set(Calendar.AM_PM, 0); 41 | calendar.set(Calendar.YEAR, year); 42 | calendar.set(Calendar.MONTH, month); 43 | calendar.set(Calendar.DATE, day); 44 | calendar.set(Calendar.HOUR, hour); 45 | calendar.set(Calendar.MINUTE, minute); 46 | calendar.set(Calendar.SECOND, second); 47 | calendar.set(Calendar.MILLISECOND, 0); 48 | return calendar; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/RuleHandlerManager.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.os.Build; 4 | 5 | import androidx.annotation.NonNull; 6 | 7 | import com.google.i18n.phonenumbers.PhoneNumberUtil; 8 | 9 | import com.novyr.callfilter.AreaCodeExtractor; 10 | import com.novyr.callfilter.ContactFinder; 11 | import com.novyr.callfilter.db.entity.enums.RuleType; 12 | 13 | import java.util.Hashtable; 14 | 15 | public class RuleHandlerManager { 16 | private final Hashtable mKnownHandlers; 17 | 18 | public RuleHandlerManager(@NonNull ContactFinder contactFinder) { 19 | mKnownHandlers = buildHandlers(contactFinder); 20 | } 21 | 22 | private Hashtable buildHandlers( 23 | @NonNull ContactFinder contactFinder 24 | ) { 25 | Hashtable rules = new Hashtable<>(); 26 | 27 | PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); 28 | 29 | rules.put(RuleType.UNMATCHED, new UnmatchedRuleHandler()); 30 | rules.put(RuleType.PRIVATE, new PrivateRuleHandler()); 31 | rules.put(RuleType.UNRECOGNIZED, new UnrecognizedRuleHandler(contactFinder)); 32 | rules.put(RuleType.RECOGNIZED, new RecognizedRuleHandler(contactFinder)); 33 | rules.put( 34 | RuleType.AREA_CODE, 35 | new AreaCodeRuleHandler(new AreaCodeExtractor(phoneNumberUtil)) 36 | ); 37 | rules.put(RuleType.MATCH, new MatchRuleHandler()); 38 | 39 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 40 | rules.put(RuleType.VERIFICATION_FAILED, new VerificationFailedRuleHandler()); 41 | rules.put(RuleType.VERIFICATION_PASSED, new VerificationPassedRuleHandler()); 42 | } 43 | 44 | return rules; 45 | } 46 | 47 | public RuleHandlerInterface findHandler(RuleType type) { 48 | return mKnownHandlers.get(type); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/AreaCodeRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.view.View; 4 | import android.widget.EditText; 5 | 6 | import androidx.annotation.NonNull; 7 | import androidx.annotation.Nullable; 8 | 9 | import com.novyr.callfilter.AreaCodeExtractor; 10 | import com.novyr.callfilter.CallDetails; 11 | import com.novyr.callfilter.R; 12 | import com.novyr.callfilter.db.entity.RuleEntity; 13 | import com.novyr.callfilter.rules.exception.InvalidValueException; 14 | 15 | public class AreaCodeRuleHandler implements RuleHandlerInterface, RuleHandlerWithFormInterface { 16 | @NonNull 17 | private final AreaCodeExtractor mAreaCodeExtractor; 18 | 19 | public AreaCodeRuleHandler(@NonNull AreaCodeExtractor areaCodeExtractor) { 20 | mAreaCodeExtractor = areaCodeExtractor; 21 | } 22 | 23 | @Override 24 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 25 | String number = details.getPhoneNumber(); 26 | if (number == null || ruleValue == null) { 27 | return false; 28 | } 29 | 30 | String areaCode = mAreaCodeExtractor.extract(number); 31 | 32 | return areaCode != null && areaCode.equals(ruleValue); 33 | } 34 | 35 | @Override 36 | public int getEditDialogLayout() { 37 | return R.layout.form_rule_area_code; 38 | } 39 | 40 | @Override 41 | public void loadFormValues(View view, RuleEntity rule) { 42 | EditText input = view.findViewById(R.id.area_code_input); 43 | 44 | input.setText(rule.getValue()); 45 | } 46 | 47 | @Override 48 | public void saveFormValues(View view, RuleEntity rule) throws InvalidValueException { 49 | EditText inputView = view.findViewById(R.id.area_code_input); 50 | String code = inputView.getText().toString(); 51 | 52 | if (code.isEmpty() || !code.matches("^[0-9]{3}$")) { 53 | throw new InvalidValueException(R.string.rule_form_label_area_code); 54 | } 55 | 56 | rule.setValue(code); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/CallDetails.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import android.os.Build; 4 | import android.telecom.Connection; 5 | import android.util.Log; 6 | 7 | import androidx.annotation.Nullable; 8 | import androidx.annotation.RequiresApi; 9 | 10 | public class CallDetails { 11 | private static final String TAG = CallDetails.class.getSimpleName(); 12 | 13 | @Nullable 14 | private final String mPhoneNumber; 15 | private final int mNetworkVerificationStatus; 16 | 17 | public CallDetails(@Nullable String phoneNumber, int networkVerificationStatus) { 18 | mPhoneNumber = phoneNumber; 19 | mNetworkVerificationStatus = networkVerificationStatus; 20 | } 21 | 22 | public CallDetails(@Nullable String phoneNumber) { 23 | this(phoneNumber, 0 /* Connection.VERIFICATION_STATUS_NOT_VERIFIED */); 24 | } 25 | 26 | @Nullable 27 | public String getPhoneNumber() { 28 | return mPhoneNumber; 29 | } 30 | 31 | @RequiresApi(api = Build.VERSION_CODES.R) 32 | public boolean isNotVerified() { 33 | if (mNetworkVerificationStatus != Connection.VERIFICATION_STATUS_FAILED && 34 | mNetworkVerificationStatus != Connection.VERIFICATION_STATUS_PASSED && 35 | mNetworkVerificationStatus != Connection.VERIFICATION_STATUS_NOT_VERIFIED) { 36 | Log.w( 37 | TAG, 38 | String.format("Unexpected verification status %d", mNetworkVerificationStatus) 39 | ); 40 | } 41 | 42 | return mNetworkVerificationStatus != Connection.VERIFICATION_STATUS_PASSED && 43 | mNetworkVerificationStatus != Connection.VERIFICATION_STATUS_FAILED; 44 | } 45 | 46 | @RequiresApi(api = Build.VERSION_CODES.R) 47 | public boolean isVerificationPassed() { 48 | return mNetworkVerificationStatus == Connection.VERIFICATION_STATUS_PASSED; 49 | } 50 | 51 | @RequiresApi(api = Build.VERSION_CODES.R) 52 | public boolean isVerificationFailed() { 53 | return mNetworkVerificationStatus == Connection.VERIFICATION_STATUS_FAILED; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/permissions/checker/CallScreeningRoleChecker.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.permissions.checker; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.app.Activity; 5 | import android.app.role.RoleManager; 6 | import android.content.Intent; 7 | import android.os.Build; 8 | import android.util.Log; 9 | 10 | import androidx.annotation.RequiresApi; 11 | 12 | import com.novyr.callfilter.BuildConfig; 13 | 14 | import static android.app.role.RoleManager.ROLE_CALL_SCREENING; 15 | import static com.novyr.callfilter.permissions.PermissionChecker.PERMISSION_CHECKER_REQUEST; 16 | 17 | @RequiresApi(api = Build.VERSION_CODES.Q) 18 | public class CallScreeningRoleChecker implements CheckerInterface { 19 | private static final String TAG = CallScreeningRoleChecker.class.getSimpleName(); 20 | 21 | @Override 22 | public boolean hasAccess(Activity activity) { 23 | RoleManager roleManager = (RoleManager) activity.getSystemService(Activity.ROLE_SERVICE); 24 | if (roleManager == null || !roleManager.isRoleAvailable(ROLE_CALL_SCREENING)) { 25 | if (BuildConfig.DEBUG) { 26 | Log.w(TAG, String.format("Role %s is not available", ROLE_CALL_SCREENING)); 27 | } 28 | return false; 29 | } 30 | 31 | return roleManager.isRoleHeld(ROLE_CALL_SCREENING); 32 | } 33 | 34 | @Override 35 | public boolean requestAccess(Activity activity, boolean forceAttempt) { 36 | @SuppressLint("WrongConstant") 37 | RoleManager roleManager = (RoleManager) activity.getSystemService(Activity.ROLE_SERVICE); 38 | if (roleManager == null || !roleManager.isRoleAvailable(ROLE_CALL_SCREENING)) { 39 | if (BuildConfig.DEBUG) { 40 | Log.w(TAG, String.format("Role %s is not available", ROLE_CALL_SCREENING)); 41 | } 42 | return false; 43 | } 44 | 45 | if (forceAttempt || !roleManager.isRoleHeld(ROLE_CALL_SCREENING)) { 46 | Intent intent = roleManager.createRequestRoleIntent(ROLE_CALL_SCREENING); 47 | activity.startActivityForResult(intent, PERMISSION_CHECKER_REQUEST); 48 | return true; 49 | } 50 | 51 | return false; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/res/layout/content_log_entity.xml: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 31 | 32 | 36 | 37 | 43 | 44 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/loglist/LogListAdapter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.loglist; 2 | 3 | import android.content.Context; 4 | import android.view.LayoutInflater; 5 | import android.view.View; 6 | import android.view.ViewGroup; 7 | 8 | import androidx.annotation.NonNull; 9 | import androidx.annotation.Nullable; 10 | import androidx.recyclerview.widget.RecyclerView; 11 | 12 | import com.novyr.callfilter.R; 13 | import com.novyr.callfilter.db.entity.LogEntity; 14 | import com.novyr.callfilter.formatter.DateFormatter; 15 | import com.novyr.callfilter.formatter.MessageFormatter; 16 | 17 | import java.util.List; 18 | 19 | class LogListAdapter extends RecyclerView.Adapter { 20 | private final LayoutInflater mInflater; 21 | private final MessageFormatter mMessageFormatter; 22 | private final DateFormatter mDateFormatter; 23 | private final LogListMenuHandler mMenuHandler; 24 | private List mEntries; 25 | 26 | LogListAdapter( 27 | Context context, 28 | MessageFormatter messageFormatter, 29 | DateFormatter dateFormatter, 30 | LogListMenuHandler menuHandler 31 | ) { 32 | mInflater = LayoutInflater.from(context); 33 | mMessageFormatter = messageFormatter; 34 | mDateFormatter = dateFormatter; 35 | mMenuHandler = menuHandler; 36 | } 37 | 38 | void setEntities(List entities) { 39 | mEntries = entities; 40 | 41 | notifyDataSetChanged(); 42 | } 43 | 44 | @NonNull 45 | @Override 46 | public LogViewHolder onCreateViewHolder(@Nullable ViewGroup parent, int viewType) { 47 | View itemView = mInflater.inflate(R.layout.content_log_entity, parent, false); 48 | 49 | return new LogViewHolder(itemView, mMessageFormatter, mDateFormatter, mMenuHandler); 50 | } 51 | 52 | @Override 53 | public void onBindViewHolder(@NonNull LogViewHolder holder, int position) { 54 | if (mEntries == null) { 55 | return; 56 | } 57 | 58 | LogEntity entity = mEntries.get(position); 59 | if (entity != null) { 60 | holder.setEntity(entity); 61 | } 62 | } 63 | 64 | @Override 65 | public int getItemCount() { 66 | return mEntries != null ? mEntries.size() : 0; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/formatter/LogDateFormatterTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.formatter; 2 | 3 | import com.novyr.callfilter.db.entity.LogEntity; 4 | import com.novyr.callfilter.db.entity.enums.LogAction; 5 | 6 | import org.junit.After; 7 | import org.junit.Before; 8 | import org.junit.Test; 9 | 10 | import java.util.Calendar; 11 | import java.util.Locale; 12 | import java.util.TimeZone; 13 | 14 | import static org.junit.Assert.assertEquals; 15 | 16 | public class LogDateFormatterTest { 17 | private Locale mOriginalLocale; 18 | private TimeZone mOriginalTimezone; 19 | 20 | @Before 21 | public void setUp() { 22 | mOriginalLocale = Locale.getDefault(); 23 | mOriginalTimezone = TimeZone.getDefault(); 24 | } 25 | 26 | @After 27 | public void tearDown() { 28 | Locale.setDefault(mOriginalLocale); 29 | TimeZone.setDefault(mOriginalTimezone); 30 | } 31 | 32 | @Test 33 | public void testFormatter() { 34 | LogDateFormatter formatter = new LogDateFormatter(); 35 | 36 | TimeZone.setDefault(TimeZone.getTimeZone("UTC")); 37 | 38 | Locale locale = new Locale("en"); 39 | Locale.setDefault(locale); 40 | 41 | LogEntity log = new LogEntity( 42 | buildCalendar(1980, 5, 8, 9, 22, 0), 43 | LogAction.ALLOWED, 44 | null 45 | ); 46 | 47 | assertEquals("6/8/80 9:22 AM", formatter.formatDate(log)); 48 | 49 | locale = new Locale("ja"); 50 | Locale.setDefault(locale); 51 | 52 | log.setCreated(buildCalendar(2020, 11, 25, 12, 5, 59)); 53 | 54 | assertEquals("20/12/25 12:05", formatter.formatDate(log)); 55 | } 56 | 57 | private Calendar buildCalendar(int year, int month, int day, int hour, int minute, int second) { 58 | Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); 59 | calendar.set(Calendar.AM_PM, 0); 60 | calendar.set(Calendar.YEAR, year); 61 | calendar.set(Calendar.MONTH, month); 62 | calendar.set(Calendar.DATE, day); 63 | calendar.set(Calendar.HOUR, hour); 64 | calendar.set(Calendar.MINUTE, minute); 65 | calendar.set(Calendar.SECOND, second); 66 | calendar.set(Calendar.MILLISECOND, 0); 67 | return calendar; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/rulelist/RuleListAdapterTouchHelper.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.rulelist; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.recyclerview.widget.ItemTouchHelper; 5 | import androidx.recyclerview.widget.RecyclerView; 6 | 7 | public class RuleListAdapterTouchHelper extends ItemTouchHelper.Callback { 8 | private final RuleListAdapter mAdapter; 9 | 10 | public RuleListAdapterTouchHelper(RuleListAdapter adapter) { 11 | mAdapter = adapter; 12 | } 13 | 14 | @Override 15 | public boolean isLongPressDragEnabled() { 16 | // Since we're using a context menu we only want the drag to work through the drag handle 17 | return false; 18 | } 19 | 20 | @Override 21 | public int getMovementFlags( 22 | @NonNull RecyclerView recyclerView, 23 | @NonNull RecyclerView.ViewHolder viewHolder 24 | ) { 25 | if (!mAdapter.canMoveItem(viewHolder.getAdapterPosition())) { 26 | return makeMovementFlags(0, 0); 27 | } 28 | 29 | final int dragFlags = mAdapter.canMoveItem(viewHolder.getAdapterPosition()) 30 | ? ItemTouchHelper.UP | ItemTouchHelper.DOWN 31 | : 0; 32 | 33 | return makeMovementFlags(dragFlags, 0); 34 | } 35 | 36 | @Override 37 | public boolean onMove( 38 | @NonNull RecyclerView recyclerView, 39 | @NonNull RecyclerView.ViewHolder viewHolder, 40 | @NonNull RecyclerView.ViewHolder target 41 | ) { 42 | if (viewHolder.getItemViewType() != target.getItemViewType()) { 43 | return false; 44 | } 45 | 46 | if (mAdapter.canMoveItem(viewHolder.getAdapterPosition())) { 47 | mAdapter.moveItem(viewHolder.getAdapterPosition(), target.getAdapterPosition()); 48 | } 49 | 50 | return true; 51 | } 52 | 53 | @Override 54 | public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { 55 | mAdapter.deleteItem(viewHolder.getAdapterPosition()); 56 | } 57 | 58 | @Override 59 | public void clearView( 60 | @NonNull RecyclerView recyclerView, 61 | @NonNull RecyclerView.ViewHolder viewHolder 62 | ) { 63 | super.clearView(recyclerView, viewHolder); 64 | 65 | mAdapter.onClearView(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/entity/LogEntity.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.entity; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | import androidx.room.Entity; 6 | import androidx.room.Ignore; 7 | import androidx.room.PrimaryKey; 8 | import androidx.room.TypeConverters; 9 | 10 | import com.novyr.callfilter.db.converter.CalendarConverter; 11 | import com.novyr.callfilter.db.converter.LogActionConverter; 12 | import com.novyr.callfilter.db.entity.enums.LogAction; 13 | import com.novyr.callfilter.model.Log; 14 | 15 | import java.util.Calendar; 16 | import java.util.TimeZone; 17 | 18 | @Entity(tableName = "log_entity") 19 | public class LogEntity implements Log { 20 | @PrimaryKey(autoGenerate = true) 21 | private int id; 22 | 23 | @NonNull 24 | @TypeConverters(CalendarConverter.class) 25 | private Calendar created; 26 | 27 | @NonNull 28 | @TypeConverters(LogActionConverter.class) 29 | private LogAction action; 30 | 31 | @Nullable 32 | private String number; 33 | 34 | @Ignore 35 | public LogEntity(@NonNull LogAction action, @Nullable String number) { 36 | this(createCalendar(), action, number); 37 | } 38 | 39 | public LogEntity( 40 | @NonNull Calendar created, 41 | @NonNull LogAction action, 42 | @Nullable String number 43 | ) { 44 | this.created = created; 45 | this.action = action; 46 | this.number = number; 47 | } 48 | 49 | private static Calendar createCalendar() { 50 | return Calendar.getInstance(TimeZone.getTimeZone("UTC")); 51 | } 52 | 53 | public int getId() { 54 | return id; 55 | } 56 | 57 | public void setId(int id) { 58 | this.id = id; 59 | } 60 | 61 | @NonNull 62 | public Calendar getCreated() { 63 | return created; 64 | } 65 | 66 | public void setCreated(@NonNull Calendar created) { 67 | this.created = created; 68 | } 69 | 70 | @NonNull 71 | public LogAction getAction() { 72 | return action; 73 | } 74 | 75 | public void setAction(@NonNull LogAction action) { 76 | this.action = action; 77 | } 78 | 79 | @Nullable 80 | public String getNumber() { 81 | return number; 82 | } 83 | 84 | public void setNumber(@Nullable String number) { 85 | this.number = number; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/rules/AreaCodeRuleHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import com.novyr.callfilter.AreaCodeExtractor; 4 | import com.novyr.callfilter.CallDetails; 5 | import com.novyr.callfilter.ContactFinder; 6 | 7 | import org.junit.Test; 8 | 9 | import static org.junit.Assert.assertFalse; 10 | import static org.junit.Assert.assertTrue; 11 | import static org.mockito.Mockito.mock; 12 | import static org.mockito.Mockito.when; 13 | 14 | public class AreaCodeRuleHandlerTest { 15 | private static final String INVALID_NUMBER = "1"; 16 | private static final String VALID_NUMBER_1 = "8005551234"; 17 | private static final String VALID_NUMBER_2 = "9005551234"; 18 | private static final String VALID_AREA_CODE_1 = "800"; 19 | private static final String VALID_AREA_CODE_2 = "900"; 20 | 21 | private AreaCodeExtractor createExtractorMock() { 22 | AreaCodeExtractor extractor = mock(AreaCodeExtractor.class); 23 | 24 | when(extractor.extract(INVALID_NUMBER)).thenReturn(null); 25 | when(extractor.extract(VALID_NUMBER_1)).thenReturn(VALID_AREA_CODE_1); 26 | when(extractor.extract(VALID_NUMBER_2)).thenReturn(VALID_AREA_CODE_2); 27 | 28 | return extractor; 29 | } 30 | 31 | @Test 32 | public void checkPrivateMatch() { 33 | AreaCodeRuleHandler checker = new AreaCodeRuleHandler(createExtractorMock()); 34 | 35 | assertFalse(checker.isMatch(new CallDetails(null), VALID_AREA_CODE_1)); 36 | } 37 | 38 | @Test 39 | public void checkInvalidMatch() { 40 | AreaCodeRuleHandler checker = new AreaCodeRuleHandler(createExtractorMock()); 41 | 42 | assertFalse(checker.isMatch(new CallDetails(INVALID_NUMBER), VALID_AREA_CODE_1)); 43 | assertFalse(checker.isMatch(new CallDetails(INVALID_NUMBER), null)); 44 | } 45 | 46 | @Test 47 | public void checkNormalMatch() { 48 | AreaCodeRuleHandler checker = new AreaCodeRuleHandler(createExtractorMock()); 49 | 50 | assertTrue(checker.isMatch(new CallDetails(VALID_NUMBER_1), VALID_AREA_CODE_1)); 51 | assertTrue(checker.isMatch(new CallDetails(VALID_NUMBER_2), VALID_AREA_CODE_2)); 52 | assertFalse(checker.isMatch(new CallDetails(VALID_NUMBER_1), VALID_AREA_CODE_2)); 53 | assertFalse(checker.isMatch(new CallDetails(VALID_NUMBER_2), VALID_AREA_CODE_1)); 54 | assertFalse(checker.isMatch(new CallDetails(VALID_NUMBER_2), null)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/rules/MatchRuleHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import android.view.View; 4 | import android.widget.EditText; 5 | 6 | import androidx.annotation.LayoutRes; 7 | import androidx.annotation.NonNull; 8 | import androidx.annotation.Nullable; 9 | 10 | import com.novyr.callfilter.CallDetails; 11 | import com.novyr.callfilter.R; 12 | import com.novyr.callfilter.db.entity.RuleEntity; 13 | import com.novyr.callfilter.rules.exception.InvalidValueException; 14 | 15 | import io.github.azagniotov.matcher.AntPathMatcher; 16 | 17 | public class MatchRuleHandler implements RuleHandlerInterface, RuleHandlerWithFormInterface { 18 | @Override 19 | public boolean isMatch(@NonNull CallDetails details, @Nullable String ruleValue) { 20 | String number = details.getPhoneNumber(); 21 | if (number == null || ruleValue == null) { 22 | return false; 23 | } 24 | 25 | if (ruleValue.contains("*") || ruleValue.contains("?")) { 26 | AntPathMatcher pathMatcher = new AntPathMatcher.Builder().build(); 27 | return pathMatcher.isMatch(ruleValue, normalizeNumber(number)); 28 | } 29 | 30 | return normalizeNumber(number).equals(normalizeNumber(ruleValue)); 31 | } 32 | 33 | private String normalizeNumber(String number) { 34 | String normalizedNumber = number.replaceAll("[^\\d]", ""); 35 | 36 | // We exclude the country code in case it was only provided in one of the numbers 37 | if (normalizedNumber.length() == 11 && normalizedNumber.startsWith("1")) { 38 | return normalizedNumber.substring(1); 39 | } 40 | 41 | return normalizedNumber; 42 | } 43 | 44 | @Override 45 | @LayoutRes 46 | public int getEditDialogLayout() { 47 | return R.layout.form_rule_match; 48 | } 49 | 50 | @Override 51 | public void loadFormValues(View view, RuleEntity rule) { 52 | EditText input = view.findViewById(R.id.match_input); 53 | 54 | input.setText(rule.getValue()); 55 | } 56 | 57 | @Override 58 | public void saveFormValues(View view, RuleEntity rule) throws InvalidValueException { 59 | EditText inputView = view.findViewById(R.id.match_input); 60 | String input = inputView.getText().toString(); 61 | 62 | if (input.isEmpty()) { 63 | throw new InvalidValueException(R.string.rule_form_label_match); 64 | } 65 | 66 | rule.setValue(input); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/telephony/AndroidLegacyHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.telephony; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.content.Context; 5 | import android.telephony.TelephonyManager; 6 | import android.util.Log; 7 | 8 | import com.novyr.callfilter.BuildConfig; 9 | 10 | import java.lang.reflect.Method; 11 | 12 | public class AndroidLegacyHandler implements HandlerInterface { 13 | private static final String TAG = AndroidLegacyHandler.class.getSimpleName(); 14 | 15 | private Object mInterfaceTelephony = null; 16 | private Method mMethodSilenceRinger = null; 17 | private Method mMethodEndCall = null; 18 | 19 | AndroidLegacyHandler(Context context) { 20 | try { 21 | TelephonyManager manager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); 22 | if (manager == null) { 23 | throw new Exception("Failed to get TelephonyManager system service."); 24 | } 25 | 26 | @SuppressLint({"PrivateApi", "SoonBlockedPrivateApi"}) 27 | Method methodGetInterface = manager.getClass().getDeclaredMethod("getITelephony"); 28 | methodGetInterface.setAccessible(true); 29 | 30 | mInterfaceTelephony = methodGetInterface.invoke(manager); 31 | if (mInterfaceTelephony != null) { 32 | mMethodEndCall = mInterfaceTelephony.getClass().getDeclaredMethod("endCall"); 33 | mMethodSilenceRinger = mInterfaceTelephony.getClass() 34 | .getDeclaredMethod("silenceRinger"); 35 | } 36 | } catch (Exception e) { 37 | if (BuildConfig.DEBUG) { 38 | Log.e(TAG, "Failed to find telephony interface or methods", e); 39 | } 40 | } 41 | } 42 | 43 | public boolean endCall() { 44 | boolean result = false; 45 | try { 46 | if (mInterfaceTelephony == null || mMethodEndCall == null) { 47 | return false; 48 | } 49 | 50 | if (mMethodSilenceRinger != null) { 51 | mMethodSilenceRinger.invoke(mInterfaceTelephony); 52 | } 53 | 54 | result = (boolean) mMethodEndCall.invoke(mInterfaceTelephony); 55 | } catch (Exception e) { 56 | if (BuildConfig.DEBUG) { 57 | Log.e(TAG, "Failed to silence and end call", e); 58 | } 59 | } 60 | return result; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/schemas/com.novyr.callfilter.db.CallFilterDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "624bd103d60d961f9ce1e0ef630cdde3", 6 | "entities": [ 7 | { 8 | "tableName": "log_entity", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created` INTEGER NOT NULL, `action` INTEGER NOT NULL, `number` TEXT)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "created", 19 | "columnName": "created", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "action", 25 | "columnName": "action", 26 | "affinity": "INTEGER", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "number", 31 | "columnName": "number", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | } 35 | ], 36 | "primaryKey": { 37 | "columnNames": [ 38 | "id" 39 | ], 40 | "autoGenerate": true 41 | }, 42 | "indices": [], 43 | "foreignKeys": [] 44 | }, 45 | { 46 | "tableName": "whitelist_entity", 47 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `number` TEXT NOT NULL)", 48 | "fields": [ 49 | { 50 | "fieldPath": "id", 51 | "columnName": "id", 52 | "affinity": "INTEGER", 53 | "notNull": true 54 | }, 55 | { 56 | "fieldPath": "number", 57 | "columnName": "number", 58 | "affinity": "TEXT", 59 | "notNull": true 60 | } 61 | ], 62 | "primaryKey": { 63 | "columnNames": [ 64 | "id" 65 | ], 66 | "autoGenerate": true 67 | }, 68 | "indices": [], 69 | "foreignKeys": [] 70 | } 71 | ], 72 | "views": [], 73 | "setupQueries": [ 74 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 75 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '624bd103d60d961f9ce1e0ef630cdde3')" 76 | ] 77 | } 78 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 12 | 15 | 18 | 19 | 22 | 23 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/loglist/LogViewHolder.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.loglist; 2 | 3 | import android.view.ContextMenu; 4 | import android.view.View; 5 | import android.widget.ImageView; 6 | import android.widget.TextView; 7 | 8 | import androidx.recyclerview.widget.RecyclerView; 9 | 10 | import com.novyr.callfilter.R; 11 | import com.novyr.callfilter.db.entity.LogEntity; 12 | import com.novyr.callfilter.formatter.DateFormatter; 13 | import com.novyr.callfilter.formatter.MessageFormatter; 14 | 15 | class LogViewHolder extends RecyclerView.ViewHolder implements View.OnCreateContextMenuListener { 16 | private final TextView mMessageView; 17 | private final TextView mCreatedView; 18 | private final ImageView mIcon; 19 | private final MessageFormatter mMessageFormatter; 20 | private final DateFormatter mDateFormatter; 21 | private final LogListMenuHandler mMenuHandler; 22 | private LogEntity mEntity; 23 | 24 | LogViewHolder( 25 | View itemView, 26 | MessageFormatter messageFormatter, 27 | DateFormatter dateFormatter, 28 | LogListMenuHandler menuHandler 29 | ) { 30 | super(itemView); 31 | 32 | mMessageView = itemView.findViewById(R.id.log_list_message); 33 | mCreatedView = itemView.findViewById(R.id.log_list_created); 34 | mIcon = itemView.findViewById(R.id.log_list_icon); 35 | 36 | mMessageFormatter = messageFormatter; 37 | mDateFormatter = dateFormatter; 38 | mMenuHandler = menuHandler; 39 | 40 | itemView.setOnCreateContextMenuListener(this); 41 | } 42 | 43 | void setEntity(LogEntity entity) { 44 | mEntity = entity; 45 | 46 | mMessageView.setText(mMessageFormatter.formatMessage(entity)); 47 | mCreatedView.setText(mDateFormatter.formatDate(entity)); 48 | 49 | switch (entity.getAction()) { 50 | case ALLOWED: 51 | mIcon.setImageResource(R.drawable.ic_check_green_24dp); 52 | break; 53 | case BLOCKED: 54 | mIcon.setImageResource(R.drawable.ic_block_red_24dp); 55 | break; 56 | case FAILED: 57 | mIcon.setImageResource(R.drawable.ic_error_outline_black_24dp); 58 | break; 59 | } 60 | } 61 | 62 | @Override 63 | public void onCreateContextMenu( 64 | ContextMenu menu, 65 | View v, 66 | ContextMenu.ContextMenuInfo menuInfo 67 | ) { 68 | if (mEntity != null) { 69 | mMenuHandler.createMenu(v.getContext(), menu, mEntity); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/formatter/LogMessageFormatter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.formatter; 2 | 3 | import android.content.res.Resources; 4 | import android.os.Build; 5 | import android.telephony.PhoneNumberUtils; 6 | 7 | import androidx.annotation.NonNull; 8 | 9 | import com.novyr.callfilter.ContactFinder; 10 | import com.novyr.callfilter.R; 11 | import com.novyr.callfilter.db.entity.LogEntity; 12 | 13 | import java.util.Locale; 14 | 15 | public class LogMessageFormatter implements MessageFormatter { 16 | private final Resources mResources; 17 | private final ContactFinder mContactFinder; 18 | 19 | public LogMessageFormatter(Resources resources, ContactFinder contactFinder) { 20 | mResources = resources; 21 | mContactFinder = contactFinder; 22 | } 23 | 24 | public String formatMessage(LogEntity entity) { 25 | String action; 26 | switch (entity.getAction()) { 27 | case BLOCKED: 28 | action = mResources.getString(R.string.log_action_blocked); 29 | break; 30 | case ALLOWED: 31 | action = mResources.getString(R.string.log_action_allowed); 32 | break; 33 | case FAILED: 34 | action = mResources.getString(R.string.log_action_failed); 35 | break; 36 | default: 37 | action = String.format( 38 | mResources.getString(R.string.log_action_unknown), 39 | entity.getAction().getCode() 40 | ); 41 | break; 42 | } 43 | 44 | String number = entity.getNumber(); 45 | String formatted; 46 | 47 | if (number == null) { 48 | formatted = mResources.getString(R.string.log_number_private); 49 | } else { 50 | formatted = formatNumber(number); 51 | } 52 | 53 | if (formatted != null) { 54 | number = formatted; 55 | } 56 | 57 | return String.format(mResources.getString(R.string.log_message_format), action, number); 58 | } 59 | 60 | private String formatNumber(@NonNull String number) { 61 | try { 62 | String contactName = mContactFinder.findContactName(number); 63 | if (contactName != null) { 64 | return contactName; 65 | } 66 | } catch (InternalError ignored) { 67 | } 68 | 69 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 70 | return PhoneNumberUtils.formatNumber(number, Locale.getDefault().getCountry()); 71 | } else { 72 | // noinspection deprecation 73 | return PhoneNumberUtils.formatNumber(number); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/rules/MatchRuleHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.rules; 2 | 3 | import com.novyr.callfilter.CallDetails; 4 | 5 | import org.junit.Test; 6 | 7 | import static org.junit.Assert.assertFalse; 8 | import static org.junit.Assert.assertTrue; 9 | 10 | public class MatchRuleHandlerTest { 11 | @Test 12 | public void checkPrivateMatch() { 13 | MatchRuleHandler checker = new MatchRuleHandler(); 14 | 15 | assertFalse(checker.isMatch(new CallDetails(null), "8005551234")); 16 | } 17 | 18 | @Test 19 | public void checkInvalidMatch() { 20 | MatchRuleHandler checker = new MatchRuleHandler(); 21 | 22 | assertFalse(checker.isMatch(new CallDetails("1"), "8005551234")); 23 | assertFalse(checker.isMatch(new CallDetails("1"), null)); 24 | } 25 | 26 | @Test 27 | public void checkNormalMatch() { 28 | MatchRuleHandler checker = new MatchRuleHandler(); 29 | 30 | assertTrue(checker.isMatch(new CallDetails("8005551234"), "8005551234")); 31 | assertFalse(checker.isMatch(new CallDetails("9005551234"), "8005551234")); 32 | } 33 | 34 | @Test 35 | public void checkWildcardMatch() { 36 | MatchRuleHandler checker = new MatchRuleHandler(); 37 | 38 | assertTrue(checker.isMatch(new CallDetails("8005551234"), "*8005551234*")); 39 | 40 | assertTrue(checker.isMatch(new CallDetails("8045551234"), "80?*")); 41 | assertFalse(checker.isMatch(new CallDetails("8145551234"), "80?*")); 42 | 43 | assertFalse(checker.isMatch(new CallDetails("8005551234"), "800555")); 44 | assertTrue(checker.isMatch(new CallDetails("8005551234"), "800555*")); 45 | assertFalse(checker.isMatch(new CallDetails("8005551234"), "5551234")); 46 | assertTrue(checker.isMatch(new CallDetails("8005551234"), "*5551234")); 47 | 48 | assertFalse(checker.isMatch(new CallDetails("9005551234"), "*321*")); 49 | assertTrue(checker.isMatch(new CallDetails("9005551234"), "*123*")); 50 | 51 | assertTrue(checker.isMatch(new CallDetails("9005551234"), "?005551234")); 52 | assertTrue(checker.isMatch(new CallDetails("8005551234"), "?005551234")); 53 | } 54 | 55 | @Test 56 | public void checkVariant() { 57 | MatchRuleHandler checker = new MatchRuleHandler(); 58 | 59 | assertTrue(checker.isMatch(new CallDetails("18005551234"), "8005551234")); 60 | assertTrue(checker.isMatch(new CallDetails("8005551234"), "8005551234")); 61 | assertTrue(checker.isMatch(new CallDetails("8005551234"), "18005551234")); 62 | assertTrue(checker.isMatch(new CallDetails("800-555-1234"), "8005551234")); 63 | assertTrue(checker.isMatch(new CallDetails("1 (800) 555 1234"), "8005551234")); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/res/layout/form_rule.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 22 | 23 | 30 | 31 | 37 | 38 | 44 | 45 | 51 | 52 | 58 | 59 | 65 | 66 | 71 | 72 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/entity/RuleEntity.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.entity; 2 | 3 | import androidx.annotation.NonNull; 4 | import androidx.annotation.Nullable; 5 | import androidx.room.Entity; 6 | import androidx.room.PrimaryKey; 7 | import androidx.room.TypeConverters; 8 | 9 | import com.novyr.callfilter.db.converter.RuleActionConverter; 10 | import com.novyr.callfilter.db.converter.RuleTypeConverter; 11 | import com.novyr.callfilter.db.entity.enums.RuleAction; 12 | import com.novyr.callfilter.db.entity.enums.RuleType; 13 | import com.novyr.callfilter.model.Rule; 14 | 15 | @Entity(tableName = "rule_entity") 16 | public class RuleEntity implements Rule { 17 | @PrimaryKey(autoGenerate = true) 18 | private int id; 19 | 20 | @NonNull 21 | @TypeConverters(RuleTypeConverter.class) 22 | private RuleType type; 23 | 24 | @NonNull 25 | @TypeConverters(RuleActionConverter.class) 26 | private RuleAction action; 27 | 28 | @Nullable 29 | private String value; 30 | 31 | private boolean enabled; 32 | 33 | private int order; 34 | 35 | public RuleEntity( 36 | @NonNull RuleType type, 37 | @NonNull RuleAction action, 38 | @Nullable String value, 39 | boolean enabled, 40 | int order 41 | ) { 42 | this.type = type; 43 | this.action = action; 44 | this.value = value; 45 | this.enabled = enabled; 46 | this.order = order; 47 | } 48 | 49 | public RuleEntity(RuleEntity entity) { 50 | this.id = entity.id; 51 | this.type = entity.type; 52 | this.action = entity.action; 53 | this.value = entity.value; 54 | this.enabled = entity.enabled; 55 | this.order = entity.order; 56 | } 57 | 58 | public int getId() { 59 | return id; 60 | } 61 | 62 | public void setId(int id) { 63 | this.id = id; 64 | } 65 | 66 | @NonNull 67 | public RuleType getType() { 68 | return type; 69 | } 70 | 71 | public void setType(@NonNull RuleType type) { 72 | this.type = type; 73 | } 74 | 75 | @NonNull 76 | public RuleAction getAction() { 77 | return action; 78 | } 79 | 80 | public void setAction(@NonNull RuleAction action) { 81 | this.action = action; 82 | } 83 | 84 | @Nullable 85 | public String getValue() { 86 | return value; 87 | } 88 | 89 | public void setValue(@Nullable String value) { 90 | this.value = value; 91 | } 92 | 93 | public boolean isEnabled() { 94 | return enabled; 95 | } 96 | 97 | public void setEnabled(boolean enabled) { 98 | this.enabled = enabled; 99 | } 100 | 101 | public int getOrder() { 102 | return order; 103 | } 104 | 105 | public void setOrder(int order) { 106 | this.order = order; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/rulelist/RuleListAdapter.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.rulelist; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.view.ViewGroup; 5 | 6 | import androidx.annotation.NonNull; 7 | import androidx.annotation.Nullable; 8 | import androidx.recyclerview.widget.RecyclerView; 9 | 10 | import com.novyr.callfilter.db.entity.RuleEntity; 11 | 12 | import java.util.List; 13 | 14 | public class RuleListAdapter extends RecyclerView.Adapter { 15 | private final RuleListActionHelper mRuleListActionHelper; 16 | private RuleViewHolderFactory mViewHolderFactory; 17 | private List mEntries; 18 | 19 | RuleListAdapter(RuleListActionHelper ruleListActionHelper) { 20 | mRuleListActionHelper = ruleListActionHelper; 21 | } 22 | 23 | public void setViewHolderFactory(RuleViewHolderFactory viewHolderFactory) { 24 | mViewHolderFactory = viewHolderFactory; 25 | } 26 | 27 | void setEntities(List entities) { 28 | mEntries = entities; 29 | 30 | notifyDataSetChanged(); 31 | } 32 | 33 | @NonNull 34 | @Override 35 | public RuleViewHolder onCreateViewHolder(@Nullable ViewGroup parent, int viewType) { 36 | return mViewHolderFactory.create(parent); 37 | } 38 | 39 | @SuppressLint("ClickableViewAccessibility") 40 | @Override 41 | public void onBindViewHolder(@NonNull RuleViewHolder holder, int position) { 42 | if (mEntries == null) { 43 | return; 44 | } 45 | 46 | RuleEntity rule = mEntries.get(position); 47 | if (rule != null) { 48 | holder.setCurrentRule(rule); 49 | } 50 | } 51 | 52 | @Override 53 | public int getItemCount() { 54 | return mEntries != null ? mEntries.size() : 0; 55 | } 56 | 57 | public boolean canMoveItem(int position) { 58 | return mRuleListActionHelper.canMove(mEntries.get(position)); 59 | } 60 | 61 | public void moveItem(int fromPosition, int toPosition) { 62 | if (!canMoveItem(fromPosition) || !canMoveItem(toPosition)) { 63 | return; 64 | } 65 | 66 | RuleEntity fromRule = mEntries.remove(fromPosition); 67 | mEntries.add(toPosition, fromRule); 68 | notifyItemMoved(fromPosition, toPosition); 69 | 70 | // We don't reorder in the DB here, if we did the view would update with the LiveData and 71 | // cause the drag to stop. Instead we handle the DB reorder in onClearView when ordering is 72 | // complete. 73 | } 74 | 75 | public void deleteItem(int position) { 76 | RuleEntity rule = mEntries.get(position); 77 | 78 | if (!mRuleListActionHelper.canDelete(rule)) { 79 | return; 80 | } 81 | 82 | // Delete the item from the database, the item is removed from the list when the LiveData 83 | // updates 84 | mRuleListActionHelper.delete(rule); 85 | } 86 | 87 | public void onClearView() { 88 | mRuleListActionHelper.reorder(mEntries.toArray(new RuleEntity[0])); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/rulelist/RuleListActivity.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.rulelist; 2 | 3 | import android.os.Bundle; 4 | import android.view.View; 5 | import android.widget.TextView; 6 | 7 | import androidx.appcompat.app.AppCompatActivity; 8 | import androidx.lifecycle.ViewModelProvider; 9 | import androidx.recyclerview.widget.ItemTouchHelper; 10 | import androidx.recyclerview.widget.LinearLayoutManager; 11 | import androidx.recyclerview.widget.RecyclerView; 12 | 13 | import com.google.android.material.floatingactionbutton.FloatingActionButton; 14 | 15 | import com.novyr.callfilter.R; 16 | import com.novyr.callfilter.viewmodel.RuleViewModel; 17 | 18 | public class RuleListActivity extends AppCompatActivity { 19 | @Override 20 | protected void onCreate(Bundle savedInstanceState) { 21 | super.onCreate(savedInstanceState); 22 | 23 | setContentView(R.layout.activity_rule_list); 24 | 25 | final RecyclerView ruleList = findViewById(R.id.rule_list); 26 | final TextView emptyView = findViewById(R.id.empty_view); 27 | 28 | final RuleViewModel ruleViewModel = new ViewModelProvider(this).get(RuleViewModel.class); 29 | final RuleListActionHelper ruleListActionHelper = new RuleListActionHelper( 30 | this, 31 | ruleViewModel 32 | ); 33 | 34 | final RuleListAdapter adapter = new RuleListAdapter(ruleListActionHelper); 35 | 36 | ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new RuleListAdapterTouchHelper(adapter)); 37 | itemTouchHelper.attachToRecyclerView(ruleList); 38 | 39 | final RuleViewHolderFactory holderFactory = new RuleViewHolderFactory( 40 | this, 41 | ruleListActionHelper, 42 | itemTouchHelper::startDrag 43 | ); 44 | 45 | // TODO Figure out a better way to handle this. It is not attached to the constructor since 46 | // it needs to access itemTouchHolder which did not exist yet (and can't exist until 47 | // after the adapter). This whole class structure is a mess that needs to be cleaned up. 48 | adapter.setViewHolderFactory(holderFactory); 49 | 50 | ruleList.setAdapter(adapter); 51 | ruleList.setLayoutManager(new LinearLayoutManager(this)); 52 | 53 | FloatingActionButton fab = findViewById(R.id.add_button); 54 | fab.setOnClickListener(view -> ruleListActionHelper.showEditDialog()); 55 | 56 | ruleViewModel.highestOrder().observe( 57 | this, 58 | order -> ruleListActionHelper.setNextOrder(order + 2) 59 | ); 60 | 61 | ruleViewModel.findAll().observe(this, entities -> { 62 | adapter.setEntities(entities); 63 | 64 | if (adapter.getItemCount() > 0) { 65 | ruleList.setVisibility(View.VISIBLE); 66 | emptyView.setVisibility(View.GONE); 67 | } else { 68 | ruleList.setVisibility(View.GONE); 69 | emptyView.setVisibility(View.VISIBLE); 70 | } 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /.scripts/install-as-system-app.ps1: -------------------------------------------------------------------------------- 1 | $appPackage = "com.novyr.callfilter" 2 | $appName = "AndroidCallFilter" 3 | #$appMainActivity = "ui.loglist.LogListActivity" 4 | $adbPath = "adb" 5 | $apkFileName = "$appName.apk" 6 | $apkPathHost = "..\app\build\outputs\apk\debug\app-debug.apk" 7 | $targetApiVersion = $null # If null will attempt to determine 8 | 9 | if ($targetApiVersion -eq $null) { 10 | $emulatorProcesses = Get-WmiObject Win32_Process -Filter "name = 'emulator.exe'" | Select-Object CommandLine 11 | $scriptName = $MyInvocation.MyCommand.Name 12 | 13 | if ($emulatorProcesses.Length -eq 0) { 14 | Write-Host "Unabled to find emulator process`n" 15 | Write-Host "Launch an emulator with an AVD containing ""API ##"" in the name, or set the version you're targetting manually in $scriptName" 16 | Exit 1 17 | } elseif ($emulatorProcesses.Length -gt 1) { 18 | Write-Host "Multiple emulator processes found`n" 19 | Write-Host "Launch a single emulator with an AVD containing ""API ##"" in the name, or set the version you're targetting manually in $scriptName" 20 | Exit 1 21 | } 22 | 23 | if ($emulatorProcesses[0] -match '-avd\s+[^A\s]+API_([0-9]+)') { 24 | $targetApiVersion = $Matches.1 25 | } else { 26 | Write-Host "Failed to determine API version`n" 27 | Write-Host "Launch an emulator with an AVD containing ""API ##"" in the name, or set the version you're targetting manually in $scriptName" 28 | Exit 1 29 | } 30 | 31 | Write-Host "Found emulator running API $targetApiVersion" 32 | } 33 | 34 | if ($targetApiVersion -ge 21) { 35 | Write-Host "This should only be used for API versions below 21 (L)" 36 | Exit 1 37 | } elseif ($targetApiVersion -ge 18) { 38 | $systemAppPath = "/system/priv-app" 39 | } else { 40 | $systemAppPath = "/system/app" 41 | } 42 | 43 | $apkPathTarget = "$systemAppPath/$apkFileName" 44 | 45 | $discardedOutput = & $adbPath root | Out-String 46 | 47 | $output = & $adbPath remount | Out-String 48 | if (!$output.Contains("remount succeeded")) { 49 | Write-Host "Unable to remount system, make sure emulator is running with -writable-system switch`n" 50 | Write-Host $output 51 | Exit 1 52 | } 53 | 54 | Write-Host "Pushing $apkPathHost to $apkPathTarget" 55 | $output = & $adbPath push "$apkPathHost" "$apkPathTarget" | Out-String 56 | if ($output.Contains('failed to copy') -or !$output.Contains("1 file pushed")) { 57 | Write-Host "Failed to push file, make sure emulator is running with -writable-system switch" 58 | Write-Host $output 59 | Exit 1 60 | } 61 | 62 | $output = & $adbPath shell chmod 644 $apkPathTarget | Out-String 63 | if ($output.Length -gt 0) { 64 | Write-Host "Unexpected output while adjusting permissions`n" 65 | Write-Host $output 66 | } 67 | 68 | $output = & $adbPath shell mount -o remount,ro / | Out-String 69 | if ($output.Length -gt 0) { 70 | Write-Host "Unexpected output while remounting`n" 71 | Write-Host $output 72 | } 73 | 74 | $discardedOutput = & $adbPath shell am force-stop $appPackage | Out-String 75 | 76 | # This is left to Android Studio 77 | #$output = & $adbPath shell am start -n "$appPackage/$appPackage.$appMainActivity" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER 78 | 79 | Exit 0 80 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | } 4 | 5 | android { 6 | compileSdkVersion 30 7 | 8 | defaultConfig { 9 | applicationId "com.novyr.callfilter" 10 | minSdkVersion 15 11 | targetSdkVersion 30 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | javaCompileOptions { 16 | annotationProcessorOptions { 17 | arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] 18 | } 19 | } 20 | 21 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 22 | 23 | vectorDrawables { 24 | useSupportLibrary true 25 | } 26 | } 27 | 28 | buildTypes { 29 | release { 30 | minifyEnabled false 31 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 32 | } 33 | } 34 | 35 | compileOptions { 36 | sourceCompatibility JavaVersion.VERSION_1_8 37 | targetCompatibility JavaVersion.VERSION_1_8 38 | } 39 | 40 | testOptions { 41 | unitTests.returnDefaultValues = true 42 | } 43 | namespace 'com.novyr.callfilter' 44 | } 45 | 46 | dependencies { 47 | // App dependencies 48 | implementation fileTree(dir: 'libs', include: ['*.jar']) 49 | implementation 'androidx.appcompat:appcompat:1.2.0' 50 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 51 | implementation 'androidx.preference:preference:1.1.1' 52 | implementation 'androidx.recyclerview:recyclerview:1.1.0' 53 | 54 | implementation 'com.google.android.material:material:1.2.1' 55 | 56 | implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.15' 57 | implementation 'io.github.azagniotov:ant-style-path-matcher:1.0.0' 58 | 59 | // Room components 60 | implementation "androidx.room:room-runtime:$rootProject.roomVersion" 61 | annotationProcessor "androidx.room:room-compiler:$rootProject.roomVersion" 62 | androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion" 63 | 64 | // Lifecycle components 65 | implementation "androidx.lifecycle:lifecycle-common-java8:$rootProject.archLifecycleVersion" 66 | 67 | 68 | // Testing-only dependencies 69 | 70 | // Unit tests 71 | testImplementation 'junit:junit:4.13.1' 72 | testImplementation 'org.mockito:mockito-core:2.19.0' 73 | 74 | // Integration tests 75 | // Needed for InstantTaskExecutorRule 76 | androidTestImplementation "android.arch.core:core-testing:1.1.1" 77 | 78 | // AndroidX 79 | // Core library 80 | androidTestImplementation 'androidx.test:core:1.3.0' 81 | 82 | // AndroidJUnitRunner and JUnit Rules 83 | androidTestImplementation 'androidx.test:runner:1.3.0' 84 | androidTestImplementation 'androidx.test:rules:1.3.0' 85 | 86 | // Assertions 87 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 88 | androidTestImplementation 'androidx.test.ext:truth:1.3.0' 89 | androidTestImplementation 'com.google.truth:truth:1.0' 90 | 91 | } 92 | 93 | allprojects { 94 | tasks.withType(JavaCompile) { 95 | options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.scripts/Run as system app.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 61 | -------------------------------------------------------------------------------- /app/schemas/com.novyr.callfilter.db.CallFilterDatabase/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 2, 5 | "identityHash": "6ce6d289e53bf68ae468ade554f55bbd", 6 | "entities": [ 7 | { 8 | "tableName": "log_entity", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created` INTEGER NOT NULL, `action` INTEGER NOT NULL, `number` TEXT)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "created", 19 | "columnName": "created", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "action", 25 | "columnName": "action", 26 | "affinity": "INTEGER", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "number", 31 | "columnName": "number", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | } 35 | ], 36 | "primaryKey": { 37 | "columnNames": [ 38 | "id" 39 | ], 40 | "autoGenerate": true 41 | }, 42 | "indices": [], 43 | "foreignKeys": [] 44 | }, 45 | { 46 | "tableName": "rule_entity", 47 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `action` INTEGER NOT NULL, `value` TEXT, `enabled` INTEGER NOT NULL, `order` INTEGER NOT NULL)", 48 | "fields": [ 49 | { 50 | "fieldPath": "id", 51 | "columnName": "id", 52 | "affinity": "INTEGER", 53 | "notNull": true 54 | }, 55 | { 56 | "fieldPath": "type", 57 | "columnName": "type", 58 | "affinity": "INTEGER", 59 | "notNull": true 60 | }, 61 | { 62 | "fieldPath": "action", 63 | "columnName": "action", 64 | "affinity": "INTEGER", 65 | "notNull": true 66 | }, 67 | { 68 | "fieldPath": "value", 69 | "columnName": "value", 70 | "affinity": "TEXT", 71 | "notNull": false 72 | }, 73 | { 74 | "fieldPath": "enabled", 75 | "columnName": "enabled", 76 | "affinity": "INTEGER", 77 | "notNull": true 78 | }, 79 | { 80 | "fieldPath": "order", 81 | "columnName": "order", 82 | "affinity": "INTEGER", 83 | "notNull": true 84 | } 85 | ], 86 | "primaryKey": { 87 | "columnNames": [ 88 | "id" 89 | ], 90 | "autoGenerate": true 91 | }, 92 | "indices": [], 93 | "foreignKeys": [] 94 | } 95 | ], 96 | "views": [], 97 | "setupQueries": [ 98 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 99 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6ce6d289e53bf68ae468ade554f55bbd')" 100 | ] 101 | } 102 | } -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/CallFilterService.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import android.content.Context; 4 | import android.net.Uri; 5 | import android.os.Build; 6 | import android.telecom.Call; 7 | import android.telecom.CallScreeningService; 8 | import android.telecom.Connection; 9 | import android.util.Log; 10 | 11 | import androidx.annotation.NonNull; 12 | import androidx.annotation.RequiresApi; 13 | 14 | import com.novyr.callfilter.db.LogRepository; 15 | import com.novyr.callfilter.db.entity.LogEntity; 16 | import com.novyr.callfilter.db.entity.enums.LogAction; 17 | 18 | import java.util.concurrent.Executor; 19 | import java.util.concurrent.Executors; 20 | 21 | @RequiresApi(api = Build.VERSION_CODES.Q) 22 | public class CallFilterService extends CallScreeningService { 23 | private static final String TAG = CallFilterService.class.getSimpleName(); 24 | private final Executor executor = Executors.newSingleThreadExecutor(); 25 | 26 | @Override 27 | public void onScreenCall(@NonNull Call.Details details) { 28 | CallResponse.Builder response = new CallResponse.Builder(); 29 | response.setDisallowCall(false); 30 | response.setRejectCall(false); 31 | response.setSkipCallLog(false); 32 | response.setSkipNotification(false); 33 | 34 | if (details.getCallDirection() != Call.Details.DIRECTION_INCOMING) { 35 | respondToCall(details, response.build()); 36 | return; 37 | } 38 | 39 | Context context = getApplicationContext(); 40 | String number = getNumberFromDetails(details); 41 | 42 | executor.execute(() -> { 43 | RuleChecker checker = RuleCheckerFactory.create(context); 44 | LogAction action = LogAction.ALLOWED; 45 | 46 | // TODO Figure out how Samsung is setting this in versions before R, assuming they are. 47 | // My phone has some incoming calls marked as verified but is on Q, not R. 48 | int verificationStatus = 0; // Connection.VERIFICATION_STATUS_NOT_VERIFIED; 49 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 50 | verificationStatus = details.getCallerNumberVerificationStatus(); 51 | } 52 | 53 | if (!checker.allowCall(new CallDetails(number, verificationStatus))) { 54 | action = LogAction.BLOCKED; 55 | response.setDisallowCall(true); 56 | response.setRejectCall(true); 57 | response.setSkipNotification(true); 58 | 59 | // TODO Figure out why this doesn't seem to work with Google's phone app in the 60 | // emulator. It works as expected with Samsung's dialer on a real phone. 61 | response.setSkipCallLog(false); 62 | } 63 | 64 | LogRepository repository = ((CallFilterApplication) context.getApplicationContext()).getLogRepository(); 65 | repository.insert(new LogEntity(action, number)); 66 | 67 | respondToCall(details, response.build()); 68 | }); 69 | } 70 | 71 | private String getNumberFromDetails(@NonNull Call.Details details) { 72 | Uri handle = details.getHandle(); 73 | if (handle == null) { 74 | Log.e(TAG, "No handle on incoming call"); 75 | return null; 76 | } 77 | 78 | String scheme = handle.getScheme(); 79 | if (scheme != null && scheme.equals("tel")) { 80 | return handle.getSchemeSpecificPart(); 81 | } 82 | 83 | Log.e(TAG, "Unhandled scheme"); 84 | return null; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/loglist/LogListMenuHandler.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.loglist; 2 | 3 | import android.app.Activity; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.net.Uri; 7 | import android.provider.ContactsContract; 8 | import android.view.ContextMenu; 9 | import android.view.MenuItem; 10 | 11 | import com.novyr.callfilter.ContactFinder; 12 | import com.novyr.callfilter.R; 13 | import com.novyr.callfilter.db.entity.LogEntity; 14 | import com.novyr.callfilter.viewmodel.LogViewModel; 15 | 16 | class LogListMenuHandler { 17 | private final ContactFinder mContactFinder; 18 | private final LogViewModel mLogViewModel; 19 | private final Activity mActivity; 20 | 21 | LogListMenuHandler( 22 | Activity activity, 23 | ContactFinder contactFinder, 24 | LogViewModel logViewModel 25 | ) { 26 | mActivity = activity; 27 | mContactFinder = contactFinder; 28 | mLogViewModel = logViewModel; 29 | } 30 | 31 | void createMenu(Context context, final ContextMenu menu, final LogEntity entity) { 32 | final String number = entity.getNumber(); 33 | final MenuItem.OnMenuItemClickListener listener = menuItem -> { 34 | int itemId = menuItem.getItemId(); 35 | if (itemId == R.id.log_context_contacts_open) { 36 | openInContacts(number); 37 | return true; 38 | } else if (itemId == R.id.log_context_contact_create) { 39 | createContact(context, number); 40 | return true; 41 | } else if (itemId == R.id.log_context_log_remove) { 42 | removeLog(entity); 43 | return true; 44 | } 45 | return false; 46 | }; 47 | 48 | mActivity.getMenuInflater().inflate(R.menu.menu_log_context, menu); 49 | 50 | boolean numberHasContact = hasContact(number); 51 | 52 | menu.findItem(R.id.log_context_contacts_open) 53 | .setVisible(numberHasContact) 54 | .setOnMenuItemClickListener(listener); 55 | 56 | menu.findItem(R.id.log_context_contact_create) 57 | .setVisible(!numberHasContact && number != null) 58 | .setOnMenuItemClickListener(listener); 59 | 60 | menu.findItem(R.id.log_context_log_remove) 61 | .setVisible(true) 62 | .setOnMenuItemClickListener(listener); 63 | } 64 | 65 | private boolean hasContact(final String number) { 66 | try { 67 | String contactName = mContactFinder.findContactName(number); 68 | 69 | return contactName != null; 70 | } catch (Exception ignored) { 71 | } 72 | return false; 73 | } 74 | 75 | private void removeLog(LogEntity entity) { 76 | mLogViewModel.delete(entity); 77 | } 78 | 79 | private void openInContacts(final String number) { 80 | String contactId = mContactFinder.findContactId(number); 81 | if (contactId == null) { 82 | return; 83 | } 84 | 85 | Intent intent = new Intent(Intent.ACTION_VIEW); 86 | Uri uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, contactId); 87 | intent.setData(uri); 88 | mActivity.startActivity(intent); 89 | } 90 | 91 | private void createContact(Context context, String number) { 92 | Intent intent = new Intent(Intent.ACTION_INSERT); 93 | intent.setType(ContactsContract.Contacts.CONTENT_TYPE); 94 | intent.putExtra(ContactsContract.Intents.Insert.PHONE, number); 95 | context.startActivity(intent); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ContactFinder.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import android.Manifest; 4 | import android.content.ContentResolver; 5 | import android.content.Context; 6 | import android.content.pm.PackageManager; 7 | import android.database.Cursor; 8 | import android.net.Uri; 9 | import android.provider.ContactsContract; 10 | 11 | import androidx.core.content.ContextCompat; 12 | 13 | import com.novyr.callfilter.model.Contact; 14 | 15 | import java.util.HashMap; 16 | 17 | public class ContactFinder { 18 | private final HashMap mContacts = new HashMap<>(); 19 | private final ContentResolver mContentResolver; 20 | private final Context mContext; 21 | 22 | public ContactFinder(Context context) { 23 | mContext = context; 24 | mContentResolver = context.getContentResolver(); 25 | } 26 | 27 | public String findContactName(String number) throws InternalError { 28 | Contact contact = findContact(number); 29 | 30 | return contact != null ? contact.getName() : null; 31 | } 32 | 33 | public String findContactId(String number) throws InternalError { 34 | Contact contact = findContact(number); 35 | 36 | return contact != null ? contact.getId() : null; 37 | } 38 | 39 | private Contact findContact(String number) throws InternalError { 40 | if (mContacts.containsKey(number)) { 41 | return mContacts.get(number); 42 | } 43 | 44 | if (ContextCompat.checkSelfPermission( 45 | mContext, 46 | Manifest.permission.READ_CONTACTS 47 | ) != PackageManager.PERMISSION_GRANTED) { 48 | throw new InternalError("Unable to lookup contacts due to permissions"); 49 | } 50 | 51 | Uri lookupUri = Uri.withAppendedPath( 52 | ContactsContract.PhoneLookup.CONTENT_FILTER_URI, 53 | Uri.encode(number) 54 | ); 55 | String[] phoneNumberProjection = {ContactsContract.PhoneLookup._ID, ContactsContract.PhoneLookup.NUMBER, ContactsContract.PhoneLookup.DISPLAY_NAME}; 56 | Cursor cursor = mContentResolver.query(lookupUri, phoneNumberProjection, null, null, null); 57 | if (cursor == null) { 58 | throw new InternalError("Failed to query content resolver"); 59 | } 60 | 61 | try { 62 | if (cursor.moveToFirst()) { 63 | String id = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup._ID)); 64 | String name = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME)); 65 | 66 | mContacts.put(number, new ContactModel(id, name)); 67 | 68 | return mContacts.get(number); 69 | } 70 | } catch (Exception e) { 71 | throw new InternalError(String.format( 72 | "Error while querying contacts %s", 73 | e.getMessage() 74 | )); 75 | } finally { 76 | cursor.close(); 77 | } 78 | 79 | return null; 80 | } 81 | 82 | private static class ContactModel implements Contact { 83 | private final String mId; 84 | private final String mName; 85 | 86 | ContactModel(String id, String name) { 87 | this.mId = id; 88 | this.mName = name; 89 | } 90 | 91 | @Override 92 | public String getId() { 93 | return mId; 94 | } 95 | 96 | @Override 97 | public String getName() { 98 | return mName; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Call Filter 4 | Filter Rules 5 | Settings 6 | Clear Logs 7 | Clear Logs 8 | Are you sure you want to clear the logs? This action cannot be undone. 9 | Log is empty 10 | No rules exist 11 | Open in Contacts 12 | Create Contact 13 | Remove from log 14 | Edit rule 15 | Delete rule 16 | Filter action icon 17 | permissions 18 | Permission request denied 19 | Call screening request denied 20 | Permission request blocked 21 | RETRY 22 | Add new rule 23 | Block 24 | Allow 25 | Anything else 26 | Numbers in contacts 27 | Numbers not in contacts 28 | Private numbers 29 | Numbers in area code 30 | Numbers matching 31 | Network Verification Failed 32 | Network Verification Passed 33 | Edit Rule 34 | Create Rule 35 | Enabled 36 | Action 37 | Match Type 38 | Area code 39 | Match 40 | Non-numeric characters and the US/Canada country code will be removed from the incoming number before matching (1-800-555-1234 will be matched as 8005551234)\n\nIf no wildcards are supplied, an exact match will be used.\n\nSupported wildcards:\n * Match zero or more digits\n ? Match one digit 41 | %s invalid 42 | Reorder drag handle 43 | Yes 44 | No 45 | Undo 46 | Blocked call 47 | Allowed call 48 | Failed to block call 49 | Unknown (%d) 50 | Private 51 | %1$s: %2$s 52 | 53 | -------------------------------------------------------------------------------- /app/src/main/res/layout/content_rule_entity.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 28 | 29 | 35 | 36 | 44 | 45 | 54 | 55 | 65 | 66 | 76 | 77 | 78 | 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/rulelist/RuleListActionHelper.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.rulelist; 2 | 3 | import android.app.Activity; 4 | import android.view.ContextMenu; 5 | import android.view.MenuItem; 6 | 7 | import com.google.android.material.snackbar.Snackbar; 8 | 9 | import com.novyr.callfilter.R; 10 | import com.novyr.callfilter.db.entity.RuleEntity; 11 | import com.novyr.callfilter.db.entity.enums.RuleAction; 12 | import com.novyr.callfilter.db.entity.enums.RuleType; 13 | import com.novyr.callfilter.ui.ruleedit.RuleEditDialog; 14 | import com.novyr.callfilter.viewmodel.RuleViewModel; 15 | 16 | // TODO Find a better name 17 | public class RuleListActionHelper { 18 | private final Activity mParent; 19 | private final RuleViewModel mRuleViewModel; 20 | private int mNextOrder; 21 | 22 | public RuleListActionHelper(Activity parent, RuleViewModel ruleViewModel) { 23 | mParent = parent; 24 | mRuleViewModel = ruleViewModel; 25 | } 26 | 27 | public void setNextOrder(int nextOrder) { 28 | mNextOrder = nextOrder; 29 | } 30 | 31 | public void enable(RuleEntity rule, boolean enabled) { 32 | if (rule.isEnabled() != enabled) { 33 | rule.setEnabled(enabled); 34 | mRuleViewModel.save(rule); 35 | } 36 | } 37 | 38 | public void showEditDialog(RuleEntity rule) { 39 | new RuleEditDialog(mParent).show(rule, mRuleViewModel::save); 40 | } 41 | 42 | public void showEditDialog() { 43 | new RuleEditDialog(mParent).show( 44 | new RuleEntity( 45 | RuleType.RECOGNIZED, 46 | RuleAction.ALLOW, 47 | null, 48 | true, 49 | mNextOrder 50 | ), 51 | mRuleViewModel::save 52 | ); 53 | } 54 | 55 | public boolean canMove(RuleEntity rule) { 56 | return rule.getType() != RuleType.UNMATCHED; 57 | } 58 | 59 | public void reorder(RuleEntity[] rules) { 60 | mRuleViewModel.reorder(rules); 61 | } 62 | 63 | public boolean canDelete(RuleEntity rule) { 64 | return rule.getType() != RuleType.UNMATCHED; 65 | } 66 | 67 | public void delete(RuleEntity rule) { 68 | mRuleViewModel.delete(rule); 69 | 70 | Snackbar.make( 71 | mParent.findViewById(android.R.id.content), 72 | "Rule deleted", 73 | Snackbar.LENGTH_LONG 74 | ) 75 | .setAction(R.string.undo, v -> { 76 | // Remove the ID so when it is re-added on undo the view model does not attempt 77 | // to update the now deleted row 78 | rule.setId(0); 79 | 80 | // Reduce the order, placing it before whatever replaced it in the on-delete 81 | // reorder 82 | rule.setOrder(Math.max(1, rule.getOrder() - 1)); 83 | 84 | mRuleViewModel.save(rule); 85 | }).show(); 86 | } 87 | 88 | public void createContextMenu(final ContextMenu contextMenu, final RuleEntity rule) { 89 | mParent.getMenuInflater().inflate(R.menu.menu_rule_context, contextMenu); 90 | 91 | final MenuItem.OnMenuItemClickListener listener = menuItem -> { 92 | int itemId = menuItem.getItemId(); 93 | if (itemId == R.id.rule_context_edit) { 94 | showEditDialog(rule); 95 | return true; 96 | } else if (itemId == R.id.rule_context_delete) { 97 | delete(rule); 98 | return true; 99 | } 100 | return false; 101 | }; 102 | 103 | contextMenu.findItem(R.id.rule_context_edit).setOnMenuItemClickListener(listener); 104 | 105 | contextMenu.findItem(R.id.rule_context_delete) 106 | .setEnabled(canDelete(rule)) 107 | .setOnMenuItemClickListener(listener); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/formatter/LogMessageFormatterTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.formatter; 2 | 3 | import android.content.res.Resources; 4 | 5 | import com.novyr.callfilter.ContactFinder; 6 | import com.novyr.callfilter.R; 7 | import com.novyr.callfilter.db.entity.LogEntity; 8 | import com.novyr.callfilter.db.entity.enums.LogAction; 9 | 10 | import org.junit.Before; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.MockitoJUnitRunner; 15 | 16 | import static org.junit.Assert.assertEquals; 17 | import static org.mockito.Mockito.mock; 18 | import static org.mockito.Mockito.when; 19 | 20 | @RunWith(MockitoJUnitRunner.class) 21 | public class LogMessageFormatterTest { 22 | private final String RECOGNIZED_NAME = "Contact Name"; 23 | private final String RECOGNIZED_NUMBER = "8005551234"; 24 | private final String UNRECOGNIZED_NUMBER = "9005554321"; 25 | private final String NUMBER_PRIVATE = "PRIVATE"; 26 | private final String ACTION_ALLOWED = "ALLOWED"; 27 | private final String ACTION_BLOCKED = "BLOCKED"; 28 | private final String ACTION_FAILED = "FAILED"; 29 | 30 | @Before 31 | public void setUp() { 32 | when(mMockResources.getString(R.string.log_action_allowed)).thenReturn(ACTION_ALLOWED); 33 | when(mMockResources.getString(R.string.log_action_blocked)).thenReturn(ACTION_BLOCKED); 34 | when(mMockResources.getString(R.string.log_action_failed)).thenReturn(ACTION_FAILED); 35 | when(mMockResources.getString(R.string.log_number_private)).thenReturn(NUMBER_PRIVATE); 36 | when(mMockResources.getString(R.string.log_message_format)).thenReturn("%1$s: %2$s"); 37 | } 38 | 39 | @Mock 40 | Resources mMockResources; 41 | 42 | @Test 43 | public void testRecognized() { 44 | LogMessageFormatter formatter = new LogMessageFormatter(mMockResources, createFinderMock()); 45 | 46 | LogEntity log = new LogEntity(LogAction.ALLOWED, RECOGNIZED_NUMBER); 47 | 48 | assertEquals( 49 | String.format("%s: %s", ACTION_ALLOWED, RECOGNIZED_NAME), 50 | formatter.formatMessage(log) 51 | ); 52 | } 53 | 54 | @Test 55 | public void testUnrecognized() { 56 | LogMessageFormatter formatter = new LogMessageFormatter(mMockResources, createFinderMock()); 57 | 58 | LogEntity log = new LogEntity(LogAction.ALLOWED, UNRECOGNIZED_NUMBER); 59 | 60 | assertEquals( 61 | String.format("%s: %s", ACTION_ALLOWED, UNRECOGNIZED_NUMBER), 62 | formatter.formatMessage(log) 63 | ); 64 | } 65 | 66 | @Test 67 | public void testPrivate() { 68 | LogMessageFormatter formatter = new LogMessageFormatter(mMockResources, createFinderMock()); 69 | 70 | LogEntity log = new LogEntity(LogAction.ALLOWED, null); 71 | 72 | assertEquals( 73 | String.format("%s: %s", ACTION_ALLOWED, NUMBER_PRIVATE), 74 | formatter.formatMessage(log) 75 | ); 76 | } 77 | 78 | @Test 79 | public void testActions() { 80 | LogMessageFormatter formatter = new LogMessageFormatter(mMockResources, createFinderMock()); 81 | 82 | LogEntity log = new LogEntity(LogAction.ALLOWED, null); 83 | assertEquals( 84 | String.format("%s: %s", ACTION_ALLOWED, NUMBER_PRIVATE), 85 | formatter.formatMessage(log) 86 | ); 87 | 88 | log.setAction(LogAction.BLOCKED); 89 | assertEquals( 90 | String.format("%s: %s", ACTION_BLOCKED, NUMBER_PRIVATE), 91 | formatter.formatMessage(log) 92 | ); 93 | 94 | log.setAction(LogAction.FAILED); 95 | assertEquals( 96 | String.format("%s: %s", ACTION_FAILED, NUMBER_PRIVATE), 97 | formatter.formatMessage(log) 98 | ); 99 | } 100 | 101 | private ContactFinder createFinderMock() { 102 | ContactFinder finder = mock(ContactFinder.class); 103 | 104 | when(finder.findContactName(RECOGNIZED_NUMBER)).thenReturn(RECOGNIZED_NAME); 105 | when(finder.findContactName(UNRECOGNIZED_NUMBER)).thenReturn(null); 106 | 107 | return finder; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/novyr/callfilter/db/dao/LogDaoTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db.dao; 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule; 4 | import androidx.room.Room; 5 | import androidx.test.core.app.ApplicationProvider; 6 | import androidx.test.filters.MediumTest; 7 | 8 | import com.novyr.callfilter.db.CallFilterDatabase; 9 | import com.novyr.callfilter.db.LiveDataTestUtil; 10 | import com.novyr.callfilter.db.entity.LogEntity; 11 | import com.novyr.callfilter.db.entity.enums.LogAction; 12 | 13 | import org.junit.After; 14 | import org.junit.Before; 15 | import org.junit.Rule; 16 | import org.junit.Test; 17 | 18 | import java.util.LinkedList; 19 | import java.util.List; 20 | import java.util.Random; 21 | 22 | import static org.junit.Assert.assertEquals; 23 | 24 | @MediumTest 25 | public class LogDaoTest { 26 | 27 | @Rule 28 | public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); 29 | private CallFilterDatabase mDatabase; 30 | private LogDao mLogDao; 31 | 32 | @Before 33 | public void initDb() { 34 | // using an in-memory database because the information stored here disappears when the 35 | // process is killed 36 | mDatabase = Room.inMemoryDatabaseBuilder( 37 | ApplicationProvider.getApplicationContext(), 38 | CallFilterDatabase.class 39 | ) 40 | // allowing main thread queries, just for testing 41 | .allowMainThreadQueries() 42 | .build(); 43 | 44 | mLogDao = mDatabase.logDao(); 45 | } 46 | 47 | @After 48 | public void closeDb() { 49 | mDatabase.close(); 50 | } 51 | 52 | @Test 53 | public void insertSavesData() throws InterruptedException { 54 | LogEntity[] entities = createEntities(1); 55 | mLogDao.insert(entities[0]); 56 | 57 | List fetched = LiveDataTestUtil.getValue(mLogDao.findAll()); 58 | assertEquals(1, fetched.size()); 59 | } 60 | 61 | @Test 62 | public void deleteRemovesData() throws InterruptedException { 63 | LogEntity[] entities = createEntities(1); 64 | mLogDao.insert(entities[0]); 65 | 66 | List fetched = LiveDataTestUtil.getValue(mLogDao.findAll()); 67 | assertEquals(1, fetched.size()); 68 | 69 | mLogDao.delete(fetched.get(0)); 70 | 71 | assertEquals(0, LiveDataTestUtil.getValue(mLogDao.findAll()).size()); 72 | } 73 | 74 | @Test 75 | public void deleteAllClearsData() throws InterruptedException { 76 | LogEntity[] entities = createEntities(10); 77 | for (LogEntity entity : entities) { 78 | mLogDao.insert(entity); 79 | } 80 | 81 | assertEquals(10, LiveDataTestUtil.getValue(mLogDao.findAll()).size()); 82 | 83 | mLogDao.deleteAll(); 84 | 85 | assertEquals(0, LiveDataTestUtil.getValue(mLogDao.findAll()).size()); 86 | } 87 | 88 | @Test 89 | public void findAllRetrievesData() throws InterruptedException { 90 | LogEntity[] entities = createEntities(10); 91 | for (LogEntity entity : entities) { 92 | mLogDao.insert(entity); 93 | } 94 | 95 | List fetched = LiveDataTestUtil.getValue(mLogDao.findAll()); 96 | assertEquals(10, fetched.size()); 97 | 98 | // Find all should retrieve in the reverse order they are added, so the first fetched 99 | // is the last entity 100 | assertEquals(entities[entities.length - 1].getNumber(), fetched.get(0).getNumber()); 101 | assertEquals(entities[0].getNumber(), fetched.get(fetched.size() - 1).getNumber()); 102 | } 103 | 104 | private LogEntity[] createEntities(int count) { 105 | LinkedList entities = new LinkedList<>(); 106 | Random random = new Random(); 107 | 108 | for (int i = 0; i < count; i++) { 109 | int number = random.nextInt(8999999) + 1000000; 110 | entities.add(new LogEntity( 111 | random.nextInt(1) > 0 ? LogAction.BLOCKED : LogAction.ALLOWED, 112 | String.valueOf(number) 113 | )); 114 | } 115 | 116 | return entities.toArray(new LogEntity[0]); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/db/CallFilterDatabase.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.db; 2 | 3 | import android.content.Context; 4 | import android.os.Build; 5 | 6 | import androidx.annotation.NonNull; 7 | import androidx.room.Database; 8 | import androidx.room.Room; 9 | import androidx.room.RoomDatabase; 10 | import androidx.sqlite.db.SupportSQLiteDatabase; 11 | 12 | import com.novyr.callfilter.db.dao.LogDao; 13 | import com.novyr.callfilter.db.dao.RuleDao; 14 | import com.novyr.callfilter.db.entity.LogEntity; 15 | import com.novyr.callfilter.db.entity.RuleEntity; 16 | import com.novyr.callfilter.db.entity.enums.RuleAction; 17 | import com.novyr.callfilter.db.entity.enums.RuleType; 18 | 19 | import java.util.concurrent.ExecutorService; 20 | import java.util.concurrent.Executors; 21 | 22 | @Database(entities = {LogEntity.class, RuleEntity.class}, version = 2) 23 | public abstract class CallFilterDatabase extends RoomDatabase { 24 | private static volatile CallFilterDatabase INSTANCE; 25 | // TODO 4 is from the docs example but seems like a lot for our needs 26 | private static final int NUMBER_OF_THREADS = 4; 27 | static final ExecutorService databaseWriteExecutor = 28 | Executors.newFixedThreadPool(NUMBER_OF_THREADS); 29 | 30 | public static CallFilterDatabase getDatabase(final Context context) { 31 | if (INSTANCE == null) { 32 | synchronized (CallFilterDatabase.class) { 33 | if (INSTANCE == null) { 34 | INSTANCE = Room 35 | .databaseBuilder( 36 | context.getApplicationContext(), 37 | CallFilterDatabase.class, 38 | "call_filter" 39 | ) 40 | .addCallback(new RoomDatabase.Callback() { 41 | @Override 42 | public void onCreate(@NonNull SupportSQLiteDatabase db) { 43 | super.onCreate(db); 44 | 45 | createDefaultRules(); 46 | } 47 | }) 48 | .build(); 49 | } 50 | } 51 | } 52 | return INSTANCE; 53 | } 54 | 55 | public abstract LogDao logDao(); 56 | 57 | public abstract RuleDao ruleDao(); 58 | 59 | private static void createDefaultRules() { 60 | databaseWriteExecutor.execute(() -> { 61 | RuleDao dao = INSTANCE.ruleDao(); 62 | dao.deleteAll(); 63 | 64 | int order = 0; 65 | 66 | RuleEntity rule = new RuleEntity( 67 | RuleType.UNMATCHED, 68 | RuleAction.ALLOW, 69 | null, 70 | true, 71 | order 72 | ); 73 | dao.insert(rule); 74 | order += 2; 75 | 76 | rule = new RuleEntity( 77 | RuleType.UNRECOGNIZED, 78 | RuleAction.BLOCK, 79 | null, 80 | false, 81 | order 82 | ); 83 | dao.insert(rule); 84 | order += 2; 85 | 86 | rule = new RuleEntity( 87 | RuleType.PRIVATE, 88 | RuleAction.BLOCK, 89 | null, 90 | false, 91 | order 92 | ); 93 | dao.insert(rule); 94 | 95 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 96 | order += 2; 97 | 98 | rule = new RuleEntity( 99 | RuleType.VERIFICATION_FAILED, 100 | RuleAction.BLOCK, 101 | null, 102 | false, 103 | order 104 | ); 105 | dao.insert(rule); 106 | order += 2; 107 | 108 | rule = new RuleEntity( 109 | RuleType.VERIFICATION_PASSED, 110 | RuleAction.ALLOW, 111 | null, 112 | false, 113 | order 114 | ); 115 | dao.insert(rule); 116 | } 117 | }); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/rulelist/RuleViewHolder.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.rulelist; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.os.Handler; 5 | import android.os.Looper; 6 | import android.view.ContextMenu; 7 | import android.view.MotionEvent; 8 | import android.view.View; 9 | import android.widget.ImageView; 10 | import android.widget.TextView; 11 | 12 | import androidx.appcompat.widget.SwitchCompat; 13 | import androidx.recyclerview.widget.RecyclerView; 14 | 15 | import com.novyr.callfilter.R; 16 | import com.novyr.callfilter.db.entity.RuleEntity; 17 | 18 | public class RuleViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnCreateContextMenuListener { 19 | private final Handler mHandler = new Handler(Looper.getMainLooper()); 20 | 21 | private final RuleListActionHelper mRuleListActionHelper; 22 | 23 | private RuleEntity mCurrentRule; 24 | private final ImageView mDragHandle; 25 | private final TextView mTypeView; 26 | private final TextView mValueView; 27 | private final TextView mAllowView; 28 | private final TextView mBlockView; 29 | private final SwitchCompat mEnabledSwitch; 30 | 31 | @SuppressLint("ClickableViewAccessibility") 32 | RuleViewHolder( 33 | View itemView, 34 | RuleListActionHelper ruleListActionHelper, 35 | OnStartDragListener dragListener 36 | ) { 37 | super(itemView); 38 | 39 | itemView.setOnClickListener(this); 40 | itemView.setOnCreateContextMenuListener(this); 41 | 42 | mRuleListActionHelper = ruleListActionHelper; 43 | 44 | mDragHandle = itemView.findViewById(R.id.rule_drag_handle); 45 | mAllowView = itemView.findViewById(R.id.rule_action_allow); 46 | mBlockView = itemView.findViewById(R.id.rule_action_block); 47 | mTypeView = itemView.findViewById(R.id.rule_type); 48 | mValueView = itemView.findViewById(R.id.rule_value); 49 | mEnabledSwitch = itemView.findViewById(R.id.rule_enabled_switch); 50 | 51 | // Setup a listener so the switch updates the entity when toggled 52 | mEnabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { 53 | final RuleEntity rule = mCurrentRule; 54 | // We need to give the switch animation time to run or when we save the entity the 55 | // view will automatically refresh causing the animation to jump to the next state 56 | mHandler.postDelayed(() -> mRuleListActionHelper.enable(rule, isChecked), 250); 57 | }); 58 | 59 | mDragHandle.setOnTouchListener((v, event) -> { 60 | if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { 61 | dragListener.onStartDrag(this); 62 | return true; 63 | } 64 | 65 | return false; 66 | }); 67 | } 68 | 69 | public interface OnStartDragListener { 70 | void onStartDrag(RecyclerView.ViewHolder viewHolder); 71 | } 72 | 73 | public void setCurrentRule(RuleEntity currentRule) { 74 | mCurrentRule = currentRule; 75 | 76 | mTypeView.setText(currentRule.getType().getDisplayNameResource()); 77 | 78 | if (currentRule.getValue() != null) { 79 | mValueView.setText(currentRule.getValue()); 80 | } else { 81 | mValueView.setText(""); 82 | } 83 | 84 | if (!mRuleListActionHelper.canMove(currentRule)) { 85 | mDragHandle.setImageResource(R.drawable.ic_drag_indicator_disabled_18dp); 86 | } else { 87 | mDragHandle.setImageResource(R.drawable.ic_drag_indicator_18dp); 88 | } 89 | 90 | mEnabledSwitch.setChecked(currentRule.isEnabled()); 91 | 92 | switch (currentRule.getAction()) { 93 | case ALLOW: 94 | mAllowView.setVisibility(View.VISIBLE); 95 | mBlockView.setVisibility(View.GONE); 96 | break; 97 | case BLOCK: 98 | mAllowView.setVisibility(View.GONE); 99 | mBlockView.setVisibility(View.VISIBLE); 100 | break; 101 | } 102 | } 103 | 104 | @Override 105 | public void onClick(View view) { 106 | mEnabledSwitch.toggle(); 107 | } 108 | 109 | @Override 110 | public void onCreateContextMenu( 111 | ContextMenu contextMenu, 112 | View view, 113 | ContextMenu.ContextMenuInfo contextMenuInfo 114 | ) { 115 | mRuleListActionHelper.createContextMenu(contextMenu, mCurrentRule); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/CallReceiver.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import android.content.BroadcastReceiver; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.os.Build; 7 | import android.os.Bundle; 8 | import android.util.Log; 9 | 10 | import com.novyr.callfilter.db.entity.LogEntity; 11 | import com.novyr.callfilter.db.entity.enums.LogAction; 12 | import com.novyr.callfilter.telephony.HandlerFactory; 13 | import com.novyr.callfilter.telephony.HandlerInterface; 14 | 15 | import java.util.concurrent.Executor; 16 | import java.util.concurrent.Executors; 17 | 18 | public class CallReceiver extends BroadcastReceiver { 19 | private static final String TAG = CallReceiver.class.getSimpleName(); 20 | private final Executor executor = Executors.newSingleThreadExecutor(); 21 | 22 | @Override 23 | public void onReceive(Context context, Intent intent) { 24 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 25 | // If we are on Q+ we use the CallScreeningService API instead 26 | return; 27 | } 28 | 29 | String intentAction = intent.getAction(); 30 | if (intentAction == null || !intentAction.equals("android.intent.action.PHONE_STATE")) { 31 | return; 32 | } 33 | 34 | String state = intent.getStringExtra(android.telephony.TelephonyManager.EXTRA_STATE); 35 | if (state == null || !state.equals(android.telephony.TelephonyManager.EXTRA_STATE_RINGING)) { 36 | return; 37 | } 38 | 39 | if (BuildConfig.DEBUG) { 40 | Log.i(TAG, "Call received"); 41 | Bundle bundle = intent.getExtras(); 42 | if (bundle != null) { 43 | Log.d(TAG, " - Intent extras:"); 44 | for (String key : bundle.keySet()) { 45 | Object value = bundle.get(key); 46 | Log.d(TAG, String.format( 47 | " - %-16s %-16s (%s)", 48 | key, 49 | value != null ? value.toString() : "NULL", 50 | value != null ? value.getClass().getName() : "" 51 | )); 52 | } 53 | } 54 | } 55 | 56 | if (!shouldHandleCall(intent)) { 57 | if (BuildConfig.DEBUG) { 58 | Log.i(TAG, " - Skipping call"); 59 | } 60 | return; 61 | } 62 | 63 | if (BuildConfig.DEBUG) { 64 | Log.i(TAG, " - Handling call"); 65 | } 66 | 67 | // noinspection deprecation 68 | String number = intent.getStringExtra(android.telephony.TelephonyManager.EXTRA_INCOMING_NUMBER); 69 | 70 | executor.execute(() -> { 71 | HandlerInterface handler = HandlerFactory.create(context); 72 | RuleChecker checker = RuleCheckerFactory.create(context); 73 | 74 | LogAction action = LogAction.ALLOWED; 75 | if (!checker.allowCall(new CallDetails(number))) { 76 | if (handler.endCall()) { 77 | action = LogAction.BLOCKED; 78 | } else { 79 | action = LogAction.FAILED; 80 | } 81 | } 82 | 83 | CallFilterApplication application = (CallFilterApplication) context.getApplicationContext(); 84 | application.getLogRepository().insert(new LogEntity(action, number)); 85 | }); 86 | } 87 | 88 | private boolean shouldHandleCall(Intent intent) { 89 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 90 | // Since we request both READ_CALL_LOG and READ_PHONE_STATE permissions we will get called twice, one of 91 | // the calls missing the EXTRA_INCOMING_NUMBER data. 92 | // https://developer.android.com/reference/android/telephony/TelephonyManager#ACTION_PHONE_STATE_CHANGED 93 | // noinspection deprecation 94 | return intent.hasExtra(android.telephony.TelephonyManager.EXTRA_INCOMING_NUMBER); 95 | } 96 | 97 | // In Lollipop (API v21 and v22) we get called twice. The first seems to always have a subscription value of 1, 98 | // in the emulator at least. If we are on Kitkat or below we can continue without checking. 99 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { 100 | return true; 101 | } 102 | 103 | Bundle bundle = intent.getExtras(); 104 | Object value = bundle != null ? bundle.get("subscription") : null; 105 | if (value == null) { 106 | return true; 107 | } 108 | 109 | String expectedId = "1"; 110 | String id = value.toString(); 111 | 112 | return id == null || id.equals(expectedId); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /app/src/test/java/com/novyr/callfilter/RuleCheckerTest.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter; 2 | 3 | import com.novyr.callfilter.db.entity.RuleEntity; 4 | import com.novyr.callfilter.db.entity.enums.RuleAction; 5 | import com.novyr.callfilter.db.entity.enums.RuleType; 6 | import com.novyr.callfilter.rules.RuleHandlerManager; 7 | 8 | import org.junit.Test; 9 | 10 | import static org.junit.Assert.assertFalse; 11 | import static org.junit.Assert.assertTrue; 12 | import static org.mockito.Mockito.mock; 13 | import static org.mockito.Mockito.when; 14 | 15 | public class RuleCheckerTest { 16 | private final String RECOGNIZED_NUMBER = "8005551234"; 17 | private final String UNRECOGNIZED_NUMBER = "9005554321"; 18 | 19 | private ContactFinder createFinderMock() { 20 | ContactFinder finder = mock(ContactFinder.class); 21 | 22 | when(finder.findContactId(RECOGNIZED_NUMBER)).thenReturn("1"); 23 | when(finder.findContactId(UNRECOGNIZED_NUMBER)).thenReturn(null); 24 | 25 | return finder; 26 | } 27 | 28 | @Test 29 | public void checkNoEntity() { 30 | RuleChecker ruleChecker = new RuleChecker( 31 | new RuleHandlerManager(createFinderMock()), 32 | new RuleEntity[0] 33 | ); 34 | 35 | assertTrue(ruleChecker.allowCall(new CallDetails(null))); 36 | assertTrue(ruleChecker.allowCall(new CallDetails(RECOGNIZED_NUMBER))); 37 | assertTrue(ruleChecker.allowCall(new CallDetails(UNRECOGNIZED_NUMBER))); 38 | } 39 | 40 | @Test 41 | public void checkUnmatched() { 42 | RuleChecker ruleChecker = new RuleChecker( 43 | new RuleHandlerManager(createFinderMock()), 44 | new RuleEntity[]{ 45 | new RuleEntity(RuleType.UNMATCHED, RuleAction.ALLOW, null, true, 0) 46 | } 47 | ); 48 | 49 | assertTrue(ruleChecker.allowCall(new CallDetails(null))); 50 | assertTrue(ruleChecker.allowCall(new CallDetails(RECOGNIZED_NUMBER))); 51 | assertTrue(ruleChecker.allowCall(new CallDetails(UNRECOGNIZED_NUMBER))); 52 | 53 | ruleChecker = new RuleChecker( 54 | new RuleHandlerManager(createFinderMock()), 55 | new RuleEntity[]{ 56 | new RuleEntity(RuleType.UNMATCHED, RuleAction.BLOCK, null, true, 0) 57 | } 58 | ); 59 | 60 | assertFalse(ruleChecker.allowCall(new CallDetails(null))); 61 | assertFalse(ruleChecker.allowCall(new CallDetails(RECOGNIZED_NUMBER))); 62 | assertFalse(ruleChecker.allowCall(new CallDetails(UNRECOGNIZED_NUMBER))); 63 | } 64 | 65 | @Test 66 | public void checkOrder() { 67 | String code = RECOGNIZED_NUMBER.substring(0, 3); 68 | RuleChecker ruleChecker = new RuleChecker( 69 | new RuleHandlerManager(createFinderMock()), 70 | new RuleEntity[]{ 71 | new RuleEntity(RuleType.AREA_CODE, RuleAction.BLOCK, code, true, 2), 72 | new RuleEntity(RuleType.RECOGNIZED, RuleAction.ALLOW, null, true, 0), 73 | } 74 | ); 75 | 76 | assertFalse(ruleChecker.allowCall(new CallDetails(RECOGNIZED_NUMBER))); 77 | } 78 | 79 | @Test 80 | public void checkWhitelist() { 81 | RuleChecker ruleChecker = new RuleChecker( 82 | new RuleHandlerManager(createFinderMock()), 83 | new RuleEntity[]{ 84 | new RuleEntity(RuleType.RECOGNIZED, RuleAction.ALLOW, null, true, 2), 85 | new RuleEntity(RuleType.UNMATCHED, RuleAction.BLOCK, null, true, 0), 86 | } 87 | ); 88 | 89 | assertFalse(ruleChecker.allowCall(new CallDetails(null))); 90 | assertFalse(ruleChecker.allowCall(new CallDetails(UNRECOGNIZED_NUMBER))); 91 | assertTrue(ruleChecker.allowCall(new CallDetails(RECOGNIZED_NUMBER))); 92 | } 93 | 94 | @Test 95 | public void checkBlacklist() { 96 | RuleChecker ruleChecker = new RuleChecker( 97 | new RuleHandlerManager(createFinderMock()), 98 | new RuleEntity[]{ 99 | new RuleEntity(RuleType.PRIVATE, RuleAction.BLOCK, null, true, 4), 100 | new RuleEntity(RuleType.UNRECOGNIZED, RuleAction.BLOCK, null, true, 2), 101 | new RuleEntity(RuleType.UNMATCHED, RuleAction.ALLOW, null, true, 0), 102 | } 103 | ); 104 | 105 | assertFalse(ruleChecker.allowCall(new CallDetails(null))); 106 | assertFalse(ruleChecker.allowCall(new CallDetails(UNRECOGNIZED_NUMBER))); 107 | assertTrue(ruleChecker.allowCall(new CallDetails(RECOGNIZED_NUMBER))); 108 | } 109 | 110 | @Test 111 | public void checkIgnoreDisabled() { 112 | RuleChecker ruleChecker = new RuleChecker( 113 | new RuleHandlerManager(createFinderMock()), 114 | new RuleEntity[]{ 115 | new RuleEntity(RuleType.PRIVATE, RuleAction.BLOCK, null, false, 6), 116 | new RuleEntity(RuleType.UNRECOGNIZED, RuleAction.BLOCK, null, false, 4), 117 | new RuleEntity(RuleType.RECOGNIZED, RuleAction.BLOCK, null, false, 2), 118 | new RuleEntity(RuleType.UNMATCHED, RuleAction.ALLOW, null, true, 0), 119 | } 120 | ); 121 | 122 | assertTrue(ruleChecker.allowCall(new CallDetails(null))); 123 | assertTrue(ruleChecker.allowCall(new CallDetails(UNRECOGNIZED_NUMBER))); 124 | assertTrue(ruleChecker.allowCall(new CallDetails(RECOGNIZED_NUMBER))); 125 | } 126 | } -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/permissions/PermissionChecker.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.permissions; 2 | 3 | import android.app.Activity; 4 | import android.content.Intent; 5 | import android.os.Build; 6 | import android.util.Log; 7 | 8 | import androidx.annotation.NonNull; 9 | 10 | import com.novyr.callfilter.BuildConfig; 11 | import com.novyr.callfilter.R; 12 | import com.novyr.callfilter.permissions.checker.AndroidPermissionChecker; 13 | import com.novyr.callfilter.permissions.checker.CallScreeningRoleChecker; 14 | import com.novyr.callfilter.permissions.checker.CheckerInterface; 15 | import com.novyr.callfilter.permissions.checker.CheckerWithErrorsInterface; 16 | 17 | import java.util.LinkedList; 18 | import java.util.List; 19 | 20 | import static android.content.pm.PackageManager.PERMISSION_DENIED; 21 | 22 | public class PermissionChecker { 23 | private static final String TAG = PermissionChecker.class.getSimpleName(); 24 | 25 | public static final int PERMISSION_CHECKER_REQUEST = 250; 26 | private final LinkedList mCheckers; 27 | private final List mErrors; 28 | private final Activity mActivity; 29 | private final NotificationHandlerInterface mNotificationHandler; 30 | private int mIndex = 0; 31 | 32 | public PermissionChecker(Activity activity, NotificationHandlerInterface notificationHandler) { 33 | mActivity = activity; 34 | mNotificationHandler = notificationHandler; 35 | mCheckers = new LinkedList<>(); 36 | mErrors = new LinkedList<>(); 37 | 38 | mCheckers.add(new AndroidPermissionChecker()); 39 | 40 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 41 | mCheckers.add(new CallScreeningRoleChecker()); 42 | } 43 | } 44 | 45 | public void onStart() { 46 | mIndex = -1; 47 | mErrors.clear(); 48 | 49 | mNotificationHandler.setErrors(new LinkedList<>()); 50 | checkNext(); 51 | } 52 | 53 | private void onFinished() { 54 | mNotificationHandler.setErrors(mErrors); 55 | } 56 | 57 | public void onRequestPermissionsResult( 58 | int requestCode, 59 | @NonNull String[] permissions, 60 | @NonNull int[] grantResults 61 | ) { 62 | CheckerInterface checker = mCheckers.get(mIndex); 63 | if (requestCode != PERMISSION_CHECKER_REQUEST || checker.getClass() != AndroidPermissionChecker.class) { 64 | if (BuildConfig.DEBUG) { 65 | Log.w( 66 | TAG, 67 | String.format( 68 | "Unexpected onRequestPermissionsResult call, requestCode: %d, checker: %s", 69 | requestCode, 70 | checker.getClass().getSimpleName() 71 | ) 72 | ); 73 | } 74 | return; 75 | } 76 | 77 | if (!wereAllPermissionsGranted(grantResults)) { 78 | mErrors.add(mActivity.getString(R.string.permission_request_denied)); 79 | } 80 | 81 | checkNext(); 82 | } 83 | 84 | public void onActivityResult(int requestCode, int resultCode, Intent data) { 85 | CheckerInterface checker = mCheckers.get(mIndex); 86 | if (requestCode != PERMISSION_CHECKER_REQUEST || checker.getClass() != CallScreeningRoleChecker.class) { 87 | if (BuildConfig.DEBUG) { 88 | Log.w( 89 | TAG, 90 | String.format( 91 | "Unexpected onActivityResult call, requestCode: %d, checker: %s", 92 | requestCode, 93 | checker.getClass().getSimpleName() 94 | ) 95 | ); 96 | } 97 | return; 98 | } 99 | 100 | if (resultCode != android.app.Activity.RESULT_OK) { 101 | mErrors.add(mActivity.getString(R.string.permission_screening_denied)); 102 | } 103 | 104 | checkNext(); 105 | } 106 | 107 | private void checkNext() { 108 | int nextIndex = findNextChecker(mIndex); 109 | if (nextIndex == -1) { 110 | onFinished(); 111 | return; 112 | } 113 | 114 | mIndex = nextIndex; 115 | CheckerInterface checker = mCheckers.get(mIndex); 116 | 117 | if (checker.hasAccess(mActivity)) { 118 | checkNext(); 119 | return; 120 | } 121 | 122 | boolean handled = checker.requestAccess(mActivity, false); 123 | 124 | if (checker instanceof CheckerWithErrorsInterface) { 125 | List checkerErrors = ((CheckerWithErrorsInterface) checker).getErrors(); 126 | if (checkerErrors != null) { 127 | mErrors.addAll(checkerErrors); 128 | } 129 | } 130 | 131 | if (!handled) { 132 | checkNext(); 133 | } 134 | } 135 | 136 | private int findNextChecker(int currentIndex) { 137 | for (int i = currentIndex + 1; i < mCheckers.size(); i++) { 138 | CheckerInterface checker = mCheckers.get(i); 139 | if (!checker.hasAccess(mActivity)) { 140 | return i; 141 | } 142 | } 143 | 144 | return -1; 145 | } 146 | 147 | private boolean wereAllPermissionsGranted(@NonNull int[] grantResults) { 148 | for (int grantResult : grantResults) { 149 | if (grantResult == PERMISSION_DENIED) { 150 | return false; 151 | } 152 | } 153 | return true; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/ui/loglist/LogListActivity.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.ui.loglist; 2 | 3 | import android.app.AlertDialog; 4 | import android.content.Intent; 5 | import android.os.Build; 6 | import android.os.Bundle; 7 | import android.view.Menu; 8 | import android.view.MenuItem; 9 | import android.view.View; 10 | import android.widget.TextView; 11 | 12 | import androidx.annotation.NonNull; 13 | import androidx.annotation.RequiresApi; 14 | import androidx.appcompat.app.AppCompatActivity; 15 | import androidx.lifecycle.ViewModelProvider; 16 | import androidx.recyclerview.widget.LinearLayoutManager; 17 | import androidx.recyclerview.widget.RecyclerView; 18 | 19 | import com.google.android.material.snackbar.Snackbar; 20 | 21 | import com.novyr.callfilter.ContactFinder; 22 | import com.novyr.callfilter.R; 23 | import com.novyr.callfilter.formatter.LogDateFormatter; 24 | import com.novyr.callfilter.formatter.LogMessageFormatter; 25 | import com.novyr.callfilter.permissions.PermissionChecker; 26 | import com.novyr.callfilter.ui.rulelist.RuleListActivity; 27 | import com.novyr.callfilter.viewmodel.LogViewModel; 28 | 29 | public class LogListActivity extends AppCompatActivity { 30 | private RecyclerView mLogList; 31 | private LogViewModel mLogViewModel; 32 | private Snackbar mPermissionNotice; 33 | private PermissionChecker mPermissionChecker; 34 | 35 | @Override 36 | protected void onCreate(Bundle savedInstanceState) { 37 | super.onCreate(savedInstanceState); 38 | 39 | setContentView(R.layout.activity_log_list); 40 | 41 | mLogList = findViewById(R.id.log_list); 42 | 43 | mLogViewModel = new ViewModelProvider(this).get(LogViewModel.class); 44 | 45 | final ContactFinder contactFinder = new ContactFinder(this); 46 | final LogListMenuHandler menuHandler = new LogListMenuHandler( 47 | this, 48 | contactFinder, 49 | mLogViewModel 50 | ); 51 | 52 | final LogListAdapter adapter = new LogListAdapter( 53 | this, 54 | new LogMessageFormatter(getResources(), contactFinder), 55 | new LogDateFormatter(), 56 | menuHandler 57 | ); 58 | 59 | mLogList.setAdapter(adapter); 60 | mLogList.setLayoutManager(new LinearLayoutManager(this)); 61 | 62 | final TextView emptyView = findViewById(R.id.empty_view); 63 | 64 | mLogViewModel.findAll().observe(this, entities -> { 65 | adapter.setEntities(entities); 66 | 67 | if (adapter.getItemCount() > 0) { 68 | mLogList.setVisibility(View.VISIBLE); 69 | emptyView.setVisibility(View.GONE); 70 | } else { 71 | mLogList.setVisibility(View.GONE); 72 | emptyView.setVisibility(View.VISIBLE); 73 | } 74 | }); 75 | 76 | mPermissionChecker = new PermissionChecker(this, errors -> { 77 | if (mPermissionNotice != null) { 78 | mPermissionNotice.dismiss(); 79 | mPermissionNotice = null; 80 | } 81 | 82 | if (errors.size() < 1) { 83 | return; 84 | } 85 | 86 | StringBuilder errorMessage = new StringBuilder(); 87 | for (int i = 0; i < errors.size(); i++) { 88 | if (errorMessage.length() > 0) { 89 | errorMessage.append("\n"); 90 | } 91 | errorMessage.append(errors.get(i)); 92 | } 93 | 94 | mPermissionNotice = Snackbar.make(mLogList, errorMessage, Snackbar.LENGTH_INDEFINITE); 95 | mPermissionNotice 96 | .setAction( 97 | R.string.permission_notice_retry, 98 | view -> mPermissionChecker.onStart() 99 | ) 100 | .show(); 101 | }); 102 | } 103 | 104 | @Override 105 | protected void onStart() { 106 | super.onStart(); 107 | 108 | mPermissionChecker.onStart(); 109 | } 110 | 111 | @Override 112 | public boolean onCreateOptionsMenu(Menu menu) { 113 | getMenuInflater().inflate(R.menu.menu_log_viewer, menu); 114 | return true; 115 | } 116 | 117 | @Override 118 | public boolean onOptionsItemSelected(@NonNull MenuItem item) { 119 | int itemId = item.getItemId(); 120 | if (itemId == R.id.action_rules) { 121 | startActivity(new Intent(this, RuleListActivity.class)); 122 | return true; 123 | } else if (itemId == R.id.action_clear_log) { 124 | new AlertDialog.Builder(this) 125 | .setTitle(R.string.dialog_clear_logs_title) 126 | .setMessage(R.string.dialog_clear_logs_message) 127 | .setIconAttribute(android.R.attr.alertDialogIcon) 128 | .setPositiveButton( 129 | R.string.yes, 130 | (dialog, whichButton) -> mLogViewModel.deleteAll() 131 | ) 132 | .setNegativeButton(R.string.no, null) 133 | .show(); 134 | return true; 135 | } 136 | 137 | return super.onOptionsItemSelected(item); 138 | } 139 | 140 | @Override 141 | public void onRequestPermissionsResult( 142 | int requestCode, 143 | @NonNull String[] permissions, 144 | @NonNull int[] grantResults 145 | ) { 146 | mPermissionChecker.onRequestPermissionsResult(requestCode, permissions, grantResults); 147 | } 148 | 149 | @RequiresApi(api = Build.VERSION_CODES.Q) 150 | @Override 151 | public void onActivityResult(int requestCode, int resultCode, Intent data) { 152 | super.onActivityResult(requestCode, resultCode, data); 153 | 154 | mPermissionChecker.onActivityResult(requestCode, resultCode, data); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/java/com/novyr/callfilter/permissions/checker/AndroidPermissionChecker.java: -------------------------------------------------------------------------------- 1 | package com.novyr.callfilter.permissions.checker; 2 | 3 | import android.Manifest; 4 | import android.app.Activity; 5 | import android.content.Context; 6 | import android.content.SharedPreferences; 7 | import android.content.pm.PackageManager; 8 | import android.os.Build; 9 | import android.util.Log; 10 | 11 | import androidx.core.app.ActivityCompat; 12 | import androidx.core.content.ContextCompat; 13 | 14 | import com.novyr.callfilter.BuildConfig; 15 | import com.novyr.callfilter.R; 16 | 17 | import java.util.ArrayList; 18 | import java.util.Arrays; 19 | import java.util.Collections; 20 | import java.util.LinkedList; 21 | import java.util.List; 22 | 23 | import static com.novyr.callfilter.permissions.PermissionChecker.PERMISSION_CHECKER_REQUEST; 24 | 25 | public class AndroidPermissionChecker implements CheckerInterface, CheckerWithErrorsInterface { 26 | private static final String TAG = AndroidPermissionChecker.class.getSimpleName(); 27 | 28 | private final List mErrors; 29 | private final List mWantedPermissions; 30 | 31 | public AndroidPermissionChecker() { 32 | mErrors = new LinkedList<>(); 33 | 34 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 35 | mWantedPermissions = new LinkedList<>(Collections.singletonList(Manifest.permission.READ_CONTACTS)); 36 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 37 | mWantedPermissions = new LinkedList<>(Arrays.asList( 38 | Manifest.permission.ANSWER_PHONE_CALLS, 39 | Manifest.permission.CALL_PHONE, 40 | Manifest.permission.READ_CALL_LOG, 41 | Manifest.permission.READ_CONTACTS, 42 | Manifest.permission.READ_PHONE_STATE 43 | )); 44 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 45 | mWantedPermissions = new LinkedList<>(Arrays.asList( 46 | Manifest.permission.CALL_PHONE, 47 | Manifest.permission.READ_CONTACTS, 48 | Manifest.permission.READ_PHONE_STATE 49 | )); 50 | } else { 51 | mWantedPermissions = new LinkedList<>(Arrays.asList( 52 | Manifest.permission.CALL_PHONE, 53 | Manifest.permission.READ_CONTACTS, 54 | Manifest.permission.READ_PHONE_STATE, 55 | Manifest.permission.MODIFY_PHONE_STATE 56 | )); 57 | } 58 | 59 | if (BuildConfig.DEBUG) { 60 | Log.i(TAG, String.format("Permissions wanted: %d", mWantedPermissions.size())); 61 | for (int i = 0; i < mWantedPermissions.size(); i++) { 62 | Log.i(TAG, String.format(" - %s", mWantedPermissions.get(i))); 63 | } 64 | } 65 | } 66 | 67 | @Override 68 | public List getErrors() { 69 | return mErrors; 70 | } 71 | 72 | private SharedPreferences getSharedPreferences(Context context) { 73 | return context.getSharedPreferences( 74 | context.getString(R.string.permission_preferences_file), 75 | Context.MODE_PRIVATE 76 | ); 77 | } 78 | 79 | private boolean hasRequested(Context context, String permission) { 80 | return getSharedPreferences(context).getBoolean( 81 | String.format("requested-%s", permission), 82 | false 83 | ); 84 | } 85 | 86 | private void setRequested(Context context, String permission) { 87 | getSharedPreferences(context).edit() 88 | .putBoolean(String.format("requested-%s", permission), true) 89 | .apply(); 90 | } 91 | 92 | public boolean hasAccess(Activity activity) { 93 | for (String item : mWantedPermissions) { 94 | if (ContextCompat.checkSelfPermission( 95 | activity, 96 | item 97 | ) != PackageManager.PERMISSION_GRANTED) { 98 | return false; 99 | } 100 | } 101 | 102 | return true; 103 | } 104 | 105 | public boolean requestAccess(Activity activity, boolean forceAttempt) { 106 | mErrors.clear(); 107 | 108 | PermissionResults permissions = findNeededPermissions(activity, forceAttempt); 109 | 110 | if (permissions.mBlockedPermissions.size() > 0) { 111 | mErrors.add(activity.getString(R.string.permission_request_blocked)); 112 | } 113 | 114 | if (permissions.mNeededPermissions.size() > 0) { 115 | ActivityCompat.requestPermissions( 116 | activity, 117 | permissions.mNeededPermissions.toArray(new String[0]), 118 | PERMISSION_CHECKER_REQUEST 119 | ); 120 | 121 | for (String permission : permissions.mNeededPermissions) { 122 | setRequested(activity, permission); 123 | } 124 | 125 | return true; 126 | } 127 | 128 | 129 | return false; 130 | } 131 | 132 | private PermissionResults findNeededPermissions(Activity activity, boolean forceRequest) { 133 | List neededPermissions = new ArrayList<>(); 134 | List blockedPermissions = new ArrayList<>(); 135 | 136 | for (String permission : mWantedPermissions) { 137 | if (forceRequest) { 138 | neededPermissions.add(permission); 139 | continue; 140 | } 141 | 142 | if (ContextCompat.checkSelfPermission( 143 | activity, 144 | permission 145 | ) != PackageManager.PERMISSION_GRANTED) { 146 | if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) { 147 | neededPermissions.add(permission); 148 | } else { 149 | if (!hasRequested(activity, permission)) { 150 | neededPermissions.add(permission); 151 | } else { 152 | blockedPermissions.add(permission); 153 | } 154 | } 155 | } 156 | } 157 | 158 | if (BuildConfig.DEBUG) { 159 | Log.i(TAG, String.format("Permissions needed: %d", neededPermissions.size())); 160 | for (int i = 0; i < neededPermissions.size(); i++) { 161 | Log.i(TAG, String.format(" - %s", neededPermissions.get(i))); 162 | } 163 | 164 | Log.i(TAG, String.format("Permissions blocked: %d", blockedPermissions.size())); 165 | for (int i = 0; i < blockedPermissions.size(); i++) { 166 | Log.i(TAG, String.format(" - %s", blockedPermissions.get(i))); 167 | } 168 | } 169 | 170 | return new PermissionResults(neededPermissions, blockedPermissions); 171 | } 172 | 173 | private static class PermissionResults { 174 | private final List mNeededPermissions; 175 | private final List mBlockedPermissions; 176 | 177 | PermissionResults(List neededPermissions, List blockedPermissions) { 178 | mNeededPermissions = neededPermissions; 179 | mBlockedPermissions = blockedPermissions; 180 | } 181 | } 182 | } 183 | --------------------------------------------------------------------------------