Compare commits

..

19 commits

Author SHA1 Message Date
1dba99ece0 Update .forgejo/workflows/test.yaml 2025-07-15 09:35:00 +00:00
b3743c777e Updated build.gradle, so It works without signing key 2025-07-15 09:33:43 +00:00
f9fdccbbf7 Add .forgejo/workflows/test.yaml 2025-07-14 11:38:57 +00:00
60a4080c9e Add fastlane/metadata/android/en-US/changelogs/18.txt 2025-06-09 08:03:17 +00:00
1f829cfa82 Merge pull request '2.0.3' () from work into main
Reviewed-on: 
2025-06-09 08:02:42 +00:00
partisan
079bbb20f5 Store anonymized call numbers in shared preferences 2025-06-05 09:36:17 +02:00
partisan
fc3f6c58ce Anonymized phone number logging 2025-06-05 09:11:05 +02:00
partisan
09de3785b9 Pulse no longer shows popup if disallowed by system 2025-06-04 22:52:37 +02:00
7cc7d5e390 Merge pull request 'v2.0.2' () from work into main
Reviewed-on: 
2025-06-03 19:21:19 +00:00
partisan
660637626e Pulled from main 2025-06-03 21:17:54 +02:00
partisan
c8ede0d472 Fixed minor bug and added warning text when app does not have sufficient permissions 2025-06-03 21:13:38 +02:00
9750daaaa2 Update fastlane/metadata/android/en-US/full_description.txt 2025-06-01 08:57:23 +00:00
b981f41955 Update fastlane/metadata/android/en-US/full_description.txt 2025-05-31 20:01:50 +00:00
a95a548e1f Update fastlane/metadata/android/en-US/full_description.txt 2025-05-31 17:22:50 +00:00
cafa1dee65 Merge pull request 'Updated fastlane full desc' () from work into main
Reviewed-on: 
2025-05-31 17:21:55 +00:00
partisan
042b079723 Updated fastlane full desc 2025-05-31 19:14:58 +02:00
561ee93875 Merge pull request 'Fixed lock warning on NONE and RANDOM popup effects' () from work into main
Reviewed-on: 
2025-05-28 14:17:42 +00:00
partisan
4c9b0a1b22 Fixed lock warning on NONE and RANDOM popup effects 2025-05-28 16:14:42 +02:00
partisan
3fa2bc38ab Typos 2025-05-27 19:07:25 +02:00
17 changed files with 237 additions and 78 deletions

View file

@ -0,0 +1,41 @@
name: Android Test
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
test:
runs-on: debian
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Configure SDK path
run: echo "sdk.dir=/opt/android-sdk" > local.properties
- name: Check Java version
run: |
if ! command -v java >/dev/null; then
echo "❌ Java is not installed"
exit 1
fi
echo "✅ Java version:"
java -version
- name: Extract version from build.gradle
id: version
run: |
VERSION=$(sed -nE 's/^[[:space:]]*versionName[[:space:]]*=[[:space:]]*"([^"]+)"/\1/p' app/build.gradle)
VERSION="v${VERSION#v}" # normalize to vX.Y.Z
echo "$VERSION" > version.txt
echo "✅ Detected version: $VERSION"
- name: Set up gradle
run: chmod +x ./gradlew
- name: Run tests
run: ./gradlew testDebugUnitTest

View file

@ -73,7 +73,7 @@ Redirecting outgoing calls to E2EE apps.
In the app, search for "Pulse" and install it. In the app, search for "Pulse" and install it.
*Pulse uses the IzzyOnDroid repo. Some F-Droid clients, such as F-Droid itself, do not include it by default. Please add the IzzyOnDroid repo: https://apt.izzysoft.de/fdroid/repo* _Pulse uses the IzzyOnDroid repo. Some F-Droid clients, such as F-Droid itself, do not include it by default. Please add the IzzyOnDroid repo: https://apt.izzysoft.de/fdroid/repo_
## Using Obtainium ## Using Obtainium
@ -83,24 +83,24 @@ In the “Add App” screen:
1. Add the following URL: https://weforge.xyz/partisan/Pulse 1. Add the following URL: https://weforge.xyz/partisan/Pulse
2. In **Override Source**, select **Forgejo (Codeberg)** 2. In **Override Source**, select **Forgejo (Codeberg)**
3. Tap the “Add” button at the very top, and youre done! 3. Tap the “Add” button at the very top, and you're done!
## Install directly ## Install directly
Go to the [Releases page](https://weforge.xyz/partisan/Pulse/releases) and download the latest file with the following format: `app-release.apk`. Go to the [Releases page](https://weforge.xyz/partisan/Pulse/releases) and download the latest file with the following format: `app-release.apk`.
Install it, and youre done! Install it, and you're done!
*Please note that when installing directly, the app will not receive automatic updates.* _Please note that when installing directly, the app will not receive automatic updates._
# Permissions # Permissions
- `ACCESS_NETWORK_STATE` check connectivity - `ACCESS_NETWORK_STATE` - check connectivity
- `CALL_PHONE` make a call via messenger - `CALL_PHONE` - make a call via messenger
- `READ_CONTACTS` check if contact has a messenger - `READ_CONTACTS` - check if contact has a messenger
- `READ_PHONE_NUMBERS` detect outgoing call - `READ_PHONE_NUMBERS` - detect outgoing call
- `SYSTEM_ALERT_WINDOW` show redirecting popup and launch from background - `SYSTEM_ALERT_WINDOW` - show redirecting popup and launch from background
- `INTERNET` check connectivity and verify donates - `INTERNET` - check connectivity and verify donates
Currently all of the permissions are required. Currently all of the permissions are required.

View file

@ -11,25 +11,31 @@ android {
applicationId = "partisan.weforge.xyz.pulse" applicationId = "partisan.weforge.xyz.pulse"
minSdk = 29 minSdk = 29
targetSdk = 34 targetSdk = 34
versionCode = 14 versionCode = 17
versionName = "2.0.0" versionName = "2.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
release { release {
storeFile file("release-key.jks") def hasSigning = project.hasProperty("RELEASE_STORE_PASSWORD") && file("release-key.jks").exists()
storePassword RELEASE_STORE_PASSWORD if (hasSigning) {
keyAlias "release-key" storeFile file("release-key.jks")
keyPassword RELEASE_STORE_PASSWORD storePassword RELEASE_STORE_PASSWORD
keyAlias "release-key"
keyPassword RELEASE_STORE_PASSWORD
} else {
println "⚠️ No release signing config present, skipping signing setup"
}
} }
} }
buildTypes { buildTypes {
release { release {
minifyEnabled = false minifyEnabled = false
signingConfig = signingConfigs.release // only apply signingConfig if it was actually initialized
signingConfig signingConfigs.release?.storeFile != null ? signingConfigs.release : null
proguardFiles(getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro') proguardFiles(getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro')
} }
} }

View file

@ -61,7 +61,9 @@ class CallRedirectionService : CallRedirectionService() {
initialPhoneAccount: PhoneAccountHandle, initialPhoneAccount: PhoneAccountHandle,
allowInteractiveResponse: Boolean, allowInteractiveResponse: Boolean,
) { ) {
Log.d("Redirection", "onPlaceCall triggered: uri=$handle, interactive=$allowInteractiveResponse") val phoneNumber = handle.schemeSpecificPart
val numberAlias = getAnonymizedAlias(phoneNumber)
Log.d("Redirection", "onPlaceCall triggered: alias=$numberAlias, interactive=$allowInteractiveResponse")
val capabilities = connectivityManager val capabilities = connectivityManager
?.getNetworkCapabilities(connectivityManager?.activeNetwork) ?.getNetworkCapabilities(connectivityManager?.activeNetwork)
@ -94,37 +96,30 @@ class CallRedirectionService : CallRedirectionService() {
return return
} }
if (!allowInteractiveResponse) {
Log.d("Redirection", "Aborting: interactive response not allowed by system")
placeCallUnmodified()
return
}
if (prefs.redirectIfRoaming && !isOutsideHomeCountry()) { if (prefs.redirectIfRoaming && !isOutsideHomeCountry()) {
Log.d("Redirection", "Aborting: redirect only while roaming, but we're inside home country") Log.d("Redirection", "Aborting: redirect only while roaming, but we're inside home country")
placeCallUnmodified() placeCallUnmodified()
return return
} }
val phoneNumber = handle.schemeSpecificPart
Log.d("Redirection", "Resolved phone number: $phoneNumber")
if (prefs.redirectInternationalOnly && !isInternationalNumber(phoneNumber)) { if (prefs.redirectInternationalOnly && !isInternationalNumber(phoneNumber)) {
Log.d("Redirection", "Aborting: number is not international and pref requires it") Log.d("Redirection", "Aborting: number $numberAlias is not international and pref requires it")
placeCallUnmodified() placeCallUnmodified()
return return
} }
if (prefs.isBlacklistEnabled && !prefs.isContactWhitelisted(phoneNumber)) { if (prefs.isBlacklistEnabled && !prefs.isContactWhitelisted(phoneNumber)) {
Log.d("Redirection", "Aborting: number is not in whitelist while blacklist is enabled") Log.d("Redirection", "Aborting: number $numberAlias is not in whitelist while blacklist is enabled")
placeCallUnmodified() placeCallUnmodified()
return return
} }
Log.d("Redirection", "Number $numberAlias is not in filters, processing redirection...")
val records: Array<Record> val records: Array<Record>
try { try {
records = getRecordsFromPhoneNumber(phoneNumber) records = getRecordsFromPhoneNumber(phoneNumber)
Log.d("Redirection", "Found ${records.size} raw records for contact") Log.d("Redirection", "Found ${records.size} raw redirect apps for number $numberAlias")
} catch (exc: SecurityException) { } catch (exc: SecurityException) {
Log.w("Redirection", "SecurityException during record fetch", exc) Log.w("Redirection", "SecurityException during record fetch", exc)
placeCallUnmodified() placeCallUnmodified()
@ -135,18 +130,20 @@ class CallRedirectionService : CallRedirectionService() {
.filter { prefs.isServiceEnabled(it.mimetype) } .filter { prefs.isServiceEnabled(it.mimetype) }
.sortedBy { prefs.getServicePriority(it.mimetype) } .sortedBy { prefs.getServicePriority(it.mimetype) }
Log.d("Redirection", "Filtered to ${enabledRecords.size} enabled records") Log.d("Redirection", "Filtered to ${enabledRecords.size} enabled redirect apps")
val record = enabledRecords.firstOrNull() val record = enabledRecords.firstOrNull()
if (record == null) { if (record == null) {
Log.d("Redirection", "Aborting: no suitable record found for redirection") Log.d("Redirection", "Aborting: no suitable redirect apps found for number $numberAlias")
placeCallUnmodified() placeCallUnmodified()
return return
} }
Log.d("Redirection", "Redirecting call to: ${record.mimetype}${record.uri}") Log.d("Redirection", "Redirecting call to: ${record.mimetype}${record.uri}")
if (prefs.popupEnabled) { Log.d("Redirection", "Popup ${if (allowInteractiveResponse) "allowed" else "not allowed"} by system; ${if (prefs.popupEnabled) "enabled" else "disabled"} in prefs")
if (allowInteractiveResponse && prefs.popupEnabled) {
window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return) window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return)
} else { } else {
window.call(record.uri) window.call(record.uri)
@ -203,6 +200,33 @@ class CallRedirectionService : CallRedirectionService() {
return results.toTypedArray() return results.toTypedArray()
} }
private fun getAnonymizedAlias(number: String): String {
val prefs = getSharedPreferences("anonymized_numbers", MODE_PRIVATE)
// Return existing alias if already mapped
val existing = prefs.getString(number, null)
if (existing != null) return existing
// Start from current counter
var counter = prefs.getInt("counter", 1)
var alias: String
// Find the first unused alias (safety check)
while (true) {
alias = "#$counter"
if (!prefs.all.containsValue(alias)) break
counter++
}
// Store new alias and increment counter
prefs.edit()
.putString(number, alias)
.putInt("counter", counter + 1)
.apply()
return alias
}
private fun isInternationalNumber(phoneNumber: String): Boolean { private fun isInternationalNumber(phoneNumber: String): Boolean {
val telephony = getSystemService(TelephonyManager::class.java) ?: return true val telephony = getSystemService(TelephonyManager::class.java) ?: return true
val simCountryIso = telephony.simCountryIso?.lowercase() ?: return true val simCountryIso = telephony.simCountryIso?.lowercase() ?: return true

View file

@ -89,7 +89,9 @@ class ContactsFragment : Fragment() {
} }
private fun getContacts(): List<ContactEntry> { private fun getContacts(): List<ContactEntry> {
val results = mutableListOf<ContactEntry>()
val resolver: ContentResolver = requireContext().contentResolver val resolver: ContentResolver = requireContext().contentResolver
val projection = arrayOf( val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER ContactsContract.CommonDataKinds.Phone.NUMBER
@ -103,16 +105,16 @@ class ContactsFragment : Fragment() {
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC" "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC"
) )
val results = mutableListOf<ContactEntry>()
cursor?.use { cursor?.use {
val nameIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) val nameIndex = it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) val numberIndex = it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) { while (it.moveToNext()) {
val name = it.getString(nameIndex) ?: continue val name = it.getString(nameIndex)
val number = it.getString(numberIndex) ?: continue val number = it.getString(numberIndex)
results.add(ContactEntry(name, number)) if (!name.isNullOrBlank() && !number.isNullOrBlank()) {
results.add(ContactEntry(name, number))
}
} }
} }

View file

@ -1,17 +1,20 @@
package partisan.weforge.xyz.pulse package partisan.weforge.xyz.pulse
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock import android.os.SystemClock
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import java.util.concurrent.TimeUnit
import nl.dionsegijn.konfetti.core.Party import nl.dionsegijn.konfetti.core.Party
import nl.dionsegijn.konfetti.core.Position import nl.dionsegijn.konfetti.core.Position
import nl.dionsegijn.konfetti.core.emitter.Emitter import nl.dionsegijn.konfetti.core.emitter.Emitter
import nl.dionsegijn.konfetti.xml.KonfettiView import nl.dionsegijn.konfetti.xml.KonfettiView
import java.util.concurrent.TimeUnit
class MainFragment : Fragment() { class MainFragment : Fragment() {
@ -24,9 +27,9 @@ class MainFragment : Fragment() {
} }
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val view = inflater.inflate(R.layout.fragment_main, container, false) val view = inflater.inflate(R.layout.fragment_main, container, false)
prefs = Preferences(requireContext()) prefs = Preferences(requireContext())
@ -44,14 +47,16 @@ class MainFragment : Fragment() {
if (isNowChecked && SystemClock.elapsedRealtime() - lastConfettiTime > 500) { if (isNowChecked && SystemClock.elapsedRealtime() - lastConfettiTime > 500) {
konfetti.start( konfetti.start(
Party( Party(
emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).perSecond(100), emitter =
speed = 30f, Emitter(duration = 100, TimeUnit.MILLISECONDS)
maxSpeed = 40f, .perSecond(100),
damping = 0.85f, speed = 25f,
spread = 360, maxSpeed = 30f,
position = Position.Relative(0.5, 0.5) damping = 0.85f,
) spread = 360,
position = Position.Relative(0.5, 0.5)
)
) )
lastConfettiTime = SystemClock.elapsedRealtime() lastConfettiTime = SystemClock.elapsedRealtime()
} }
@ -62,6 +67,49 @@ class MainFragment : Fragment() {
} }
} }
val warningText = view.findViewById<TextView>(R.id.warningText)
val warnings = mutableListOf<String>()
// 1. Check if contacts are available
val contactCursor =
requireContext()
.contentResolver
.query(
android.provider.ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
null,
null,
null,
null
)
if (contactCursor != null) {
contactCursor.use {
if (!it.moveToFirst()) {
warnings.add(getString(R.string.warn_no_contacts))
}
}
} else {
warnings.add(getString(R.string.warn_no_contacts))
}
// 2. Check internet connectivity
if (!hasInternet()) {
warnings.add(getString(R.string.warn_no_internet))
}
// Show warning if needed
if (warnings.isNotEmpty()) {
warningText.text = warnings.joinToString("\n")
warningText.visibility = View.VISIBLE
}
return view return view
} }
private fun hasInternet(): Boolean {
val cm = requireContext().getSystemService(ConnectivityManager::class.java)
val capabilities = cm?.getNetworkCapabilities(cm.activeNetwork) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
} }

View file

@ -133,7 +133,11 @@ class PopupSettingsFragment : Fragment() {
binding.popupEffectSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { binding.popupEffectSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val selectedEffect = allEffects[position] val selectedEffect = allEffects[position]
if (!prefs.isDonationActivated && selectedEffect !in prefs.getAvailablePopupEffects()) { if (!prefs.isDonationActivated &&
selectedEffect !in prefs.getAvailablePopupEffects() &&
selectedEffect != Preferences.PopupEffect.NONE &&
selectedEffect != Preferences.PopupEffect.RANDOM
) {
Toast.makeText(requireContext(), getString(R.string.donate_lock), Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), getString(R.string.donate_lock), Toast.LENGTH_SHORT).show()
binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal) binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal)
} else { } else {

View file

@ -38,8 +38,8 @@ class Preferences(private val context: Context) {
val isEnabled: Boolean val isEnabled: Boolean
get() = isServiceEnabledByUser && get() = isServiceEnabledByUser &&
hasGeneralPermissions(context) && hasGeneralPermissions(context) &&
hasDrawOverlays(context) && hasCallRedirectionRole(context) &&
hasCallRedirectionRole(context) (popupEnabled.not() || hasDrawOverlays(context))
enum class PopupEffect { enum class PopupEffect {
NONE, FADE, SCALE, BOUNCE, FLOP, MATRIX, SLIDE_SNAP, GAMER_MODE, RANDOM NONE, FADE, SCALE, BOUNCE, FLOP, MATRIX, SLIDE_SNAP, GAMER_MODE, RANDOM

View file

@ -127,10 +127,13 @@ class SecretView @JvmOverloads constructor(
com.google.android.material.R.attr.colorPrimaryVariant, com.google.android.material.R.attr.colorPrimaryVariant,
com.google.android.material.R.attr.colorSecondary com.google.android.material.R.attr.colorSecondary
) )
context.obtainStyledAttributes(colorAttrs).use { val ta = context.obtainStyledAttributes(colorAttrs)
playerPaint.color = it.getColor(0, Color.CYAN) try {
enemyPaint.color = it.getColor(0, Color.CYAN) playerPaint.color = ta.getColor(0, Color.CYAN)
colorSecondary = it.getColor(1, Color.GREEN) enemyPaint.color = ta.getColor(0, Color.CYAN)
colorSecondary = ta.getColor(1, Color.GREEN)
} finally {
ta.recycle()
} }
Choreographer.getInstance().postFrameCallback(this) Choreographer.getInstance().postFrameCallback(this)

View file

@ -34,4 +34,17 @@
app:iconGravity="textTop" app:iconGravity="textTop"
app:iconPadding="0dp" app:iconPadding="0dp"
app:cornerRadius="48dp" /> app:cornerRadius="48dp" />
<TextView
android:id="@+id/warningText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:padding="16dp"
android:textColor="@android:color/white"
android:background="#AAFF5252"
android:textSize="14sp"
android:text=""
android:visibility="gone" />
</FrameLayout> </FrameLayout>

View file

@ -47,6 +47,10 @@
<item>Random</item> <item>Random</item>
</string-array> </string-array>
<!-- Missing perms -->
<string name="warn_no_contacts">Unable to access contacts.</string>
<string name="warn_no_internet">No internet connection.</string>
<!-- Donate screen --> <!-- Donate screen -->
<string name="donate_title">Support Pulse Development 💖</string> <string name="donate_title">Support Pulse Development 💖</string>
<string name="donate_description">Pulse is free and open-source. You can support future development through Ko-fi. As a thank-you, donors get special popup animation effects!</string> <string name="donate_description">Pulse is free and open-source. You can support future development through Ko-fi. As a thank-you, donors get special popup animation effects!</string>

View file

@ -17,6 +17,6 @@ Fixes:
Misc: Misc:
- Updated Gradle to v8.14.1 - Updated Gradle to v8.14.1
- Updated screenshots to include new looks of the app. - Updated screenshots to reflect the new look of the app
- Updated description. - Updated description
- Added temporary "v2.0" to store icon to indicate new version. - Temporarily added "v2.0" to the store icon to indicate the new version

View file

@ -0,0 +1,2 @@
v2.0.1
- Fixed lock warning on NONE and RANDOM popup effects

View file

@ -0,0 +1,3 @@
v2.0.2
- Added warning text in case the app does not have sufficient permissions
- Fixed a bug related to tapping the Pulse logo in the About section, specific to MIUI

View file

@ -0,0 +1,3 @@
v2.0.3
- Anonymized phone numbers in logs
- Pulse no longer shows popup if disallowed by system

View file

@ -3,6 +3,7 @@ Redirect calls to Signal, Telegram, Threema, or WhatsApp.
--- ---
**Features:** **Features:**
- Material You design - Material You design
- Popup with cancel option - Popup with cancel option
- Extensive settings panel: - Extensive settings panel:
@ -11,15 +12,17 @@ Redirect calls to Signal, Telegram, Threema, or WhatsApp.
- Allowlist specific contacts - Allowlist specific contacts
- Change per-service priority - Change per-service priority
- Customize popup position, animation, and duration - Customize popup position, animation, and duration
... - etc
**Supports:** **Supports:**
- Signal - Signal
- Telegram - Telegram
- Threema - Threema
- WhatsApp - WhatsApp
**Permissions required:** **Permissions required:**
- `CALL_PHONE` - initiate calls via messenger - `CALL_PHONE` - initiate calls via messenger
- `READ_CONTACTS` - check contact compatibility - `READ_CONTACTS` - check contact compatibility
- `READ_PHONE_NUMBERS` - detect outgoing call - `READ_PHONE_NUMBERS` - detect outgoing call
@ -29,5 +32,8 @@ Redirect calls to Signal, Telegram, Threema, or WhatsApp.
Currently all of the permissions are required. Currently all of the permissions are required.
---
**License:** GPL-3.0 **License:** GPL-3.0
Free and open source Free and open source

View file

@ -1 +1 @@
Redirecting outgoing calls to E2EE apps. Redirecting outgoing calls to E2EE apps