diff --git a/.gitignore b/.gitignore index fed30fd..d5d5c08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ *.iml .gradle /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml +/.idea/ .DS_Store /build /captures .externalNativeBuild + +# Agent and Skills related +AGENTS.md +skills-lock.json +.agents/private/** diff --git a/.idea/caches/build_file_checksums.ser b/.idea/caches/build_file_checksums.ser deleted file mode 100644 index 8a039e9..0000000 Binary files a/.idea/caches/build_file_checksums.ser and /dev/null differ diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml deleted file mode 100644 index 3918e38..0000000 --- a/.idea/checkstyle-idea.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 681f41a..0000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
-
-
\ No newline at end of file diff --git a/.idea/dictionaries/Cerie.xml b/.idea/dictionaries/Cerie.xml deleted file mode 100644 index 560a4bc..0000000 --- a/.idea/dictionaries/Cerie.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index 97626ba..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 674414f..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index f04d60e..0000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/markdown-navigator.xml b/.idea/markdown-navigator.xml deleted file mode 100644 index 4e63b3e..0000000 --- a/.idea/markdown-navigator.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/markdown-navigator/profiles_settings.xml b/.idea/markdown-navigator/profiles_settings.xml deleted file mode 100644 index db06266..0000000 --- a/.idea/markdown-navigator/profiles_settings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 4896e0c..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31b0c63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 aJIEw + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index a39f5aa..86798a3 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,4 @@ This project shows you how to make your app as the default phone call app and ma ![listen_phone_call](media/listen_phone_call.gif) -原文链接:[安卓代替系统默认电话应用 (Android 6.0+) 与电话状态监听](https://ajiew.github.io/2018-08-11-android-replace-default-phone-app-and-listen-phone-calls/) +原文链接:[安卓代替系统默认电话应用 (Android 6.0+) 与电话状态监听](https://ajiew.github.io/android-phone-call-app-and-listen-phone-calls/) diff --git a/app/build.gradle b/app/build.gradle index 8198f6d..3973840 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,21 +1,27 @@ -apply plugin: 'com.android.application' +plugins { + id "com.android.application" +} android { - compileSdkVersion 28 + namespace 'com.ajiew.phonecallapp' + compileSdk 35 + defaultConfig { applicationId "com.ajiew.phonecallapp" - minSdkVersion 16 - targetSdkVersion 28 + minSdk 21 + targetSdk 35 versionCode 1 versionName "1.0" - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } + buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -24,10 +30,11 @@ android { dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'com.android.support:appcompat-v7:28.0.0' - implementation 'com.android.support.constraint:constraint-layout:1.1.2' + implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.activity:activity:1.10.1' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } diff --git a/app/src/androidTest/java/com/ajiew/phonecallapp/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/ajiew/phonecallapp/ExampleInstrumentedTest.java index 38e9f4f..ecf7a93 100644 --- a/app/src/androidTest/java/com/ajiew/phonecallapp/ExampleInstrumentedTest.java +++ b/app/src/androidTest/java/com/ajiew/phonecallapp/ExampleInstrumentedTest.java @@ -1,8 +1,8 @@ package com.ajiew.phonecallapp; import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f200eaf..ea7f20a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,21 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + - + @@ -24,7 +36,9 @@ + android:launchMode="singleInstance" + android:exported="true" + tools:targetApi="23"> @@ -47,7 +61,9 @@ + android:permission="android.permission.BIND_INCALL_SERVICE" + android:exported="false" + tools:targetApi="23"> @@ -66,4 +82,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/ajiew/phonecallapp/ActivityStack.java b/app/src/main/java/com/ajiew/phonecallapp/ActivityStack.java index 019acf0..00a822d 100644 --- a/app/src/main/java/com/ajiew/phonecallapp/ActivityStack.java +++ b/app/src/main/java/com/ajiew/phonecallapp/ActivityStack.java @@ -3,6 +3,7 @@ import android.app.Activity; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; @@ -10,14 +11,16 @@ public class ActivityStack { private static final ActivityStack INSTANCE = new ActivityStack(); - private List activities = new ArrayList<>(); + private final List activities = new ArrayList<>(); public static ActivityStack getInstance() { return INSTANCE; } public void addActivity(Activity activity) { - activities.add(activity); + if (activity != null) { + activities.add(activity); + } } public Activity getTopActivity() { @@ -41,18 +44,27 @@ public void finishActivity(Activity activity) { } public void finishActivity(Class activityClass) { - for (Activity activity : activities) { - if (activity.getClass().equals(activityClass)) { - finishActivity(activity); + if (activityClass == null) { + return; + } + + Iterator iterator = activities.iterator(); + while (iterator.hasNext()) { + Activity activity = iterator.next(); + if (activity != null && activity.getClass().equals(activityClass)) { + iterator.remove(); + activity.finish(); } } } public void finishAllActivity() { - if (!activities.isEmpty()) { - for (Activity activity : activities) { + Iterator iterator = activities.iterator(); + while (iterator.hasNext()) { + Activity activity = iterator.next(); + iterator.remove(); + if (activity != null) { activity.finish(); - activities.remove(activity); } } } diff --git a/app/src/main/java/com/ajiew/phonecallapp/MainActivity.java b/app/src/main/java/com/ajiew/phonecallapp/MainActivity.java index 066f71c..681c920 100644 --- a/app/src/main/java/com/ajiew/phonecallapp/MainActivity.java +++ b/app/src/main/java/com/ajiew/phonecallapp/MainActivity.java @@ -1,21 +1,31 @@ package com.ajiew.phonecallapp; +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; import android.app.ActivityManager; +import android.app.role.RoleManager; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.Settings; -import android.support.annotation.Nullable; -import android.support.v7.app.AlertDialog; -import android.support.v7.app.AppCompatActivity; import android.telecom.TelecomManager; import android.view.WindowManager; import android.widget.CompoundButton; import android.widget.Switch; import android.widget.Toast; +import androidx.activity.EdgeToEdge; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + import com.ajiew.phonecallapp.listenphonecall.CallListenerService; import java.lang.reflect.Field; @@ -23,14 +33,37 @@ public class MainActivity extends AppCompatActivity { + @SuppressLint("UseSwitchCompatOrMaterialCode") private Switch switchPhoneCall; + @SuppressLint("UseSwitchCompatOrMaterialCode") private Switch switchListenCall; private CompoundButton.OnCheckedChangeListener switchCallCheckChangeListener; + private final ActivityResultLauncher dialerRequestLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + Toast.makeText(MainActivity.this, getString(R.string.app_name) + " 已成为默认电话应用", + Toast.LENGTH_SHORT).show(); + } + }); + + private final ActivityResultLauncher phoneStatePermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), granted -> { + if (granted) { + if (switchListenCall != null && switchListenCall.isChecked()) { + startCallListenerService(); + } + } else { + Toast.makeText(this, "缺少获取电话状态权限", Toast.LENGTH_SHORT).show(); + setSwitchListenCallChecked(false); + } + }); + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { + EdgeToEdge.enable(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); @@ -45,16 +78,29 @@ private void initView() { // 发起将本应用设为默认电话应用的请求,仅支持 Android M 及以上 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (switchPhoneCall.isChecked()) { - Intent intent = new Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER); - intent.putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, - getPackageName()); - startActivity(intent); + // Android 10 之后需要通过 RoleManager 修改默认电话应用 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + RoleManager roleManager = (RoleManager) getSystemService(Context.ROLE_SERVICE); + if (roleManager == null + || !roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)) { + Toast.makeText(this, "当前设备不支持设置默认电话应用", Toast.LENGTH_SHORT).show(); + switchPhoneCall.setChecked(false); + return; + } + Intent intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER); + dialerRequestLauncher.launch(intent); + } else { + Intent intent = new Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER); + intent.putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, getPackageName()); + startActivity(intent); + } } else { // 取消时跳转到默认设置页面 startActivity(new Intent("android.settings.MANAGE_DEFAULT_APPS_SETTINGS")); } } else { - Toast.makeText(MainActivity.this, "Android 6.0 以上才支持修改默认电话应用!", Toast.LENGTH_LONG).show(); + Toast.makeText(MainActivity.this, "Android 6.0 以上才支持修改默认电话应用!", Toast.LENGTH_LONG) + .show(); switchPhoneCall.setChecked(false); } @@ -63,8 +109,7 @@ private void initView() { // 检查是否开启了权限 switchCallCheckChangeListener = (buttonView, isChecked) -> { if (isChecked && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - && !Settings.canDrawOverlays(MainActivity.this) - ) { + && !Settings.canDrawOverlays(MainActivity.this)) { // 请求 悬浮框 权限 askForDrawOverlay(); @@ -75,18 +120,43 @@ private void initView() { return; } - Intent callListener = new Intent(MainActivity.this, CallListenerService.class); + boolean granted = ContextCompat.checkSelfPermission(this, + Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED; + if (isChecked && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !granted) { + phoneStatePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE); + return; + } + if (isChecked) { - startService(callListener); - Toast.makeText(this, "电话监听服务已开启", Toast.LENGTH_SHORT).show(); + startCallListenerService(); } else { - stopService(callListener); - Toast.makeText(this, "电话监听服务已关闭", Toast.LENGTH_SHORT).show(); + stopCallListenerService(); } }; switchListenCall.setOnCheckedChangeListener(switchCallCheckChangeListener); } + private void startCallListenerService() { + Intent callListener = new Intent(MainActivity.this, CallListenerService.class); + startService(callListener); + Toast.makeText(this, "电话监听服务已开启", Toast.LENGTH_SHORT).show(); + } + + private void stopCallListenerService() { + Intent callListener = new Intent(MainActivity.this, CallListenerService.class); + stopService(callListener); + Toast.makeText(this, "电话监听服务已关闭", Toast.LENGTH_SHORT).show(); + } + + private void setSwitchListenCallChecked(boolean checked) { + if (switchListenCall == null) { + return; + } + switchListenCall.setOnCheckedChangeListener(null); + switchListenCall.setChecked(checked); + switchListenCall.setOnCheckedChangeListener(switchCallCheckChangeListener); + } + private void askForDrawOverlay() { AlertDialog alertDialog = new AlertDialog.Builder(this) .setTitle("允许显示悬浮框") @@ -98,11 +168,12 @@ private void askForDrawOverlay() { .setNegativeButton("稍后再说", (dialog, which) -> dialog.dismiss()) .create(); - //noinspection ConstantConditions - alertDialog.getWindow().setFlags( - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); alertDialog.show(); + if (alertDialog.getWindow() != null) { + alertDialog.getWindow().setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + } } /** @@ -151,8 +222,10 @@ public boolean isServiceRunning(Class serviceClass) { ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); if (manager == null) return false; - for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { - if (serviceClass.getName().equals(service.service.getClassName())) { + for (ActivityManager.RunningServiceInfo service : manager.getRunningServices( + Integer.MAX_VALUE)) { + if (service != null && service.service != null + && serviceClass.getName().equals(service.service.getClassName())) { return true; } } diff --git a/app/src/main/java/com/ajiew/phonecallapp/listenphonecall/CallListenerService.java b/app/src/main/java/com/ajiew/phonecallapp/listenphonecall/CallListenerService.java index 6e97ee8..8da5db6 100644 --- a/app/src/main/java/com/ajiew/phonecallapp/listenphonecall/CallListenerService.java +++ b/app/src/main/java/com/ajiew/phonecallapp/listenphonecall/CallListenerService.java @@ -7,10 +7,7 @@ import android.graphics.PixelFormat; import android.os.Build; import android.os.IBinder; -import android.support.annotation.Nullable; -import android.telephony.PhoneStateListener; import android.telephony.TelephonyManager; -import android.text.TextUtils; import android.view.Gravity; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -20,6 +17,9 @@ import android.widget.FrameLayout; import android.widget.TextView; +import androidx.core.content.ContextCompat; +import androidx.annotation.Nullable; + import com.ajiew.phonecallapp.MainActivity; import com.ajiew.phonecallapp.R; @@ -33,8 +33,7 @@ public class CallListenerService extends Service { private WindowManager windowManager; private WindowManager.LayoutParams params; - private PhoneStateListener phoneStateListener; - private TelephonyManager telephonyManager; + private PhoneStateListenerCompat phoneStateListenerCompat; private String callNumber; private boolean hasShown; @@ -44,9 +43,11 @@ public class CallListenerService extends Service { public void onCreate() { super.onCreate(); - initPhoneStateListener(); - - initPhoneCallView(); + if (initPhoneCallView()) { + initPhoneStateListener(); + } else { + stopSelf(); + } } @Nullable @@ -59,16 +60,17 @@ public IBinder onBind(Intent intent) { * 初始化来电状态监听器 */ private void initPhoneStateListener() { - phoneStateListener = new PhoneStateListener() { + phoneStateListenerCompat = new PhoneStateListenerCompat() { @Override - public void onCallStateChanged(int state, String incomingNumber) { - super.onCallStateChanged(state, incomingNumber); - - callNumber = incomingNumber; + public void onCallStateChanged(int state, String phoneNumber) { + if (phoneNumber != null && !phoneNumber.isEmpty()) { + callNumber = phoneNumber; + } switch (state) { case TelephonyManager.CALL_STATE_IDLE: // 待机,即无电话时,挂断时触发 dismiss(); + callNumber = null; break; case TelephonyManager.CALL_STATE_RINGING: // 响铃,来电时触发 @@ -88,24 +90,19 @@ public void onCallStateChanged(int state, String incomingNumber) { } } }; - - // 设置来电监听器 - telephonyManager = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); - if (telephonyManager != null) { - telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); - } - + phoneStateListenerCompat.startListening(this); } - private void initPhoneCallView() { + private boolean initPhoneCallView() { windowManager = (WindowManager) getApplicationContext() .getSystemService(Context.WINDOW_SERVICE); - int width = windowManager.getDefaultDisplay().getWidth(); - int height = windowManager.getDefaultDisplay().getHeight(); + if (windowManager == null) { + return false; + } params = new WindowManager.LayoutParams(); params.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP; - params.width = width; + params.width = WindowManager.LayoutParams.MATCH_PARENT; params.height = WindowManager.LayoutParams.WRAP_CONTENT; params.screenOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; @@ -145,22 +142,30 @@ public boolean dispatchKeyEvent(KeyEvent event) { } }; - phoneCallView = ((LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.view_phone_call, interceptorLayout); + LayoutInflater layoutInflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); + if (layoutInflater == null) { + return false; + } + + phoneCallView = layoutInflater.inflate(R.layout.view_phone_call, interceptorLayout); tvCallNumber = phoneCallView.findViewById(R.id.tv_call_number); btnOpenApp = phoneCallView.findViewById(R.id.btn_open_app); + if (tvCallNumber == null || btnOpenApp == null) { + return false; + } btnOpenApp.setOnClickListener(v -> { Intent intent = new Intent(getApplicationContext(), MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); CallListenerService.this.startActivity(intent); }); + return true; } /** * 显示顶级弹框展示通话信息 */ private void show() { - if (!hasShown) { + if (!hasShown && windowManager != null && phoneCallView != null && params != null) { windowManager.addView(phoneCallView, params); hasShown = true; } @@ -170,7 +175,7 @@ private void show() { * 取消显示 */ private void dismiss() { - if (hasShown) { + if (hasShown && windowManager != null && phoneCallView != null) { windowManager.removeView(phoneCallView); isCallingIn = false; hasShown = false; @@ -178,15 +183,20 @@ private void dismiss() { } private void updateUI() { - tvCallNumber.setText(formatPhoneNumber(callNumber)); + if (callNumber != null && !callNumber.isEmpty()) { + tvCallNumber.setText(formatPhoneNumber(callNumber)); + } int callTypeDrawable = isCallingIn ? R.drawable.ic_phone_call_in : R.drawable.ic_phone_call_out; tvCallNumber.setCompoundDrawablesWithIntrinsicBounds(null, null, - getResources().getDrawable(callTypeDrawable), null); + ContextCompat.getDrawable(this, callTypeDrawable), null); } public static String formatPhoneNumber(String phoneNum) { - if (!TextUtils.isEmpty(phoneNum) && phoneNum.length() == 11) { + if (phoneNum == null || phoneNum.isEmpty()) { + return "未知号码"; + } + if (phoneNum.length() == 11) { return phoneNum.substring(0, 3) + "-" + phoneNum.substring(3, 7) + "-" + phoneNum.substring(7); @@ -198,6 +208,8 @@ public static String formatPhoneNumber(String phoneNum) { public void onDestroy() { super.onDestroy(); - telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); + if (phoneStateListenerCompat != null) { + phoneStateListenerCompat.stopListening(this); + } } } diff --git a/app/src/main/java/com/ajiew/phonecallapp/listenphonecall/PhoneStateListenerCompat.java b/app/src/main/java/com/ajiew/phonecallapp/listenphonecall/PhoneStateListenerCompat.java new file mode 100644 index 0000000..e6c4fca --- /dev/null +++ b/app/src/main/java/com/ajiew/phonecallapp/listenphonecall/PhoneStateListenerCompat.java @@ -0,0 +1,83 @@ +package com.ajiew.phonecallapp.listenphonecall; + +import android.content.Context; +import android.os.Build; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; + +import androidx.annotation.RequiresApi; + +/** + * 电话状态监听器,API 31+ 使用 {@link android.telephony.TelephonyCallback.CallStateListener}。 + * + * @author aJIEw + * Created on: 2025/5/6 16:45 + */ +public abstract class PhoneStateListenerCompat { + private PhoneStateListener phoneStateListener; + private CallStateTelephonyCallback callStateListener; + + @SuppressWarnings("deprecation") + public void startListening(Context context) { + TelephonyManager tm = (TelephonyManager) context.getSystemService( + Context.TELEPHONY_SERVICE); + if (tm == null) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + callStateListener = new CallStateTelephonyCallback() { + @Override + public void onCallStateChanged(int state) { + PhoneStateListenerCompat.this.onCallStateChanged(state, null); + } + }; + tm.registerTelephonyCallback(context.getMainExecutor(), callStateListener); + } else { + phoneStateListener = new PhoneStateListener() { + @Override + public void onCallStateChanged(int state, String phoneNumber) { + PhoneStateListenerCompat.this.onCallStateChanged(state, phoneNumber); + } + }; + startLegacyListening(tm, phoneStateListener); + } + } + + public void stopListening(Context context) { + TelephonyManager tm = (TelephonyManager) context.getSystemService( + Context.TELEPHONY_SERVICE); + if (tm == null) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (callStateListener != null) { + tm.unregisterTelephonyCallback(callStateListener); + callStateListener = null; + } + } else { + if (phoneStateListener != null) { + stopLegacyListening(tm, phoneStateListener); + phoneStateListener = null; + } + } + } + + @SuppressWarnings("deprecation") + private void startLegacyListening(TelephonyManager tm, PhoneStateListener listener) { + tm.listen(listener, PhoneStateListener.LISTEN_CALL_STATE); + } + + @SuppressWarnings("deprecation") + private void stopLegacyListening(TelephonyManager tm, PhoneStateListener listener) { + tm.listen(listener, PhoneStateListener.LISTEN_NONE); + } + + public abstract void onCallStateChanged(int state, String phoneNumber); +} + +@RequiresApi(api = Build.VERSION_CODES.S) +abstract class CallStateTelephonyCallback extends android.telephony.TelephonyCallback + implements android.telephony.TelephonyCallback.CallStateListener { +} diff --git a/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallActivity.java b/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallActivity.java index 967a387..103b1ea 100644 --- a/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallActivity.java +++ b/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallActivity.java @@ -1,24 +1,26 @@ package com.ajiew.phonecallapp.phonecallui; +import static com.ajiew.phonecallapp.listenphonecall.CallListenerService.formatPhoneNumber; + import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; -import android.support.annotation.RequiresApi; -import android.support.v7.app.AppCompatActivity; import android.view.View; import android.view.WindowManager; import android.widget.TextView; +import androidx.activity.EdgeToEdge; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; + import com.ajiew.phonecallapp.ActivityStack; import com.ajiew.phonecallapp.R; import java.util.Timer; import java.util.TimerTask; -import static com.ajiew.phonecallapp.listenphonecall.CallListenerService.formatPhoneNumber; - /** * 提供接打电话的界面,仅支持 Android M (6.0, API 23) 及以上的系统 @@ -42,7 +44,7 @@ public class PhoneCallActivity extends AppCompatActivity implements View.OnClick private int callingTime; public static void actionStart(Context context, String phoneNumber, - PhoneCallService.CallType callType) { + PhoneCallService.CallType callType) { Intent intent = new Intent(context, PhoneCallActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.putExtra(Intent.EXTRA_MIME_TYPES, callType); @@ -52,6 +54,7 @@ public static void actionStart(Context context, String phoneNumber, @Override protected void onCreate(Bundle savedInstanceState) { + EdgeToEdge.enable(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_phone_call); diff --git a/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallManager.java b/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallManager.java index f551c00..f1a8df8 100644 --- a/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallManager.java +++ b/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallManager.java @@ -3,10 +3,11 @@ import android.content.Context; import android.media.AudioManager; import android.os.Build; -import android.support.annotation.RequiresApi; import android.telecom.Call; import android.telecom.VideoProfile; +import androidx.annotation.RequiresApi; + @RequiresApi(api = Build.VERSION_CODES.M) public class PhoneCallManager { @@ -53,7 +54,7 @@ public void openSpeaker() { /** * 销毁资源 - * */ + */ public void destroy() { call = null; context = null; diff --git a/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallService.java b/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallService.java index d057c7a..5dde231 100644 --- a/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallService.java +++ b/app/src/main/java/com/ajiew/phonecallapp/phonecallui/PhoneCallService.java @@ -1,12 +1,15 @@ package com.ajiew.phonecallapp.phonecallui; +import android.net.Uri; import android.os.Build; -import android.support.annotation.RequiresApi; import android.telecom.Call; import android.telecom.InCallService; +import androidx.annotation.RequiresApi; + import com.ajiew.phonecallapp.ActivityStack; + /** * 监听电话通信状态的服务,实现该类的同时必须提供电话管理的 UI * @@ -17,7 +20,7 @@ @RequiresApi(api = Build.VERSION_CODES.M) public class PhoneCallService extends InCallService { - private Call.Callback callback = new Call.Callback() { + private final Call.Callback callback = new Call.Callback() { @Override public void onStateChanged(Call call, int state) { super.onStateChanged(call, state); @@ -40,6 +43,9 @@ public void onStateChanged(Call call, int state) { @Override public void onCallAdded(Call call) { super.onCallAdded(call); + if (call == null) { + return; + } call.registerCallback(callback); PhoneCallManager.call = call; @@ -53,8 +59,7 @@ public void onCallAdded(Call call) { } if (callType != null) { - Call.Details details = call.getDetails(); - String phoneNumber = details.getHandle().getSchemeSpecificPart(); + String phoneNumber = getPhoneNumber(call); PhoneCallActivity.actionStart(this, phoneNumber, callType); } } @@ -62,11 +67,29 @@ public void onCallAdded(Call call) { @Override public void onCallRemoved(Call call) { super.onCallRemoved(call); + if (call == null) { + PhoneCallManager.call = null; + return; + } call.unregisterCallback(callback); PhoneCallManager.call = null; } + private String getPhoneNumber(Call call) { + Call.Details details = call.getDetails(); + if (details == null) { + return null; + } + + Uri handle = details.getHandle(); + if (handle == null) { + return null; + } + + return handle.getSchemeSpecificPart(); + } + public enum CallType { CALL_IN, CALL_OUT, diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 3eab884..52e24cb 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -26,7 +26,7 @@ android:layout_height="wrap_content" android:layout_marginLeft="16dp" android:layout_marginRight="16dp" - android:layout_marginTop="16dp" + android:layout_marginTop="48dp" android:text="设为默认电话应用" /> - @@ -48,6 +51,8 @@ android:layout_centerHorizontal="true" android:layout_marginTop="24dp" android:text="通话中:01:33" + android:textColor="@android:color/black" + android:textSize="18sp" android:visibility="gone" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/view_phone_call.xml b/app/src/main/res/layout/view_phone_call.xml index 81592a9..5036c76 100644 --- a/app/src/main/res/layout/view_phone_call.xml +++ b/app/src/main/res/layout/view_phone_call.xml @@ -14,7 +14,7 @@ android:layout_marginTop="30dp" android:gravity="center" android:textAlignment="center" - android:textColor="@color/colorAccent" + android:textColor="@android:color/white" android:textSize="18sp" tools:text="133-8892-9579" /> @@ -22,6 +22,7 @@ android:id="@+id/btn_open_app" style="@style/Base.Widget.AppCompat.Button.Borderless.Colored" android:layout_width="match_parent" + android:textColor="@android:color/white" android:layout_height="wrap_content" android:layout_below="@id/tv_call_number" android:gravity="center" diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4f2b693..0eb88fe 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -6,8 +6,6 @@ @color/colorPrimary @color/colorPrimaryDark @color/colorAccent - true - @null diff --git a/app/src/test/java/com/ajiew/phonecallapp/ActivityStackTest.java b/app/src/test/java/com/ajiew/phonecallapp/ActivityStackTest.java new file mode 100644 index 0000000..edd0e13 --- /dev/null +++ b/app/src/test/java/com/ajiew/phonecallapp/ActivityStackTest.java @@ -0,0 +1,41 @@ +package com.ajiew.phonecallapp; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; + +import org.junit.After; +import org.junit.Test; + +public class ActivityStackTest { + + @After + public void tearDown() { + ActivityStack.getInstance().finishAllActivity(); + } + + @Test + public void finishActivityByClass_removesMatchingActivitiesWithoutCrash() { + TestActivity firstActivity = new TestActivity(); + TestActivity secondActivity = new TestActivity(); + + ActivityStack.getInstance().addActivity(firstActivity); + ActivityStack.getInstance().addActivity(secondActivity); + + ActivityStack.getInstance().finishActivity(TestActivity.class); + + assertTrue(firstActivity.finished); + assertTrue(secondActivity.finished); + assertNull(ActivityStack.getInstance().getTopActivity()); + } + + public static class TestActivity extends Activity { + boolean finished; + + @Override + public void finish() { + finished = true; + } + } +} diff --git a/app/src/test/java/com/ajiew/phonecallapp/listenphonecall/CallListenerServiceTest.java b/app/src/test/java/com/ajiew/phonecallapp/listenphonecall/CallListenerServiceTest.java new file mode 100644 index 0000000..4331231 --- /dev/null +++ b/app/src/test/java/com/ajiew/phonecallapp/listenphonecall/CallListenerServiceTest.java @@ -0,0 +1,28 @@ +package com.ajiew.phonecallapp.listenphonecall; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class CallListenerServiceTest { + + @Test + public void formatPhoneNumber_returnsUnknownWhenNull() { + assertEquals("未知号码", CallListenerService.formatPhoneNumber(null)); + } + + @Test + public void formatPhoneNumber_returnsUnknownWhenEmpty() { + assertEquals("未知号码", CallListenerService.formatPhoneNumber("")); + } + + @Test + public void formatPhoneNumber_formatsElevenDigitNumber() { + assertEquals("133-8892-9579", CallListenerService.formatPhoneNumber("13388929579")); + } + + @Test + public void formatPhoneNumber_keepsNonElevenDigitNumber() { + assertEquals("10086", CallListenerService.formatPhoneNumber("10086")); + } +} diff --git a/build.gradle b/build.gradle index 077cb2f..c6aaff6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,27 +1,4 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. - -buildscript { - - repositories { - google() - jcenter() - } - dependencies { - classpath 'com.android.tools.build:gradle:3.1.4' - - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -task clean(type: Delete) { - delete rootProject.buildDir +plugins { + id "com.android.application" version '8.9.2' apply false } diff --git a/gradle.properties b/gradle.properties index 743d692..d54129d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,8 +6,13 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.daemon=true +org.gradle.configureondemand=true +org.gradle.parallel=true +org.gradle.caching=true + +android.enableJetifier=true +android.useAndroidX=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7a3265e..758de96 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5a7e93f..81aa1c0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Sat Aug 04 17:17:57 CST 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip diff --git a/settings.gradle b/settings.gradle index e7b4def..cc732ed 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "PhoneCallApp" include ':app'