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.
*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
@ -83,24 +83,24 @@ In the “Add App” screen:
1. Add the following URL: https://weforge.xyz/partisan/Pulse
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
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
- `ACCESS_NETWORK_STATE` check connectivity
- `CALL_PHONE` make a call via messenger
- `READ_CONTACTS` check if contact has a messenger
- `READ_PHONE_NUMBERS` detect outgoing call
- `SYSTEM_ALERT_WINDOW` show redirecting popup and launch from background
- `INTERNET` check connectivity and verify donates
- `ACCESS_NETWORK_STATE` - check connectivity
- `CALL_PHONE` - make a call via messenger
- `READ_CONTACTS` - check if contact has a messenger
- `READ_PHONE_NUMBERS` - detect outgoing call
- `SYSTEM_ALERT_WINDOW` - show redirecting popup and launch from background
- `INTERNET` - check connectivity and verify donates
Currently all of the permissions are required.

View file

@ -11,25 +11,31 @@ android {
applicationId = "partisan.weforge.xyz.pulse"
minSdk = 29
targetSdk = 34
versionCode = 14
versionName = "2.0.0"
versionCode = 17
versionName = "2.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release {
def hasSigning = project.hasProperty("RELEASE_STORE_PASSWORD") && file("release-key.jks").exists()
if (hasSigning) {
storeFile file("release-key.jks")
storePassword RELEASE_STORE_PASSWORD
keyAlias "release-key"
keyPassword RELEASE_STORE_PASSWORD
} else {
println "⚠️ No release signing config present, skipping signing setup"
}
}
}
buildTypes {
release {
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')
}
}

View file

@ -61,7 +61,9 @@ class CallRedirectionService : CallRedirectionService() {
initialPhoneAccount: PhoneAccountHandle,
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
?.getNetworkCapabilities(connectivityManager?.activeNetwork)
@ -94,37 +96,30 @@ class CallRedirectionService : CallRedirectionService() {
return
}
if (!allowInteractiveResponse) {
Log.d("Redirection", "Aborting: interactive response not allowed by system")
placeCallUnmodified()
return
}
if (prefs.redirectIfRoaming && !isOutsideHomeCountry()) {
Log.d("Redirection", "Aborting: redirect only while roaming, but we're inside home country")
placeCallUnmodified()
return
}
val phoneNumber = handle.schemeSpecificPart
Log.d("Redirection", "Resolved phone number: $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()
return
}
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()
return
}
Log.d("Redirection", "Number $numberAlias is not in filters, processing redirection...")
val records: Array<Record>
try {
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) {
Log.w("Redirection", "SecurityException during record fetch", exc)
placeCallUnmodified()
@ -135,18 +130,20 @@ class CallRedirectionService : CallRedirectionService() {
.filter { prefs.isServiceEnabled(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()
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()
return
}
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)
} else {
window.call(record.uri)
@ -203,6 +200,33 @@ class CallRedirectionService : CallRedirectionService() {
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 {
val telephony = getSystemService(TelephonyManager::class.java) ?: return true
val simCountryIso = telephony.simCountryIso?.lowercase() ?: return true

View file

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

View file

@ -1,17 +1,20 @@
package partisan.weforge.xyz.pulse
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Bundle
import android.os.SystemClock
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
import java.util.concurrent.TimeUnit
import nl.dionsegijn.konfetti.core.Party
import nl.dionsegijn.konfetti.core.Position
import nl.dionsegijn.konfetti.core.emitter.Emitter
import nl.dionsegijn.konfetti.xml.KonfettiView
import java.util.concurrent.TimeUnit
class MainFragment : Fragment() {
@ -45,9 +48,11 @@ class MainFragment : Fragment() {
if (isNowChecked && SystemClock.elapsedRealtime() - lastConfettiTime > 500) {
konfetti.start(
Party(
emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).perSecond(100),
speed = 30f,
maxSpeed = 40f,
emitter =
Emitter(duration = 100, TimeUnit.MILLISECONDS)
.perSecond(100),
speed = 25f,
maxSpeed = 30f,
damping = 0.85f,
spread = 360,
position = Position.Relative(0.5, 0.5)
@ -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
}
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 {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
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()
binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal)
} else {

View file

@ -38,8 +38,8 @@ class Preferences(private val context: Context) {
val isEnabled: Boolean
get() = isServiceEnabledByUser &&
hasGeneralPermissions(context) &&
hasDrawOverlays(context) &&
hasCallRedirectionRole(context)
hasCallRedirectionRole(context) &&
(popupEnabled.not() || hasDrawOverlays(context))
enum class PopupEffect {
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.colorSecondary
)
context.obtainStyledAttributes(colorAttrs).use {
playerPaint.color = it.getColor(0, Color.CYAN)
enemyPaint.color = it.getColor(0, Color.CYAN)
colorSecondary = it.getColor(1, Color.GREEN)
val ta = context.obtainStyledAttributes(colorAttrs)
try {
playerPaint.color = ta.getColor(0, Color.CYAN)
enemyPaint.color = ta.getColor(0, Color.CYAN)
colorSecondary = ta.getColor(1, Color.GREEN)
} finally {
ta.recycle()
}
Choreographer.getInstance().postFrameCallback(this)

View file

@ -34,4 +34,17 @@
app:iconGravity="textTop"
app:iconPadding="0dp"
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>

View file

@ -47,6 +47,10 @@
<item>Random</item>
</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 -->
<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>

View file

@ -17,6 +17,6 @@ Fixes:
Misc:
- Updated Gradle to v8.14.1
- Updated screenshots to include new looks of the app.
- Updated description.
- Added temporary "v2.0" to store icon to indicate new version.
- Updated screenshots to reflect the new look of the app
- Updated description
- 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:**
- Material You design
- Popup with cancel option
- Extensive settings panel:
@ -11,15 +12,17 @@ Redirect calls to Signal, Telegram, Threema, or WhatsApp.
- Allowlist specific contacts
- Change per-service priority
- Customize popup position, animation, and duration
...
- etc
**Supports:**
- Signal
- Telegram
- Threema
- WhatsApp
**Permissions required:**
- `CALL_PHONE` - initiate calls via messenger
- `READ_CONTACTS` - check contact compatibility
- `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.
---
**License:** GPL-3.0
Free and open source

View file

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