Compare commits

...

20 commits

Author SHA1 Message Date
660bac6af8 Merge pull request 'v2.0' (#1) from work into main
Reviewed-on: #1
2025-05-27 16:59:42 +00:00
partisan
f8aeb6da10 Some final changes 2025-05-27 18:56:59 +02:00
partisan
9dcfe80a26 Updated fastlane/metadata 2025-05-26 21:45:06 +02:00
partisan
b65222ddf9 Important stuff 2025-05-25 21:59:07 +02:00
partisan
693607de7c More animations 2025-05-25 18:19:02 +02:00
partisan
b2b839cf72 Show if token is activated 2025-05-25 11:37:36 +02:00
partisan
e8549c8841 Added Redirect settings page 2025-05-20 14:31:24 +02:00
partisan
4ce065425b Added donate page 2025-05-20 12:50:08 +02:00
partisan
6af51d8fc8 Added search for Contacts/Allowlist tab 2025-05-18 09:24:05 +02:00
partisan
6d9024a580 General fixes, UI cleanup and popup animations 2025-05-17 20:43:06 +02:00
partisan
e810208a14 Clean up & Fixes 2025-05-17 08:57:57 +02:00
partisan
27cd0a829e Updated Gradle to 9.0.0 2025-05-16 16:39:16 +02:00
partisan
2939fb55d1 Added Services into its own menu 2025-05-16 12:50:35 +02:00
partisan
72d4a797ea Added About section to menu 2025-05-15 22:23:07 +02:00
partisan
1850641fdb Well, if it works it ain't stupid (material you fix for popup) 2025-05-15 09:13:23 +02:00
partisan
64efa8c15e This is so cute 2025-05-14 19:00:25 +02:00
partisan
fafe7e2cd5 Fancy toggle button for main menu 2025-05-14 14:31:05 +02:00
partisan
1691891a4d Contact Whitelist added 2025-05-13 08:31:02 +02:00
partisan
663463cd38 New menu, organizing settings to its separate categories 2025-05-11 17:21:25 +02:00
partisan
cba93c6069 New Welcome screen 2025-05-11 11:20:16 +02:00
88 changed files with 3682 additions and 443 deletions

View file

@ -1,9 +1,6 @@
name: Android Release Build
on:
push:
tags:
- '*'
workflow_dispatch: {}
jobs:

3
.gitignore vendored
View file

@ -14,4 +14,5 @@
.cxx
local.properties
release-key.jks
check.py
check.py
round.sh

View file

@ -7,7 +7,7 @@
</p>
<p align="center">
Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.
Redirecting outgoing calls to E2EE apps.
</p>
---
@ -30,14 +30,41 @@ Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.
<p align="center">
<span>
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/data/screenshot.png" alt="Main screen" height="500" style="border-radius: 8px;">
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" alt="Main screen" height="500" style="border-radius: 8px;">
</span>
&nbsp;&nbsp;&nbsp;&nbsp;
<span>
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/data/screenshot-redirecting.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
</span>
&nbsp;&nbsp;&nbsp;&nbsp;
<span>
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
</span>
&nbsp;&nbsp;&nbsp;&nbsp;
<span>
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
</span>
</p>
# Features
- Material You design
- Popup with cancel option
- Extensive settings panel:
- Toggle per-service support
- Redirection only on Wi-Fi/Data
- Allowlist specific contacts
- Change per-service priority
- Customize popup position, animation, and duration
- ...
# Supports
- Signal
- Telegram
- Threema
- WhatsApp
# How to Install
## Using Droid-ify (or other F-Droid client)
@ -68,13 +95,14 @@ Install it, and youre done!
# Permissions
* ACCESS_NETWORK_STATE - check internet is available
* CALL_PHONE - make a call via messenger
* READ_CONTACTS - check contact has a messenger record
* SYSTEM_ALERT_WINDOW - show redirecting popup and launch an activity from background
* CALL_REDIRECTION - process outgoing call
- `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
All permissions are mandatory.
Currently all of the permissions are required.
# License

View file

@ -11,8 +11,8 @@ android {
applicationId = "partisan.weforge.xyz.pulse"
minSdk = 29
targetSdk = 34
versionCode = 13
versionName = "1.4.1"
versionCode = 14
versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -29,7 +29,7 @@ android {
buildTypes {
release {
minifyEnabled = false
signingConfig signingConfigs.release
signingConfig = signingConfigs.release
proguardFiles(getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro')
}
}
@ -62,11 +62,15 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.google.android.material:material:1.13.0-alpha13'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.cardview:cardview:1.0.0'
implementation "androidx.browser:browser:1.7.0"
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.32'
implementation 'nl.dionsegijn:konfetti-xml:2.0.2' // This library holds the fabric of reality together please dont remove it at any costs >:3
}

View file

@ -4,7 +4,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.telephony" android:required="true" />
<application
@ -16,13 +18,14 @@
android:theme="@style/Theme.Pulse">
<activity
android:name=".MainActivity"
android:name=".WelcomeActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".MainActivity" />
<service
android:name=".CallRedirectionService"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

View file

@ -0,0 +1,71 @@
package partisan.weforge.xyz.pulse
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import partisan.weforge.xyz.pulse.databinding.FragmentAboutBinding
class AboutFragment : Fragment() {
private var _binding: FragmentAboutBinding? = null
private val binding get() = _binding!!
private var tapCount = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAboutBinding.inflate(inflater, container, false)
return binding.root
}
override fun onResume() {
super.onResume()
(requireActivity() as? MainActivity)?.setAppBarTitle(
getString(R.string.about_name)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.sourceButton.setOnClickListener {
openUrl("https://weforge.xyz/partisan/Pulse")
}
binding.licenseButton.setOnClickListener {
openUrl("https://www.gnu.org/licenses/gpl-3.0.en.html")
}
binding.appIcon.setOnClickListener {
tapCount++
if (tapCount >= 5) {
binding.secretButton.visibility = View.VISIBLE
}
}
binding.secretButton.setOnClickListener {
requireActivity().supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, SecretFragment())
.addToBackStack(null)
.commit()
}
}
private fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -4,10 +4,13 @@ import android.Manifest
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.util.Log
import android.provider.ContactsContract
import android.telecom.CallRedirectionService
import android.telecom.PhoneAccountHandle
import android.telephony.TelephonyManager
import androidx.annotation.RequiresPermission
import com.google.i18n.phonenumbers.PhoneNumberUtil
import java.lang.ref.WeakReference
class CallRedirectionService : CallRedirectionService() {
@ -58,31 +61,97 @@ class CallRedirectionService : CallRedirectionService() {
initialPhoneAccount: PhoneAccountHandle,
allowInteractiveResponse: Boolean,
) {
if (!prefs.isEnabled || !hasInternet() || !allowInteractiveResponse) {
Log.d("Redirection", "onPlaceCall triggered: uri=$handle, interactive=$allowInteractiveResponse")
val capabilities = connectivityManager
?.getNetworkCapabilities(connectivityManager?.activeNetwork)
val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
val isCellular = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
Log.d("Redirection", "isWifi=$isWifi, isCellular=$isCellular")
val shouldRedirect = when {
isWifi && !prefs.redirectOnWifi -> false
isCellular && !prefs.redirectOnData -> false
else -> true
}
if (!prefs.isEnabled) {
Log.d("Redirection", "Aborting: redirection disabled in prefs")
placeCallUnmodified()
return
}
if (!shouldRedirect) {
Log.d("Redirection", "Aborting: redirection blocked by current network preference")
placeCallUnmodified()
return
}
if (!hasInternet()) {
Log.d("Redirection", "Aborting: no internet connection detected")
placeCallUnmodified()
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")
placeCallUnmodified()
return
}
if (prefs.isBlacklistEnabled && !prefs.isContactWhitelisted(phoneNumber)) {
Log.d("Redirection", "Aborting: number is not in whitelist while blacklist is enabled")
placeCallUnmodified()
return
}
val records: Array<Record>
try {
records = getRecordsFromPhoneNumber(handle.schemeSpecificPart)
records = getRecordsFromPhoneNumber(phoneNumber)
Log.d("Redirection", "Found ${records.size} raw records for contact")
} catch (exc: SecurityException) {
Log.w("Redirection", "SecurityException during record fetch", exc)
placeCallUnmodified()
return
}
// Filter to enabled services only
val enabledRecords = records
.filter { prefs.isServiceEnabled(it.mimetype) }
.sortedBy { prefs.getServicePriority(it.mimetype) }
Log.d("Redirection", "Filtered to ${enabledRecords.size} enabled records")
val record = enabledRecords.firstOrNull()
if (record == null) {
Log.d("Redirection", "Aborting: no suitable record found for redirection")
placeCallUnmodified()
return
}
window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return)
Log.d("Redirection", "Redirecting call to: ${record.mimetype}${record.uri}")
if (prefs.popupEnabled) {
window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return)
} else {
window.call(record.uri)
cancelCall()
}
}
@RequiresPermission(Manifest.permission.READ_CONTACTS)
@ -134,8 +203,36 @@ class CallRedirectionService : CallRedirectionService() {
return results.toTypedArray()
}
private fun isInternationalNumber(phoneNumber: String): Boolean {
val telephony = getSystemService(TelephonyManager::class.java) ?: return true
val simCountryIso = telephony.simCountryIso?.lowercase() ?: return true
// Use libphonenumber to parse the number and get region
val util = PhoneNumberUtil.getInstance()
return try {
val numberProto = util.parse(phoneNumber, simCountryIso.uppercase())
val numberRegion = util.getRegionCodeForNumber(numberProto)?.lowercase()
numberRegion != simCountryIso
} catch (e: Exception) {
true // treat as international if parsing fails
}
}
fun isOutsideHomeCountry(): Boolean {
val telephony = getSystemService(TelephonyManager::class.java) ?: return true
val simCountry = telephony.simCountryIso?.lowercase()
val networkCountry = telephony.networkCountryIso?.lowercase()
// If SIM or network country can't be determined, assume we're abroad
if (simCountry.isNullOrBlank() || networkCountry.isNullOrBlank()) return true
// If they don't match, you're abroad
return simCountry != networkCountry
}
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
private fun hasInternet(): Boolean {
private fun hasInternet(): Boolean { // This "hasInternet" func is (kinda) re-defined in Donation Fragment
val capabilities = connectivityManager
?.getNetworkCapabilities(connectivityManager?.activeNetwork) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&

View file

@ -0,0 +1,49 @@
package partisan.weforge.xyz.pulse
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class ContactAdapter(
private val prefs: Preferences,
private val fullList: List<ContactEntry>
) : RecyclerView.Adapter<ContactAdapter.ViewHolder>() {
private var filteredList = fullList.toMutableList()
inner class ViewHolder(inflater: LayoutInflater, parent: ViewGroup) :
RecyclerView.ViewHolder(inflater.inflate(R.layout.item_contact, parent, false)) {
val contactName: TextView = itemView.findViewById(R.id.contactName)
val contactAllowed: CheckBox = itemView.findViewById(R.id.contactAllowed)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(parent.context), parent)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = filteredList[position]
holder.contactName.text = contact.name
holder.contactAllowed.setOnCheckedChangeListener(null)
holder.contactAllowed.isChecked = prefs.isContactWhitelisted(contact.phoneNumber)
holder.contactAllowed.setOnCheckedChangeListener { _, isChecked ->
prefs.setContactWhitelisted(contact.phoneNumber, isChecked)
}
}
override fun getItemCount(): Int = filteredList.size
fun filter(query: String) {
filteredList = if (query.isBlank()) {
fullList.toMutableList()
} else {
fullList.filter {
it.name.contains(query, ignoreCase = true)
}.toMutableList()
}
notifyDataSetChanged()
}
}

View file

@ -0,0 +1,6 @@
package partisan.weforge.xyz.pulse
data class ContactEntry(
val name: String,
val phoneNumber: String
)

View file

@ -0,0 +1,126 @@
package partisan.weforge.xyz.pulse
import android.content.ContentResolver
import android.os.Bundle
import android.provider.ContactsContract
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.graphics.Color
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import partisan.weforge.xyz.pulse.databinding.FragmentContactsBinding
class ContactsFragment : Fragment() {
private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!!
private lateinit var prefs: Preferences
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onResume() {
super.onResume()
(requireActivity() as? MainActivity)?.apply {
setAppBarTitle(getString(R.string.settings_name), getString(R.string.whitelist_name))
setupPopupToggle(true, prefs.isBlacklistEnabled) { isChecked ->
prefs.isBlacklistEnabled = isChecked
binding.contactRecycler.isEnabled = isChecked
binding.contactRecycler.alpha = if (isChecked) 1f else 0.4f
}
}
// Initial state
binding.contactRecycler.isEnabled = prefs.isBlacklistEnabled
binding.contactRecycler.alpha = if (prefs.isBlacklistEnabled) 1f else 0.4f
}
override fun onPause() {
super.onPause()
(requireActivity() as? MainActivity)?.setupPopupToggle(false)
}
private lateinit var adapter: ContactAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = Preferences(requireContext())
val contacts = getContacts()
adapter = ContactAdapter(prefs, contacts)
binding.contactRecycler.layoutManager = LinearLayoutManager(requireContext())
binding.contactRecycler.adapter = adapter
binding.contactSearch.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
adapter.filter(newText ?: "")
return true
}
})
val searchView = binding.contactSearch
searchView.setIconifiedByDefault(false)
searchView.isIconified = false
searchView.isSubmitButtonEnabled = false
searchView.clearFocus()
val editText = searchView.findViewById<androidx.appcompat.widget.SearchView.SearchAutoComplete>(
androidx.appcompat.R.id.search_src_text
)
editText.isFocusable = true
editText.isFocusableInTouchMode = true
editText.setTextColor(Color.WHITE)
editText.setHintTextColor(Color.LTGRAY)
val searchPlate = searchView.findViewById<View>(androidx.appcompat.R.id.search_plate)
searchPlate.setBackgroundColor(Color.TRANSPARENT)
searchPlate.setPadding(0, 0, 0, 0)
}
private fun getContacts(): List<ContactEntry> {
val resolver: ContentResolver = requireContext().contentResolver
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER
)
val cursor = resolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
null,
null,
"${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)
while (it.moveToNext()) {
val name = it.getString(nameIndex) ?: continue
val number = it.getString(numberIndex) ?: continue
results.add(ContactEntry(name, number))
}
}
return results
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -0,0 +1,213 @@
package partisan.weforge.xyz.pulse
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.Fragment
import okhttp3.*
import partisan.weforge.xyz.pulse.databinding.FragmentDonateBinding
import java.io.IOException
class DonateFragment : Fragment() {
private val client = OkHttpClient()
private val apiBase = "https://api.weforge.xyz/api"
private var _binding: FragmentDonateBinding? = null
private val binding get() = _binding!!
private lateinit var prefs: Preferences
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentDonateBinding.inflate(inflater, container, false)
return binding.root
}
override fun onResume() {
super.onResume()
(requireActivity() as? MainActivity)?.setAppBarTitle(
getString(R.string.about_name), getString(R.string.donate_name)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
prefs = Preferences(requireContext())
// Pre-fill token field
binding.tokenInput.setText(prefs.donationToken)
// Show toast and open Ko-fi
binding.kofiButton.setOnClickListener {
Toast.makeText(
requireContext(),
getString(R.string.donate_toast_reminder),
Toast.LENGTH_LONG
).show()
val customTab = CustomTabsIntent.Builder().build()
customTab.launchUrl(requireContext(), Uri.parse("https://ko-fi.com/internetaddict"))
}
binding.tokenDisplay.text = "token:${prefs.donationToken}"
binding.tokenDisplay.setOnClickListener {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("Ko-fi token", "token:${prefs.donationToken}")
clipboard.setPrimaryClip(clip)
Toast.makeText(context, getString(R.string.donate_token_copied), Toast.LENGTH_SHORT).show()
}
// If already donated, update UI to show activation message and hide token entry controls
if (prefs.isDonationActivated) {
binding.postDonatePrompt.text = getString(R.string.donate_token_activated)
binding.openTokenSection.visibility = View.GONE
binding.tokenSection.visibility = View.GONE
} else {
// Show token entry section button if not activated
binding.openTokenSection.setOnClickListener {
binding.tokenSection.visibility = View.VISIBLE
binding.openTokenSection.visibility = View.GONE
}
}
binding.verifyButton.setOnClickListener {
var token = binding.tokenInput.text.toString().trim()
// Strip optional "token:" prefix
if (token.startsWith("token:")) {
token = token.removePrefix("token:")
}
// Validate token format
if (token.length != 16) {
Toast.makeText(context, getString(R.string.donate_token_invalid_format), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
prefs.donationToken = token
if (prefs.isDonationActivated) {
binding.resultText.text = getString(R.string.donate_token_already_activated)
return@setOnClickListener
}
// Step 0: Check INTERNET permission
if (!hasInternetPermission(requireContext())) {
binding.resultText.text = getString(R.string.donate_missing_permission)
return@setOnClickListener
}
// Step 1: Try activation server first
val aliveRequest = Request.Builder().url("$apiBase/alive").build()
client.newCall(aliveRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
// If server unreachable, fallback to internet check
val internetCheck = Request.Builder()
.url("https://deb.debian.org/")
.build()
client.newCall(internetCheck).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activity?.runOnUiThread {
binding.resultText.text = getString(R.string.donate_no_internet)
}
}
override fun onResponse(call: Call, response: Response) {
activity?.runOnUiThread {
if (!response.isSuccessful || response.body?.string().isNullOrBlank()) {
binding.resultText.text = getString(R.string.donate_no_internet)
} else {
binding.resultText.text = getString(R.string.donate_server_unreachable)
}
}
}
})
}
override fun onResponse(call: Call, response: Response) {
if (response.body?.string()?.trim() != "true") {
activity?.runOnUiThread {
binding.resultText.text = getString(R.string.donate_server_not_responding)
}
return
}
// Step 2: Check token
val checkRequest = Request.Builder()
.url("$apiBase/check?token=$token")
.build()
client.newCall(checkRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activity?.runOnUiThread {
binding.resultText.text = getString(R.string.donate_token_check_failed)
}
}
override fun onResponse(call: Call, response: Response) {
val result = response.body?.string()?.trim()
if (result == "0") {
activity?.runOnUiThread {
binding.resultText.text = getString(R.string.donate_token_invalid)
}
return
}
// Step 3: Activate
val activateRequest = Request.Builder()
.url("$apiBase/activate?token=$token")
.build()
client.newCall(activateRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activity?.runOnUiThread {
binding.resultText.text = getString(R.string.donate_activation_failed)
}
}
override fun onResponse(call: Call, response: Response) {
val activateResult = response.body?.string()?.trim()
if (activateResult == "success") {
prefs.isDonationActivated = true
activity?.runOnUiThread {
val remaining = (result?.toIntOrNull() ?: 1) - 1
binding.resultText.text =
getString(R.string.donate_token_activated) + "\n" +
getString(R.string.donate_token_left, remaining.toString())
}
} else {
activity?.runOnUiThread {
binding.resultText.text = getString(R.string.donate_activation_failed)
}
}
}
})
}
})
}
})
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun hasInternetPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.INTERNET
) == PackageManager.PERMISSION_GRANTED
}
}

View file

@ -1,193 +1,153 @@
package partisan.weforge.xyz.pulse
import android.Manifest
import android.app.role.RoleManager
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContracts
import android.view.View
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doAfterTextChanged
import java.lang.NumberFormatException
import android.text.InputType
import android.widget.CheckBox
import android.widget.EditText
import android.widget.LinearLayout
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import partisan.weforge.xyz.pulse.getServicePriority
import partisan.weforge.xyz.pulse.setServicePriority
import partisan.weforge.xyz.pulse.isServiceEnabled
import partisan.weforge.xyz.pulse.setServiceEnabled
import partisan.weforge.xyz.pulse.databinding.ActivityMainBinding
import androidx.appcompat.app.ActionBarDrawerToggle
import partisan.weforge.xyz.pulse.REQUIRED_PERMISSIONS
import partisan.weforge.xyz.pulse.hasCallRedirectionRole
import partisan.weforge.xyz.pulse.hasDrawOverlays
import partisan.weforge.xyz.pulse.hasGeneralPermissions
import com.google.android.material.switchmaterial.SwitchMaterial
class MainActivity : AppCompatActivity() {
companion object {
private val PERMISSIONS = arrayOf(
Manifest.permission.READ_CONTACTS,
Manifest.permission.CALL_PHONE,
)
}
private lateinit var binding: ActivityMainBinding
private lateinit var prefs: Preferences
private lateinit var window: PopupWindow
private var roleManager: RoleManager? = null
private val registerForCallRedirectionRole =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
private var popupSwitch: SwitchMaterial? = null
private var popupMenuItem: MenuItem? = null
private val registerForGeneralPermissions =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {}
private val registerForDrawOverlays =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
val popupToggle: SwitchMaterial
get() = findViewById(R.id.globalPopupToggle)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
prefs = Preferences(this)
updateDonationIcon()
setContentView(binding.root)
init()
setup()
}
override fun onDestroy() {
super.onDestroy()
window.cancel()
}
private fun init() {
prefs = Preferences(this)
window = PopupWindow(this, null)
roleManager = getSystemService(RoleManager::class.java)
binding.apply {
redirectionDelay.value = (prefs.redirectionDelay / 1000).toFloat()
popupPosition.editText?.setText(prefs.popupPosition.toString())
toggle.isChecked = prefs.isEnabled
setSupportActionBar(binding.topAppBar)
val drawerToggle = ActionBarDrawerToggle(
this,
binding.drawerLayout,
binding.topAppBar,
R.string.navigation_drawer_open,
R.string.navigation_drawer_open // The "close" string is never actually shown in the UI, so I reuse "navigation_drawer_open" as sort of a placeholder
)
binding.drawerLayout.addDrawerListener(drawerToggle)
drawerToggle.syncState()
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, MainFragment())
.commit()
setupPopupToggle(false)
binding.navigationView.setNavigationItemSelectedListener { item ->
when (item.itemId) {
R.id.action_home -> {
supportFragmentManager.popBackStack(null, androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE)
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, MainFragment())
.commit()
true
}
R.id.action_popup_settings -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, PopupSettingsFragment())
.addToBackStack(null)
.commit()
true
}
R.id.action_about -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, AboutFragment())
.addToBackStack(null)
.commit()
true
}
R.id.action_services -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, ServiceSettingsFragment())
.addToBackStack(null)
.commit()
true
}
R.id.action_redirect_settings -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, RedirectSettingsFragment())
.addToBackStack(null)
.commit()
true
}
R.id.action_contacts -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, ContactsFragment())
.addToBackStack(null)
.commit()
true
}
R.id.action_donate -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, DonateFragment())
.addToBackStack(null)
.commit()
true
}
else -> false
}.also {
binding.drawerLayout.closeDrawers()
}
}
}
private fun setup() {
binding.apply {
redirectionDelay.setLabelFormatter {
String.format("%.1f", it)
}
redirectionDelay.addOnChangeListener { _, value, _ ->
prefs.redirectionDelay = (value * 1000).toLong()
}
popupPosition.setEndIconOnClickListener {
window.preview()
}
popupPosition.editText?.doAfterTextChanged {
try {
prefs.popupPosition = it?.toString()?.toInt() ?: return@doAfterTextChanged
} catch (exc: NumberFormatException) {}
}
toggle.setOnCheckedChangeListener { _, isChecked ->
if (isChecked && !hasPermissions()) {
toggle.isChecked = false
requestPermissions()
return@setOnCheckedChangeListener
}
prefs.isEnabled = isChecked
}
val services = listOf(
ServiceEntry("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call", R.string.destination_signal, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call")),
ServiceEntry("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call", R.string.destination_telegram, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call")),
ServiceEntry("vnd.android.cursor.item/vnd.ch.threema.app.call", R.string.destination_threema, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.ch.threema.app.call")),
ServiceEntry("vnd.android.cursor.item/vnd.com.whatsapp.voip.call", R.string.destination_whatsapp, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.com.whatsapp.voip.call")),
)
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.topbar_toggle, menu)
popupMenuItem = menu.findItem(R.id.globalPopupToggle)
popupSwitch = popupMenuItem?.actionView?.findViewById(R.id.globalPopupToggle)
popupMenuItem?.isVisible = false // hide by default
return true
}
val adapter = ServiceAdapter(
context = this@MainActivity,
services = services.toMutableList(),
onReordered = { updatedList ->
updatedList.forEachIndexed { index, entry ->
setServicePriority(entry.mimetype, index)
}
}
)
binding.serviceRecycler.adapter = adapter
binding.serviceRecycler.layoutManager = LinearLayoutManager(this@MainActivity)
val touchHelper = ItemTouchHelper(adapter.dragHelper)
touchHelper.attachToRecyclerView(binding.serviceRecycler)
adapter.setDragStartListener { viewHolder ->
touchHelper.startDrag(viewHolder)
fun setupPopupToggle(
visible: Boolean,
initialState: Boolean = false,
onToggle: ((Boolean) -> Unit)? = null
) {
popupMenuItem?.isVisible = visible
popupSwitch?.apply {
setOnCheckedChangeListener(null)
isChecked = initialState
setOnCheckedChangeListener { _, isChecked ->
onToggle?.invoke(isChecked)
}
// binding.serviceConfigList.removeAllViews()
// for ((mimetype, labelRes) in mimetypes) {
// val checkbox = CheckBox(this@MainActivity).apply {
// text = getString(labelRes)
// isChecked = this@MainActivity.isServiceEnabled(mimetype)
// setOnCheckedChangeListener { _, checked ->
// this@MainActivity.setServiceEnabled(mimetype, checked)
// }
// }
// val priorityInput = EditText(this@MainActivity).apply {
// inputType = InputType.TYPE_CLASS_NUMBER
// setEms(4)
// hint = "Priority"
// setText(this@MainActivity.getServicePriority(mimetype).toString())
// setOnFocusChangeListener { _, hasFocus ->
// if (!hasFocus) {
// val value = text.toString().toIntOrNull()
// if (value != null) this@MainActivity.setServicePriority(mimetype, value)
// }
// }
// }
// val row = LinearLayout(this@MainActivity).apply {
// orientation = LinearLayout.HORIZONTAL
// setPadding(0, 16, 0, 16)
// addView(checkbox, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
// addView(priorityInput, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
// }
// binding.serviceConfigList.addView(row)
// }
}
}
private fun requestPermissions() {
when {
!hasGeneralPermissions() -> requestGeneralPermissions()
!hasDrawOverlays() -> requestDrawOverlays()
!hasCallRedirectionRole() -> requestCallRedirectionRole()
}
private fun updateDonationIcon() {
val donateItem = binding.navigationView.menu.findItem(R.id.action_donate)
donateItem.setIcon(
if (prefs.isDonationActivated)
R.drawable.heart_filled_24
else
R.drawable.heart_24
)
}
fun setAppBarTitle(vararg parts: String) {
binding.topAppBar.title = parts.joinToString(" > ")
}
private fun hasPermissions(): Boolean {
return hasGeneralPermissions() && hasDrawOverlays() && hasCallRedirectionRole()
}
private fun requestDrawOverlays() {
registerForDrawOverlays.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION))
}
private fun requestGeneralPermissions() {
registerForGeneralPermissions.launch(PERMISSIONS)
}
private fun hasGeneralPermissions(): Boolean {
return !PERMISSIONS.any { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
}
private fun hasDrawOverlays(): Boolean {
return Settings.canDrawOverlays(this)
}
private fun requestCallRedirectionRole() {
registerForCallRedirectionRole
.launch(roleManager?.createRequestRoleIntent(RoleManager.ROLE_CALL_REDIRECTION))
}
private fun hasCallRedirectionRole(): Boolean {
return roleManager?.isRoleHeld(RoleManager.ROLE_CALL_REDIRECTION) ?: false
return hasGeneralPermissions(this) &&
hasDrawOverlays(this) &&
hasCallRedirectionRole(this)
}
}

View file

@ -0,0 +1,67 @@
package partisan.weforge.xyz.pulse
import android.os.Bundle
import android.os.SystemClock
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
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() {
private lateinit var prefs: Preferences
private var lastConfettiTime = 0L
override fun onResume() {
super.onResume()
(requireActivity() as? MainActivity)?.setAppBarTitle(getString(R.string.app_name))
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.fragment_main, container, false)
prefs = Preferences(requireContext())
val toggle = view.findViewById<MaterialButton>(R.id.toggle)
val konfetti = view.findViewById<KonfettiView>(R.id.confettiView)
toggle.isCheckable = true
toggle.isChecked = prefs.isServiceEnabledByUser
toggle.setOnClickListener {
// the button toggles itself internally since it's checkable
val isNowChecked = toggle.isChecked
prefs.isServiceEnabledByUser = isNowChecked
if (isNowChecked && SystemClock.elapsedRealtime() - lastConfettiTime > 500) {
konfetti.start(
Party(
emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).perSecond(100),
speed = 30f,
maxSpeed = 40f,
damping = 0.85f,
spread = 360,
position = Position.Relative(0.5, 0.5)
)
)
lastConfettiTime = SystemClock.elapsedRealtime()
}
toggle.post {
toggle.jumpDrawablesToCurrentState()
toggle.invalidate()
}
}
return view
}
}

View file

@ -0,0 +1,48 @@
package partisan.weforge.xyz.pulse
import android.content.Context
import android.graphics.*
import android.view.View
import kotlin.random.Random
class MatrixRainView(context: Context) : View(context) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GREEN
textSize = 5f * resources.displayMetrics.density
typeface = Typeface.MONOSPACE
}
private val charset = "01アイウエオカキクケコ".toCharArray()
private val random = Random
private var columns = 0
private lateinit var yOffsets: IntArray
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
columns = w / paint.textSize.toInt()
yOffsets = IntArray(columns) { random.nextInt(h) }
}
override fun onDraw(canvas: Canvas) {
// drawColor with transparent clear instead of black
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
for (i in 0 until columns) {
val x = i * paint.textSize
val y = yOffsets[i].toFloat()
val char = charset[random.nextInt(charset.size)]
paint.alpha = 255
canvas.drawText(char.toString(), x, y, paint)
paint.alpha = 100
canvas.drawText(char.toString(), x, y - paint.textSize, paint)
yOffsets[i] += paint.textSize.toInt()
if (yOffsets[i] > height) {
yOffsets[i] = 0
}
}
postInvalidateDelayed(50)
}
}

View file

@ -0,0 +1,27 @@
package partisan.weforge.xyz.pulse
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.Settings
import android.app.role.RoleManager
val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.READ_CONTACTS,
Manifest.permission.CALL_PHONE,
)
fun hasGeneralPermissions(context: Context): Boolean {
return REQUIRED_PERMISSIONS.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
}
fun hasDrawOverlays(context: Context): Boolean {
return Settings.canDrawOverlays(context)
}
fun hasCallRedirectionRole(context: Context): Boolean {
val roleManager = context.getSystemService(RoleManager::class.java)
return roleManager?.isRoleHeld(RoleManager.ROLE_CALL_REDIRECTION) ?: false
}

View file

@ -0,0 +1,152 @@
package partisan.weforge.xyz.pulse
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.AdapterView
import android.widget.CompoundButton
import android.widget.Spinner
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.core.content.getSystemService
import partisan.weforge.xyz.pulse.databinding.FragmentPopupSettingsBinding
class PopupSettingsFragment : Fragment() {
private var _binding: FragmentPopupSettingsBinding? = null
private val binding get() = _binding!!
private lateinit var prefs: Preferences
private lateinit var window: PopupWindow
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentPopupSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onResume() {
super.onResume()
updateSpinner()
(requireActivity() as? MainActivity)?.apply {
setAppBarTitle(getString(R.string.settings_name), getString(R.string.popup_name))
setupPopupToggle(true, prefs.popupEnabled) { isChecked ->
prefs.popupEnabled = isChecked
updateControls(isChecked)
}
}
}
override fun onPause() {
super.onPause()
(requireActivity() as? MainActivity)?.setupPopupToggle(false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = Preferences(requireContext())
window = PopupWindow(requireContext(), null)
binding.popupPreview.setOnClickListener {
window.preview(false)
}
binding.popupPreview.setOnLongClickListener {
window.preview(true)
true
}
binding.redirectionDelay.value = (prefs.redirectionDelay / 1000).toFloat()
binding.redirectionDelay.setLabelFormatter {
String.format("%.1f", it)
}
binding.redirectionDelay.addOnChangeListener { _, value, _ ->
prefs.redirectionDelay = (value * 1000).toLong()
}
updateSpinner()
val screenHeight = getScreenHeightPx()
binding.popupHeightSlider.valueFrom = 0f
binding.popupHeightSlider.valueTo = screenHeight.toFloat()
binding.popupHeightSlider.value = prefs.popupPosition.toFloat()
binding.popupHeightSlider.addOnChangeListener { _, value, _ ->
prefs.popupPosition = value.toInt().coerceIn(0, screenHeight)
}
updateControls(prefs.popupEnabled)
}
private fun updateControls(enabled: Boolean) {
binding.redirectionDelay.isEnabled = enabled
binding.popupHeightSlider.isEnabled = enabled
binding.popupPreview.isEnabled = enabled
binding.popupEffectSpinner.isEnabled = enabled
binding.popupEffectLabel.isEnabled = enabled
}
private fun getScreenHeightPx(): Int {
val wm = requireContext().getSystemService<android.view.WindowManager>()!!
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bounds: Rect = wm.currentWindowMetrics.bounds
bounds.height()
} else {
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
requireActivity().windowManager.defaultDisplay.getMetrics(metrics)
metrics.heightPixels
}
}
private fun updateSpinner() {
val allEffects = Preferences.PopupEffect.values()
val effectLabels = resources.getStringArray(R.array.popup_effects)
val availableEffects = prefs.getAvailablePopupEffects() + listOf(
Preferences.PopupEffect.NONE,
Preferences.PopupEffect.RANDOM
)
val displayNames = allEffects.mapIndexed { index, effect ->
val baseName = effectLabels.getOrElse(index) { effect.name }
if (!prefs.isDonationActivated && effect !in availableEffects)
"$baseName 🔒"
else
baseName
}
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, displayNames)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.popupEffectSpinner.adapter = adapter
binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal)
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()) {
Toast.makeText(requireContext(), getString(R.string.donate_lock), Toast.LENGTH_SHORT).show()
binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal)
} else {
prefs.popupEffect = selectedEffect
}
}
override fun onNothingSelected(parent: AdapterView<*>) {}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -3,30 +3,49 @@ package partisan.weforge.xyz.pulse
import android.Manifest
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.PixelFormat
import android.graphics.Rect
import android.media.AudioManager
import android.net.Uri
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.ContextThemeWrapper
import android.widget.TextView
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.os.Handler
import android.os.Looper
import android.widget.ProgressBar
import android.view.animation.OvershootInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.annotation.RequiresPermission
import androidx.core.content.res.use
import com.google.android.material.card.MaterialCardView
import java.lang.ref.WeakReference
import java.util.*
import kotlin.concurrent.timerTask
import android.util.Log
import android.content.res.ColorStateList
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.MaterialColors
import partisan.weforge.xyz.pulse.Preferences.PopupEffect
import partisan.weforge.xyz.pulse.MatrixRainView
class PopupWindow(
ctx: Context,
private val service: WeakReference<CallRedirectionService>?,
) {
private val themedCtx = ContextThemeWrapper(ctx, R.style.Theme_Pulse)
private val themedCtx = DynamicColors.wrapContextIfAvailable(
ContextThemeWrapper(ctx, R.style.Theme_Pulse)
)
private val prefs = Preferences(themedCtx)
private val windowManager = themedCtx.getSystemService(WindowManager::class.java)
private val audioManager = themedCtx.getSystemService(AudioManager::class.java)
private val view = LayoutInflater.from(themedCtx).inflate(R.layout.popup, null)
private val inflater = LayoutInflater.from(themedCtx)
private val view = inflater.inflate(R.layout.popup, null)
private val layoutParams = WindowManager.LayoutParams().apply {
format = PixelFormat.TRANSLUCENT
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
@ -36,57 +55,75 @@ class PopupWindow(
height = WindowManager.LayoutParams.WRAP_CONTENT
y = prefs.popupPosition
}
private var timer: Timer? = null
private var currentEffect: PopupEffect = PopupEffect.NONE
private var matrixOverlay: View? = null
private var gamerAnimator: ValueAnimator? = null
private var timer: Timer? = null
init {
view.setOnClickListener {
cancel()
service?.get()?.placeCallUnmodified()
}
// This is utterly stupid, but it works
applyResolvedColors(view)
}
fun preview() {
fun preview(isLongPress: Boolean = false) {
remove()
layoutParams.y = prefs.popupPosition
val destinations = listOf(
R.string.destination_signal,
R.string.destination_telegram,
R.string.destination_threema,
// Whatsapp smells
)
setDescription(destinations.random())
add()
}
fun show(uri: Uri, destinationId: Int) {
val service = service?.get() ?: return
if (!remove()) {
service.placeCallUnmodified()
return
}
val duration = if (isLongPress) prefs.redirectionDelay * 5 else prefs.redirectionDelay
timer?.cancel()
timer = Timer()
timer?.schedule(timerTask {
if (!remove()) {
service.placeCallUnmodified()
return@timerTask
remove()
}, duration)
}
fun show(uri: Uri, destinationId: Int) {
val svc = service?.get() ?: return
timer?.cancel()
timer = Timer()
timer?.schedule(timerTask {
Handler(Looper.getMainLooper()).post {
if (!remove()) {
svc.placeCallUnmodified()
return@post
}
if (audioManager?.mode != AudioManager.MODE_IN_CALL) {
svc.placeCallUnmodified()
return@post
}
try {
call(uri)
} catch (exc: SecurityException) {
svc.placeCallUnmodified()
return@post
}
svc.cancelCall()
}
if (audioManager?.mode != AudioManager.MODE_IN_CALL) {
service.placeCallUnmodified()
return@timerTask
}
try {
call(uri)
} catch (exc: SecurityException) {
service.placeCallUnmodified()
return@timerTask
}
service.cancelCall()
}, prefs.redirectionDelay)
layoutParams.y = prefs.popupPosition
setDescription(destinationId)
startProgressAnimation(prefs.redirectionDelay)
if (!add()) {
Log.w("PopupWindow", "add() failed popup not shown, calling directly.")
timer?.cancel()
service.placeCallUnmodified()
svc.placeCallUnmodified()
}
}
@ -107,7 +144,7 @@ class PopupWindow(
}
@RequiresPermission(Manifest.permission.CALL_PHONE)
private fun call(data: Uri) {
fun call(data: Uri) {
Intent(Intent.ACTION_VIEW).apply {
this.data = data
flags = Intent.FLAG_ACTIVITY_NEW_TASK
@ -115,27 +152,298 @@ class PopupWindow(
}
}
private fun animateAppear() {
view.animate().cancel()
// Always reset all transforms before animation
view.rotationX = 0f
view.alpha = 1f
view.translationX = 0f
view.translationY = 0f
view.scaleX = 1f
view.scaleY = 1f
// Reset gamer effect if it was active before
gamerAnimator?.cancel()
gamerAnimator = null
val card = view as MaterialCardView
themedCtx.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorOutline)).use { ta ->
val defaultStroke = ta.getColor(0, Color.DKGRAY)
card.strokeColor = defaultStroke
}
val effect = when (prefs.popupEffect) {
PopupEffect.RANDOM -> prefs.getAvailablePopupEffects().random()
else -> prefs.popupEffect
}
currentEffect = effect
when (effect) {
PopupEffect.NONE -> {}
PopupEffect.FADE -> {
view.alpha = 0f
view.animate().alpha(1f).setDuration(300).start()
}
PopupEffect.SCALE -> { //
view.translationX = view.width.toFloat()
view.alpha = 0f
view.animate()
.translationX(0f)
.alpha(1f)
.setDuration(350)
.setInterpolator(DecelerateInterpolator(2f))
.start()
}
PopupEffect.BOUNCE -> {
view.scaleX = 0.7f
view.scaleY = 0.7f
view.animate().scaleX(1f).scaleY(1f)
.setInterpolator(OvershootInterpolator())
.setDuration(400).start()
}
PopupEffect.FLOP -> {
view.rotationX = 90f
view.alpha = 0f
view.animate().rotationX(0f).alpha(1f)
.setDuration(350)
.setInterpolator(DecelerateInterpolator())
.start()
}
PopupEffect.MATRIX -> {
val rainView = MatrixRainView(themedCtx)
matrixOverlay?.let {
try {
windowManager?.removeViewImmediate(it)
} catch (_: Exception) {}
}
matrixOverlay = rainView
val popupBounds = Rect()
view.getGlobalVisibleRect(popupBounds)
val overlayParams = WindowManager.LayoutParams().apply {
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
format = PixelFormat.TRANSLUCENT
flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
width = view.width
height = view.height
gravity = Gravity.BOTTOM
x = 0
y = layoutParams.y
}
try {
windowManager?.addView(rainView, overlayParams)
} catch (e: Exception) {
Log.e("MatrixRain", "Failed to add rainView", e)
return
}
// Fade-in popup over 500ms
view.alpha = 0f
view.animate().cancel()
view.animate().alpha(1f).setDuration(500).start()
// Remove MatrixRainView in sync
rainView.animate().alpha(0f).setDuration(500).withEndAction {
try {
windowManager?.removeView(rainView)
matrixOverlay = null
} catch (_: Exception) {}
}.start()
}
PopupEffect.SLIDE_SNAP -> {
view.translationY = 200f
view.alpha = 0f
view.animate()
.translationY(0f)
.alpha(1f)
.setDuration(350)
.setInterpolator(OvershootInterpolator(2f))
.start()
}
PopupEffect.GAMER_MODE -> {
val popupCard = view as MaterialCardView
val hsv = floatArrayOf(0f, 1f, 1f)
gamerAnimator?.cancel() // Cancel any existing animator before starting new one
gamerAnimator = ValueAnimator.ofFloat(0f, 360f).apply {
duration = 2000
repeatCount = ValueAnimator.INFINITE
addUpdateListener {
hsv[0] = it.animatedValue as Float
popupCard.strokeColor = Color.HSVToColor(hsv)
}
start()
}
view.alpha = 0f
view.animate()
.alpha(1f)
.setDuration(400)
.start()
}
else -> {}
}
}
private fun animateDisappear(onEnd: () -> Unit) {
// While the reset after animation can be a nice safety net, it's causing visual glitches and is already run at the start of every animation anyway
// val resetAndFinish = {
// view.animate().cancel()
// view.translationX = 0f
// view.translationY = 0f
// view.scaleX = 1f
// view.scaleY = 1f
// view.rotationX = 0f
// view.rotationY = 0f
// view.alpha = 1f
// Log.d("PopupWindow", "Reset and finish after disappear animation")
// onEnd()
// }
val end = Runnable {
Log.d("PopupWindow", "Disappearance animation complete")
view.post { onEnd() } // defer by one frame to ensure alpha=0 is rendered
}
when (currentEffect) {
// PopupEffect.NONE -> falls to else
PopupEffect.FADE -> view.animate()
.alpha(0f)
.setDuration(200)
.withEndAction(end)
.start()
PopupEffect.SCALE -> view.animate()
.translationX(view.width.toFloat())
.alpha(0f)
.setDuration(200)
.setInterpolator(DecelerateInterpolator(2f))
.withEndAction(end)
.start()
PopupEffect.BOUNCE -> view.animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(200)
.withEndAction(end)
.start()
PopupEffect.FLOP -> view.animate()
.rotationX(90f)
.alpha(0f)
.setDuration(200)
.withEndAction(end)
.start()
PopupEffect.MATRIX -> {
view.animate()
.alpha(0f)
.setDuration(150)
.withEndAction(end)
.start()
matrixOverlay?.let { overlay ->
overlay.animate().cancel()
overlay.animate().alpha(0f).setDuration(150).withEndAction {
try {
windowManager?.removeViewImmediate(overlay)
} catch (_: Exception) {}
matrixOverlay = null
}.start()
}
}
PopupEffect.SLIDE_SNAP -> view.animate()
.translationY(200f)
.alpha(0f)
.setDuration(200)
.setInterpolator(DecelerateInterpolator(2f))
.withEndAction(end)
.start()
else -> end.run()
}
}
private fun add(): Boolean {
try {
// If already attached, force remove and re-add
if (view.parent != null) {
windowManager?.removeViewImmediate(view)
}
view.animate().cancel()
windowManager?.addView(view, layoutParams)
} catch (exc: WindowManager.BadTokenException) {
animateAppear()
} catch (exc: Exception) {
Log.e("PopupWindow", "Failed to add popup view", exc)
return false
}
return true
}
private fun remove(): Boolean {
try {
windowManager?.removeView(view)
} catch (_: IllegalArgumentException) {
} catch (_: WindowManager.BadTokenException) {
return false
private fun remove(onRemoved: (() -> Unit)? = null): Boolean {
return try {
animateDisappear {
try {
windowManager?.removeView(view)
matrixOverlay?.let {
try {
windowManager?.removeViewImmediate(it)
} catch (e: Exception) {
Log.e("PopupWindow", "Failed to remove matrix overlay", e)
}
matrixOverlay = null
}
} catch (e: Exception) {
Log.e("PopupWindow", "Failed to remove popup view", e)
}
onRemoved?.invoke()
}
true
} catch (e: Exception) {
Log.e("PopupWindow", "Exception during remove()", e)
false
}
return true
}
fun cancel() {
Log.d("PopupWindow", "Cancel called")
timer?.cancel()
remove()
}
private fun applyResolvedColors(view: android.view.View) {
val attrSurface = com.google.android.material.R.attr.colorSurface
val attrOnSurface = com.google.android.material.R.attr.colorOnSurface
val attrPrimary = com.google.android.material.R.attr.colorPrimaryVariant
try {
themedCtx.obtainStyledAttributes(intArrayOf(attrSurface, attrOnSurface, attrPrimary)).use { ta ->
val surface = ta.getColor(0, Color.LTGRAY)
val onSurface = ta.getColor(1, Color.DKGRAY)
val primary = ta.getColor(2, Color.DKGRAY)
(view as? MaterialCardView)?.setCardBackgroundColor(surface)
view.findViewById<TextView>(R.id.description)?.setTextColor(onSurface)
view.findViewById<ProgressBar>(R.id.progress)?.let { bar ->
bar.progressTintList = ColorStateList.valueOf(primary)
bar.progressBackgroundTintList = ColorStateList.valueOf(
primary and 0x00FFFFFF or (0x40 shl 24)
)
}
view.invalidate()
}
} catch (e: Exception) {
Log.e("PopupTheme", "Color resolution error: ${e.message}", e)
}
}
}

View file

@ -4,24 +4,84 @@ import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
class Preferences(ctx: Context) {
class Preferences(private val context: Context) {
companion object {
private const val ENABLED = "enabled"
private const val REDIRECTION_DELAY = "redirection_delay"
private const val POPUP_POSITION = "popup_position_y"
private const val POPUP_ENABLED = "popup_enabled"
private val POPUP_EFFECT = "popup_effect"
private const val BLACKLISTED_CONTACTS = "blacklisted_contacts"
private const val BLACKLIST_ENABLED = "blacklist_enabled"
private val SERVICE_ORDER_KEY = "service_order"
private const val DONATION_ACTIVATED = "donation_activated"
private const val DONATION_TOKEN = "donation_token"
private const val REDIRECT_WIFI = "redirect_wifi"
private const val REDIRECT_DATA = "redirect_data"
private const val REDIRECT_INTERNATIONAL = "redirect_international"
private const val REDIRECT_ROAMING = "redirect_roaming"
private const val DEFAULT_REDIRECTION_DELAY = 2000L
private const val DEFAULT_POPUP_POSITION = 333
// migration
private const val SERVICE_ENABLED = "service_enabled"
}
private val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
var isEnabled: Boolean
get() = prefs.getBoolean(ENABLED, prefs.getBoolean(SERVICE_ENABLED, false))
set(value) = prefs.edit { putBoolean(ENABLED, value) }
// Whether user enabled/disabled the service manually by tiggle button
var isServiceEnabledByUser: Boolean
get() = prefs.getBoolean(SERVICE_ENABLED, true)
set(value) = prefs.edit { putBoolean(SERVICE_ENABLED, value) }
// True only if all required permissions + toggle are satisfied
val isEnabled: Boolean
get() = isServiceEnabledByUser &&
hasGeneralPermissions(context) &&
hasDrawOverlays(context) &&
hasCallRedirectionRole(context)
enum class PopupEffect {
NONE, FADE, SCALE, BOUNCE, FLOP, MATRIX, SLIDE_SNAP, GAMER_MODE, RANDOM
}
var popupEffect: PopupEffect
get() {
val name = prefs.getString(POPUP_EFFECT, PopupEffect.FADE.name) ?: PopupEffect.FADE.name
return try {
PopupEffect.valueOf(name)
} catch (_: IllegalArgumentException) {
// If invalid, fallback and clear the broken value
prefs.edit().remove(POPUP_EFFECT).apply()
PopupEffect.BOUNCE
}
}
set(value) {
prefs.edit().putString(POPUP_EFFECT, value.name).apply()
}
var isBlacklistEnabled: Boolean
get() = prefs.getBoolean(BLACKLIST_ENABLED, false)
set(value) = prefs.edit { putBoolean(BLACKLIST_ENABLED, value) }
var isDonationActivated: Boolean
get() = prefs.getBoolean(DONATION_ACTIVATED, false)
set(value) = prefs.edit { putBoolean(DONATION_ACTIVATED, value) }
var donationToken: String
get() {
val stored = prefs.getString(DONATION_TOKEN, null)
return if (stored != null) {
stored
} else {
generateAndStoreToken()
}
}
set(value) = prefs.edit { putString(DONATION_TOKEN, value) }
var popupEnabled: Boolean
get() = prefs.getBoolean(POPUP_ENABLED, true)
set(value) = prefs.edit { putBoolean(POPUP_ENABLED, value) }
var redirectionDelay: Long
get() = prefs.getLong(REDIRECTION_DELAY, DEFAULT_REDIRECTION_DELAY)
@ -31,26 +91,87 @@ class Preferences(ctx: Context) {
get() = prefs.getInt(POPUP_POSITION, DEFAULT_POPUP_POSITION)
set(value) = prefs.edit { putInt(POPUP_POSITION, value) }
var redirectOnWifi: Boolean
get() = prefs.getBoolean(REDIRECT_WIFI, true)
set(value) = prefs.edit { putBoolean(REDIRECT_WIFI, value) }
var redirectOnData: Boolean
get() = prefs.getBoolean(REDIRECT_DATA, true)
set(value) = prefs.edit { putBoolean(REDIRECT_DATA, value) }
var redirectInternationalOnly: Boolean
get() = prefs.getBoolean(REDIRECT_INTERNATIONAL, false)
set(value) = prefs.edit { putBoolean(REDIRECT_INTERNATIONAL, value) }
var redirectIfRoaming: Boolean
get() = prefs.getBoolean(REDIRECT_ROAMING, false)
set(value) = prefs.edit { putBoolean(REDIRECT_ROAMING, value) }
private fun makeKeyEnabled(mimetype: String) = "enabled_$mimetype"
private fun makeKeyPriority(mimetype: String) = "priority_$mimetype"
/** Whether this service is enabled */
fun getAvailablePopupEffects(): List<PopupEffect> {
val locked = listOf(
PopupEffect.FLOP,
PopupEffect.MATRIX,
PopupEffect.SLIDE_SNAP,
PopupEffect.GAMER_MODE
)
return PopupEffect.values().filter {
isDonationActivated || it !in locked
}.filter { it != PopupEffect.RANDOM && it != PopupEffect.NONE }
}
fun isServiceEnabled(mimetype: String): Boolean {
return prefs.getBoolean(makeKeyEnabled(mimetype), true)
}
/** Current priority for this service (lower = higher priority) */
fun getServicePriority(mimetype: String): Int {
return prefs.getInt(makeKeyPriority(mimetype), Int.MAX_VALUE)
val order = getServiceOrder()
val index = order.indexOf(mimetype)
return if (index != -1) index else Int.MAX_VALUE
}
fun getServiceOrder(): List<String> {
val stored = prefs.getString(SERVICE_ORDER_KEY, null)
return stored?.split("|")?.filter { it.isNotBlank() } ?: emptyList()
}
fun setServiceOrder(order: List<String>) {
prefs.edit().putString(SERVICE_ORDER_KEY, order.joinToString("|")).apply()
}
/** Enable or disable individual service */
fun setServiceEnabled(mimetype: String, enabled: Boolean) {
prefs.edit().putBoolean(makeKeyEnabled(mimetype), enabled).apply()
}
/** Change priority for an individual service */
fun setServicePriority(mimetype: String, priority: Int) {
prefs.edit().putInt(makeKeyPriority(mimetype), priority).apply()
}
var blacklistedContacts: Set<String>
get() = prefs.getStringSet(BLACKLISTED_CONTACTS, emptySet()) ?: emptySet()
set(value) = prefs.edit { putStringSet(BLACKLISTED_CONTACTS, value) }
fun isContactWhitelisted(phoneNumber: String): Boolean {
return !blacklistedContacts.contains(phoneNumber)
}
fun setContactWhitelisted(phoneNumber: String, allowed: Boolean) {
val current = blacklistedContacts.toMutableSet()
if (!allowed) {
current.add(phoneNumber)
} else {
current.remove(phoneNumber)
}
blacklistedContacts = current
}
private fun generateAndStoreToken(): String {
val newToken = (1..16)
.map { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".random() }
.joinToString("")
prefs.edit().putString(DONATION_TOKEN, newToken).apply()
return newToken
}
}

View file

@ -0,0 +1,56 @@
package partisan.weforge.xyz.pulse
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.google.android.material.materialswitch.MaterialSwitch
class RedirectSettingsFragment : Fragment() {
private lateinit var prefs: Preferences
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
prefs = Preferences(requireContext())
return inflater.inflate(R.layout.fragment_redirect_settings, container, false)
}
override fun onResume() {
super.onResume()
(requireActivity() as? MainActivity)?.setAppBarTitle(
getString(R.string.settings_name), getString(R.string.redirect_name)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val switchWifi = view.findViewById<MaterialSwitch>(R.id.switchRedirectWifi)
val switchData = view.findViewById<MaterialSwitch>(R.id.switchRedirectData)
val switchInternational = view.findViewById<MaterialSwitch>(R.id.switchRedirectInternational)
val switchRoaming = view.findViewById<MaterialSwitch>(R.id.switchRedirectRoaming)
// Load saved state
switchWifi.isChecked = prefs.redirectOnWifi
switchData.isChecked = prefs.redirectOnData
switchInternational.isChecked = prefs.redirectInternationalOnly
switchRoaming.isChecked = prefs.redirectIfRoaming
// Save on toggle
switchWifi.setOnCheckedChangeListener { _, isChecked ->
prefs.redirectOnWifi = isChecked
}
switchData.setOnCheckedChangeListener { _, isChecked ->
prefs.redirectOnData = isChecked
}
switchInternational.setOnCheckedChangeListener { _, isChecked ->
prefs.redirectInternationalOnly = isChecked
}
switchRoaming.setOnCheckedChangeListener { _, isChecked ->
prefs.redirectIfRoaming = isChecked
}
}
}

View file

@ -0,0 +1,18 @@
package partisan.weforge.xyz.pulse
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
class SecretFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return SecretView(requireContext())
}
}

View file

@ -0,0 +1,902 @@
package partisan.weforge.xyz.pulse
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.*
import android.util.AttributeSet
import android.view.Choreographer
import android.view.MotionEvent
import android.view.View
import kotlin.math.*
import kotlin.random.Random
class SecretView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs), Choreographer.FrameCallback {
private val bulletPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
strokeWidth = 6f
}
private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
alpha = 40
style = Paint.Style.FILL
}
private val playerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val enemyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val rocketPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = 3f
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 64f
typeface = Typeface.DEFAULT_BOLD
}
private val retryRect = RectF()
private val retryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.DKGRAY
}
private val retryTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 48f
}
private var colorSecondary: Int = Color.GREEN
private var playerX = 0f
private var viewWidth = 0f
private var viewHeight = 0f
private var enemyClearAggression = 0f
private var adaptiveSpawnTimer = 0L
private var lastEnemyCount = 0
private var avgEnemiesPerSecond = 0f
private var isTouching = false
private var shieldRechargeTimer = 0L
private var shieldFlashAlpha = 0f
private var lastMissileSide = -1
private var missileCooldown = 0L
private var bulletCooldownMs = 0L
private var gameOver = false
private var score = 0
// Player level
private var multiFireLevel = 1
private var piercingLevel = 1
private var shieldLevel = 0
private var missileLevel = 0
private var rapidFireLevel = 1
private val bullets = mutableListOf<Bullet>()
private val enemyBullets = mutableListOf<Bullet>()
private val enemies = mutableListOf<Enemy>()
private val rockets = mutableListOf<Rocket>()
private val stars = mutableListOf<Star>()
private val explosions = mutableListOf<Explosion>()
private val rocketTrails = mutableListOf<Pair<Float, Float>>()
private val pickups = mutableListOf<Pickup>()
private val playerMissiles = mutableListOf<PlayerMissile>()
private data class Bullet(var x: Float, var y: Float, val dy: Float = -15f, var life: Int = 1)
private data class Star(var x: Float, var y: Float, val radius: Float, val speed: Float)
private data class Rocket(var x: Float, var y: Float, var angle: Float, val trail: MutableList<Pair<Float, Float>> = mutableListOf())
private data class Explosion(var x: Float, var y: Float, var timer: Int = 12)
private data class Pickup(
val x: Float,
var y: Float,
val type: Int,
var hue: Float = Random.nextFloat() * 360f,
)
private data class PlayerMissile(
var x: Float,
var y: Float,
var angle: Float,
var ttl: Long = 90000L,
var target: Enemy? = null,
var recheckCooldown: Long = 0L,
var side: Int, // -1 = left, 1 = right
val trail: MutableList<Pair<Float, Float>> = mutableListOf()
)
private var lastLogicTime = 0L
private val logicStepMs = 16L
private var waveTimer = 0L
private var currentWave = 0
private var enemiesLeftInWave = 0
private var currentWaveType = ""
init {
for (i in 0 until 50) {
stars.add(Star(Random.nextFloat() * 1080f, Random.nextFloat() * 1920f, Random.nextFloat() * 2f + 1f, Random.nextFloat() * 2f + 0.5f))
}
val colorAttrs = intArrayOf(
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)
}
Choreographer.getInstance().postFrameCallback(this)
lastLogicTime = System.nanoTime()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewWidth = w.toFloat()
viewHeight = h.toFloat()
// Kinda hacky way to center things, but I'd rather do this here than in update(), since it can't be in init() as the screen size isn't initialized at that point.
playerX = viewWidth / 2f
retryRect.set(viewWidth / 2f - 120f, viewHeight / 2f + 60f, viewWidth / 2f + 120f, viewHeight / 2f + 130f)
}
override fun doFrame(frameTimeNanos: Long) {
if (!gameOver) {
val now = System.nanoTime()
while ((now - lastLogicTime) / 1_000_000 >= logicStepMs) {
update(logicStepMs)
lastLogicTime += logicStepMs * 1_000_000
}
invalidate()
Choreographer.getInstance().postFrameCallback(this)
}
}
private fun update(deltaMs: Long) {
stars.forEach {
it.y += it.speed * deltaMs / 16f
if (it.y > viewHeight) {
it.y = 0f
it.x = Random.nextFloat() * viewWidth
}
}
// Update shield recharge timer and flash animation
if (shieldRechargeTimer > 0) {
shieldRechargeTimer -= deltaMs
if (shieldRechargeTimer <= 0) {
shieldRechargeTimer = 0
shieldFlashAlpha = 1f // trigger visual flash on recharge
}
}
if (shieldFlashAlpha > 0f) {
shieldFlashAlpha -= deltaMs / 300f
if (shieldFlashAlpha < 0f) shieldFlashAlpha = 0f
}
rockets.forEach { rocket ->
rocket.trail.add(0, rocket.x to rocket.y)
if (rocket.trail.size > 20) {
rocket.trail.removeLast()
}
}
explosions.forEach { it.timer-- }
explosions.removeIf { it.timer <= 0 }
bullets.forEach { it.y += it.dy * deltaMs / 16f }
bullets.removeIf { it.y < 0 || it.life <= 0 }
bulletCooldownMs -= deltaMs
if (isTouching && bulletCooldownMs <= 0) {
val baseCooldown = 450f
var multiplier = 1f
for (level in 2..rapidFireLevel) {
val reduction = ((36 - level * 1.2f).coerceAtLeast(4f)) / 100f
multiplier *= (1f - reduction)
}
bulletCooldownMs = max(50L, (baseCooldown * multiplier).toLong())
val baseY = viewHeight - 100f
val spacing = 10f
val count = multiFireLevel
for (i in 0 until count) {
val offset = (i - (count - 1) / 2f) * spacing
bullets.add(Bullet(playerX + offset, baseY))
}
}
enemies.forEach {
it.update(deltaMs)
it.x = max(20f, min(it.x, viewWidth - 20f))
if (it.y > viewHeight + 100f) it.y = -40f // respawn at top
}
pickups.forEach {
it.y += 2.5f * deltaMs / 16f
it.hue = (it.hue + deltaMs * 0.01f) % 360f
}
pickups.removeIf { it.y > viewHeight - 40f }
rockets.forEach {
val targetAngle = atan2(viewHeight - 100f - it.y, playerX - it.x)
it.angle += ((targetAngle - it.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f
it.x += cos(it.angle) * 6f
it.y += sin(it.angle) * 6f
}
rockets.removeIf { it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight }
enemyBullets.forEach { it.y += it.dy * deltaMs / 16f }
enemyBullets.removeIf { it.y > viewHeight }
updatePlayerMissiles(deltaMs)
checkCollisions()
spawnEnemies(deltaMs)
}
private fun checkCollisions() {
val pickupIter = pickups.iterator()
while (pickupIter.hasNext()) {
val p = pickupIter.next()
if (hypot(playerX - p.x, viewHeight - 100f - p.y) < 40f) {
when (p.type) {
0 -> { // Multi-fire
if (multiFireLevel < 4) {
multiFireLevel++
} else {
applyRandomPowerupExcept(0)
}
}
1 -> shieldLevel++
2 -> if (missileLevel < 8) missileLevel++
3 -> if (rapidFireLevel < 30) rapidFireLevel++
4 -> if (piercingLevel < 15) piercingLevel++
}
pickupIter.remove()
}
}
val enemyIter = enemies.iterator()
while (enemyIter.hasNext()) {
val enemy = enemyIter.next()
if (hypot(playerX - enemy.x, viewHeight - 100f - enemy.y) < 40f) {
if (shieldLevel > 0 && shieldRechargeTimer <= 0) {
shieldRechargeTimer = calculateShieldRechargeTime()
enemyIter.remove()
explosions.add(Explosion(enemy.x, enemy.y))
continue
} else {
explosions.add(Explosion(playerX, viewHeight - 100f))
gameOver = true
return
}
}
for (b in bullets) {
if (hypot(b.x - enemy.x, b.y - enemy.y) < 30f) {
explosions.add(Explosion(enemy.x, enemy.y))
enemyIter.remove()
score += 10
b.life--
if (Random.nextFloat() < 0.03f) {
val type = Random.nextInt(5) // 5 pickup types
val hue = Random.nextFloat() * 360f
pickups.add(Pickup(enemy.x, enemy.y, type, hue))
}
break
}
}
}
val bulletIter = enemyBullets.iterator()
while (bulletIter.hasNext()) {
val b = bulletIter.next()
val dy = b.y - (viewHeight - 100f)
val dx = b.x - playerX
val dist = hypot(dx, dy)
val hitRadius = if (shieldLevel > 0 && shieldRechargeTimer <= 0) 60f else 20f
if (dist < hitRadius) {
if (shieldLevel > 0 && shieldRechargeTimer <= 0) {
shieldRechargeTimer = calculateShieldRechargeTime()
bulletIter.remove()
explosions.add(Explosion(b.x, b.y))
} else {
explosions.add(Explosion(playerX, viewHeight - 100f))
gameOver = true
return
}
}
}
val rocketIter = rockets.iterator()
while (rocketIter.hasNext()) {
val rocket = rocketIter.next()
val dy = viewHeight - 100f - rocket.y
val dx = playerX - rocket.x
val dist = hypot(dx, dy)
val hitRadius = if (shieldLevel > 0 && shieldRechargeTimer <= 0) 60f else 30f
if (dist < hitRadius) {
if (shieldLevel > 0 && shieldRechargeTimer <= 0) {
shieldRechargeTimer = calculateShieldRechargeTime()
rocketIter.remove()
explosions.add(Explosion(rocket.x, rocket.y))
continue
} else {
explosions.add(Explosion(playerX, viewHeight - 100f))
gameOver = true
return
}
}
for (b in bullets) {
if (hypot(b.x - rocket.x, b.y - rocket.y) < 20f) {
explosions.add(Explosion(rocket.x, rocket.y))
rocketIter.remove()
break
}
}
}
val missileIter = playerMissiles.iterator()
while (missileIter.hasNext()) {
val missile = missileIter.next()
val enemyHit = enemies.firstOrNull { enemy ->
hypot(missile.x - enemy.x, missile.y - enemy.y) < 30f
}
if (enemyHit != null) {
explosions.add(Explosion(enemyHit.x, enemyHit.y))
enemies.remove(enemyHit)
missileIter.remove()
score += 10
if (Random.nextFloat() < 0.5f) {
val type = Random.nextInt(5)
val hue = Random.nextFloat() * 360f
pickups.add(Pickup(enemyHit.x, enemyHit.y, type, hue))
}
}
}
}
private fun updatePlayerMissiles(deltaMs: Long) {
// Missile cooldown and launch
missileCooldown -= deltaMs
if (missileLevel > 0 && missileCooldown <= 0) {
val cooldown = when (missileLevel) {
1 -> 20000L
2 -> 15000L
3 -> 12000L
4 -> 20000L
5 -> 18000L
6 -> 17000L
7 -> 16000L
else -> 15000L
}
val baseY = viewHeight - 100f
if (missileLevel >= 4) {
// fire both sides
playerMissiles.add(PlayerMissile(playerX - 20f, baseY, -PI.toFloat() / 2f, side = -1))
playerMissiles.add(PlayerMissile(playerX + 20f, baseY, -PI.toFloat() / 2f, side = 1))
} else {
lastMissileSide *= -1
val offsetX = 20f * lastMissileSide
playerMissiles.add(PlayerMissile(playerX + offsetX, baseY, -PI.toFloat() / 2f, side = lastMissileSide))
}
missileCooldown = cooldown
}
// Update missiles
val lockedEnemies = playerMissiles.mapNotNull { it.target }.toSet()
val availableTargets = enemies.filter { it !in lockedEnemies }
playerMissiles.forEach { missile ->
missile.ttl -= deltaMs
if (missile.ttl <= 0) return@forEach
// Add current position to trail
missile.trail.add(0, missile.x to missile.y)
if (missile.trail.size > 20) {
missile.trail.removeLast()
}
if (missile.target == null || !enemies.contains(missile.target)) {
missile.recheckCooldown -= deltaMs
if (missile.recheckCooldown <= 0) {
val newTarget = availableTargets.minByOrNull {
hypot((it.x - missile.x).toDouble(), (it.y - missile.y).toDouble())
}
if (newTarget != null) {
missile.target = newTarget
} else {
missile.recheckCooldown = 1000L
}
}
}
val target = missile.target
val angleTo = if (target != null) {
atan2(target.y - missile.y, target.x - missile.x)
} else missile.angle
// steer towards target slowly
missile.angle += ((angleTo - missile.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f
missile.x += cos(missile.angle) * 6f
missile.y += sin(missile.angle) * 6f
}
playerMissiles.removeIf { it.ttl <= 0 || it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight }
}
private fun applyRandomPowerupExcept(excludedType: Int) {
val types = (0..4).filter { it != excludedType }
val type = types.random()
when (type) {
1 -> shieldLevel++
2 -> if (missileLevel < 8) missileLevel++
3 -> if (rapidFireLevel < 30) rapidFireLevel++
4 -> if (piercingLevel < 15) piercingLevel++
}
}
private fun createCubeIconPath(): Path {
return Path().apply {
addRect(-10f, -10f, 10f, 10f, Path.Direction.CW)
}
}
private fun calculateShieldRechargeTime(): Long {
val base = 60000L // 60 seconds
val min = 10000L // 10 seconds
val reduction = (1.0 - exp(-shieldLevel / 40.0)).toFloat()
return (base - (base - min) * reduction).toLong()
}
private fun spawnEnemies(deltaMs: Long) {
waveTimer -= deltaMs
adaptiveSpawnTimer += deltaMs
// Passive asteroids
if (Random.nextFloat() < 0.002f) {
enemies.add(EnemyAsteroid(Random.nextFloat() * viewWidth, -40f, viewWidth))
}
// Setup new wave
if (enemiesLeftInWave <= 0 && waveTimer <= 0) {
currentWave++
currentWaveType = when (currentWave % 3) {
0 -> "easy"
1 -> "medium"
else -> "hard"
}
val difficultyBoost = when (currentWaveType) {
"easy" -> (currentWave * 1.2f).roundToInt()
"medium" -> (currentWave * 1.5f).roundToInt()
"hard" -> (currentWave * 1.8f).roundToInt()
else -> currentWave
}
enemiesLeftInWave = 3 + difficultyBoost
waveTimer = 3000L
adaptiveSpawnTimer = 0L
lastEnemyCount = enemies.size
avgEnemiesPerSecond = 0f
enemyClearAggression = 0f
}
// Adjust aggression every 1s
if (adaptiveSpawnTimer >= 1000L) {
val cleared = (lastEnemyCount - enemies.size).coerceAtLeast(0)
avgEnemiesPerSecond = avgEnemiesPerSecond * 0.7f + cleared * 0.3f
lastEnemyCount = enemies.size
adaptiveSpawnTimer = 0L
// Normalize to 01 range (assuming 0 to 6 cleared per second)
enemyClearAggression = (avgEnemiesPerSecond / 6f).coerceIn(0f, 1f)
}
// Spawn enemies in group
if (enemiesLeftInWave > 0 && enemies.count { it !is EnemyAsteroid } < 10) {
val baseX = Random.nextFloat() * (viewWidth - 100f) + 50f
val baseY = -40f
val spacing = 35f
val sharedOffset = Random.nextFloat() * 1000f
val sharedFireTime = Random.nextLong(2000L, 4000L)
// Dynamically adjust formation size
val formationBase = when (currentWaveType) {
"easy" -> 5
"medium" -> 3
"hard" -> 1
else -> 3
}
// Scale formation by aggression (max +2)
val dynamicBonus = (enemyClearAggression * 2).roundToInt()
val formationSize = (formationBase + dynamicBonus).coerceAtMost(enemiesLeftInWave)
for (i in 0 until formationSize) {
val offsetX = (i - (formationSize - 1) / 2f) * spacing
val x = baseX + offsetX
val enemy = when (currentWaveType) {
"easy" -> EnemyEasy(x, baseY)
"medium" -> EnemyMedium(x, baseY, sharedOffset, sharedFireTime) { enemyBullets.add(it) }
"hard" -> {
val hardEnemiesSoFar = enemies.count { it is EnemyHard }
if (hardEnemiesSoFar >= 2) {
EnemyMedium(x, baseY, sharedOffset, sharedFireTime) { enemyBullets.add(it) }
} else {
EnemyHard(x, baseY, { rockets.add(it) }, sharedOffset, sharedFireTime)
}
}
else -> EnemyEasy(x, baseY)
}
enemies.add(enemy)
enemiesLeftInWave--
}
}
}
private fun resetGame() {
bullets.clear()
enemyBullets.clear()
enemies.clear()
rockets.clear()
explosions.clear()
rocketTrails.clear()
playerMissiles.clear()
pickups.clear()
// Reset upgrades
multiFireLevel = 1
piercingLevel = 1
shieldLevel = 0
missileLevel = 0
rapidFireLevel = 1
// Reset gameplay state
score = 0
currentWave = 0
enemiesLeftInWave = 0
gameOver = false
shieldRechargeTimer = 0L
shieldFlashAlpha = 0f
lastMissileSide = -1
missileCooldown = 0L
bulletCooldownMs = 0L
enemyClearAggression = 0f
adaptiveSpawnTimer = 0L
lastEnemyCount = 0
avgEnemiesPerSecond = 0f
playerX = viewWidth / 2f
lastLogicTime = System.nanoTime()
Choreographer.getInstance().postFrameCallback(this)
}
override fun onDraw(canvas: Canvas) {
viewWidth = width.toFloat()
viewHeight = height.toFloat()
canvas.drawColor(Color.parseColor("#121212"))
stars.forEach { canvas.drawCircle(it.x, it.y, it.radius, starPaint) }
val enemyBulletPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
strokeWidth = 6f // same as player bullets
}
enemyBullets.forEach { canvas.drawLine(it.x, it.y, it.x, it.y + 20f, enemyBulletPaint) }
// Enemy rockets
rockets.forEach { rocket ->
rocket.trail.forEachIndexed { index, (x, y) ->
val alpha = ((1f - index / 20f.toFloat()) * 255).toInt()
rocketPaint.alpha = alpha
canvas.drawCircle(x, y, 2f, rocketPaint)
}
}
rocketPaint.alpha = 255
// Player rockets
val missilePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = colorSecondary
style = Paint.Style.STROKE
strokeWidth = 3f
}
playerMissiles.forEach { missile ->
missile.trail.forEachIndexed { index, (x, y) ->
val alpha = ((1f - index / 20f.toFloat()) * 255).toInt()
missilePaint.alpha = alpha
canvas.drawCircle(x, y, 2f, missilePaint)
}
}
missilePaint.alpha = 255
playerMissiles.forEach { missile ->
canvas.drawCircle(missile.x, missile.y, 10f, missilePaint)
}
explosions.forEach {
val radius = 40f * (1f - it.timer / 12f.toFloat())
val alpha = (255 * (it.timer / 12f.toFloat())).toInt()
val paint = Paint().apply {
color = Color.YELLOW
this.alpha = alpha
style = Paint.Style.STROKE
strokeWidth = 3f
}
canvas.drawCircle(it.x, it.y, radius, paint)
}
bullets.forEach { canvas.drawLine(it.x, it.y, it.x, it.y - 20f, bulletPaint) }
enemies.forEach { it.draw(canvas, enemyPaint) }
rockets.forEach { canvas.drawCircle(it.x, it.y, 10f, rocketPaint) }
// Draw pickups
pickups.forEach { pickup ->
// Draw cube (filled rect with outline)
val size = 30f
val left = pickup.x - size / 2f
val top = pickup.y - size / 2f
val right = pickup.x + size / 2f
val bottom = pickup.y + size / 2f
val hsv = floatArrayOf(pickup.hue, 1f, 1f)
val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.HSVToColor(hsv)
style = Paint.Style.FILL
}
val outlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = 2f
}
canvas.drawRect(left, top, right, bottom, fillPaint)
canvas.drawRect(left, top, right, bottom, outlinePaint)
// Draw icon
val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
strokeWidth = 3f
style = Paint.Style.STROKE
}
canvas.save()
canvas.translate(pickup.x, pickup.y)
canvas.rotate(-45f)
when (pickup.type) {
0 -> { // Multi-fire: two parallel lines
canvas.drawLine(-8f, -10f, -8f, 10f, iconPaint)
canvas.drawLine(8f, -10f, 8f, 10f, iconPaint)
}
1 -> { // Shield: quarter circle
val path = Path()
path.addArc(RectF(-10f, -10f, 10f, 10f), -90f, 90f)
canvas.drawPath(path, iconPaint)
}
2 -> { // Missiles: circle + trail
canvas.drawCircle(0f, 0f, 4f, iconPaint)
canvas.drawLine(-6f, 6f, -2f, 2f, iconPaint)
}
3 -> { // Rapid fire: two lines in succession
canvas.drawLine(-5f, -10f, -5f, 10f, iconPaint)
canvas.drawLine(5f, -10f, 5f, 10f, iconPaint)
}
4 -> { // Piercing bullets: arrow head
val path = Path()
path.moveTo(-8f, 10f)
path.lineTo(0f, -10f)
path.lineTo(8f, 10f)
canvas.drawPath(path, iconPaint)
}
}
canvas.restore()
}
if (!gameOver) {
// Draw player
val baseY = viewHeight - 100f
val safeX = max(30f, min(playerX, viewWidth - 30f))
playerX = safeX
val path = Path().apply {
moveTo(safeX, baseY - 30f)
lineTo(safeX - 30f, baseY + 30f)
lineTo(safeX + 30f, baseY + 30f)
close()
}
canvas.drawPath(path, playerPaint)
// Draw shield
if (shieldLevel > 0 && shieldRechargeTimer <= 0) {
val baseColor = colorSecondary
val shieldPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = baseColor
style = Paint.Style.STROKE
strokeWidth = 6f
alpha = (180 + shieldFlashAlpha * 75f).toInt().coerceAtMost(255)
}
canvas.drawCircle(playerX, viewHeight - 100f, 60f, shieldPaint)
}
} else {
canvas.drawText("Game Over", viewWidth / 2f, viewHeight / 2f - 60f, textPaint)
canvas.drawText("Score: $score", viewWidth / 2f, viewHeight / 2f + 10f, textPaint)
canvas.drawText("Tap to restart", retryRect.centerX(), retryRect.centerY() + 16f, retryTextPaint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (gameOver && event.action == MotionEvent.ACTION_DOWN) {
resetGame()
return true
}
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
playerX = max(30f, min(event.x, width - 30f))
isTouching = true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> isTouching = false
}
return true
}
private interface Enemy {
var x: Float
var y: Float
fun update(deltaMs: Long)
fun draw(canvas: Canvas, paint: Paint)
}
private class EnemyAsteroid(
override var x: Float,
override var y: Float,
private val screenWidth: Float
) : Enemy {
private var vx = Random.nextFloat() * 2f - 1f
private val vy = Random.nextFloat() * 2f + 2f
private var angle = Random.nextFloat() * 360f
private val rotationSpeed = Random.nextFloat() * 2f * if (Random.nextBoolean()) 1 else -1
override fun update(deltaMs: Long) {
x += vx * deltaMs / 16f
y += vy * deltaMs / 16f
angle = (angle + rotationSpeed * deltaMs / 16f) % 360f
if (x < 20f || x > screenWidth - 20f) {
vx = -vx
x = max(20f, min(x, screenWidth - 20f))
}
}
override fun draw(canvas: Canvas, paint: Paint) {
canvas.save()
canvas.rotate(angle, x, y)
val path = Path().apply {
moveTo(x - 20f, y)
lineTo(x - 10f, y - 15f)
lineTo(x + 10f, y - 15f)
lineTo(x + 20f, y)
lineTo(x + 10f, y + 15f)
lineTo(x - 10f, y + 15f)
close()
}
canvas.drawPath(path, paint)
canvas.restore()
}
}
private class EnemyEasy(override var x: Float, override var y: Float) : Enemy {
private val offset = Random.nextFloat() * 1000f
override fun update(deltaMs: Long) {
y += 2f * deltaMs / 16f
x += sin((y + offset) / 50f) * 2f
}
override fun draw(canvas: Canvas, paint: Paint) {
val path = Path().apply {
moveTo(x, y - 20f)
lineTo(x + 20f, y)
lineTo(x, y + 20f)
lineTo(x - 20f, y)
close()
}
canvas.drawPath(path, paint)
}
}
private class EnemyMedium(
override var x: Float,
override var y: Float,
private val offset: Float,
private var fireTimer: Long,
val fireEnemyBullet: (Bullet) -> Unit
) : Enemy {
override fun update(deltaMs: Long) {
y += 2.5f * deltaMs / 16f
x += sin((y + offset) / 40f) * 3f
fireTimer -= deltaMs
if (fireTimer <= 0) {
fireEnemyBullet(Bullet(x, y + 30f, dy = 10f))
fireTimer = Random.nextLong(3000L, 6000L)
}
}
override fun draw(canvas: Canvas, paint: Paint) {
val path = Path().apply {
moveTo(x, y - 25f)
lineTo(x + 15f, y)
lineTo(x, y + 25f)
lineTo(x - 15f, y)
close()
}
canvas.drawPath(path, paint)
}
}
private class EnemyHard(
override var x: Float,
override var y: Float,
val fireRocket: (Rocket) -> Unit,
private val offset: Float = Random.nextFloat() * 1000f,
private var fireTimer: Long = Random.nextLong(2000L, 5000L)
) : Enemy {
private var cooldown = 0L
private var firing = false
override fun update(deltaMs: Long) {
if (firing) {
cooldown += deltaMs
if (cooldown > 500 && cooldown < 1300) {
fireRocket(Rocket(x, y - 30f, -PI.toFloat() / 2))
cooldown = 1300
} else if (cooldown > 2000) {
cooldown = 0L
fireTimer = Random.nextLong(15000L, 25000L)
firing = false
}
return
}
y += 3f * deltaMs / 16f
x += sin((y + offset) / 25f) * 4f
fireTimer -= deltaMs
if (fireTimer <= 0) {
firing = true
cooldown = 0L
}
}
override fun draw(canvas: Canvas, paint: Paint) {
val path = Path().apply {
moveTo(x, y - 25f)
lineTo(x + 10f, y - 10f)
lineTo(x + 20f, y + 10f)
lineTo(x, y + 25f)
lineTo(x - 20f, y + 10f)
lineTo(x - 10f, y - 10f)
close()
}
canvas.drawPath(path, paint)
}
}
}

View file

@ -66,8 +66,8 @@ class ServiceAdapter(
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.adapterPosition
val to = target.adapterPosition
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
services.add(to, services.removeAt(from))
notifyItemMoved(from, to)
return true

View file

@ -0,0 +1,96 @@
package partisan.weforge.xyz.pulse
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import partisan.weforge.xyz.pulse.databinding.FragmentServiceSettingsBinding
class ServiceSettingsFragment : Fragment() {
private var _binding: FragmentServiceSettingsBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentServiceSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onResume() {
super.onResume()
(requireActivity() as? MainActivity)?.setAppBarTitle(
getString(R.string.settings_name),
getString(R.string.services_name)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val available = listOf(
ServiceEntry(
"vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call",
R.string.destination_signal,
requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call")
),
ServiceEntry(
"vnd.android.cursor.item/vnd.org.telegram.messenger.android.call",
R.string.destination_telegram,
requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call")
),
ServiceEntry(
"vnd.android.cursor.item/vnd.ch.threema.app.call",
R.string.destination_threema,
requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.ch.threema.app.call")
),
ServiceEntry(
"vnd.android.cursor.item/vnd.com.whatsapp.voip.call",
R.string.destination_whatsapp,
requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.com.whatsapp.voip.call")
),
)
val storedOrder = Preferences(requireContext()).getServiceOrder()
val ordered = storedOrder.mapNotNull { mime ->
available.find { it.mimetype == mime }
}.toMutableList()
// Add any missing services that weren't stored (e.g., first run)
val missing = available.filterNot { s -> ordered.any { it.mimetype == s.mimetype } }
ordered += missing
val prefs = Preferences(requireContext())
val adapter = ServiceAdapter(
context = requireContext(),
services = ordered,
onReordered = { updatedList ->
val newOrder = updatedList.map { it.mimetype }
prefs.setServiceOrder(newOrder)
}
)
binding.serviceRecycler.adapter = adapter
binding.serviceRecycler.layoutManager = LinearLayoutManager(requireContext())
val touchHelper = ItemTouchHelper(adapter.dragHelper)
touchHelper.attachToRecyclerView(binding.serviceRecycler)
adapter.setDragStartListener { viewHolder ->
touchHelper.startDrag(viewHolder)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -0,0 +1,104 @@
package partisan.weforge.xyz.pulse
import android.app.role.RoleManager
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
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 partisan.weforge.xyz.pulse.databinding.ActivityWelcomeBinding
import java.util.concurrent.TimeUnit
import partisan.weforge.xyz.pulse.hasGeneralPermissions
import partisan.weforge.xyz.pulse.hasDrawOverlays
import partisan.weforge.xyz.pulse.hasCallRedirectionRole
import partisan.weforge.xyz.pulse.REQUIRED_PERMISSIONS
class WelcomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityWelcomeBinding
private lateinit var prefs: Preferences
private var roleManager: RoleManager? = null
private val requestPermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {}
private val requestOverlayLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {}
private val requestRoleLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (hasGeneralPermissions(this) && hasDrawOverlays(this) && hasCallRedirectionRole(this)) {
startActivity(Intent(this, MainActivity::class.java))
finish()
return
}
binding = ActivityWelcomeBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = Preferences(this)
roleManager = getSystemService(RoleManager::class.java)
binding.activateButton.setOnClickListener {
when {
!hasGeneralPermissions(this) -> {
requestPermissionsLauncher.launch(REQUIRED_PERMISSIONS)
}
!hasDrawOverlays(this) -> {
requestOverlayLauncher.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION))
}
!hasCallRedirectionRole(this) -> {
requestRoleLauncher.launch(roleManager?.createRequestRoleIntent(RoleManager.ROLE_CALL_REDIRECTION))
}
else -> {
showConfettiAndContinue()
}
}
}
}
private fun showConfettiAndContinue() {
binding.appIcon.post {
val iconLocation = IntArray(2)
binding.appIcon.getLocationOnScreen(iconLocation)
val iconCenterX = iconLocation[0] + binding.appIcon.width / 2f
val iconCenterY = iconLocation[1] + binding.appIcon.height / 2f
val rootWidth = binding.root.width.toFloat()
val rootHeight = binding.root.height.toFloat()
val relativeX = (iconCenterX / rootWidth).toDouble()
val relativeY = (iconCenterY / rootHeight).toDouble()
binding.konfettiView.visibility = View.VISIBLE
binding.konfettiView.start(
Party(
speed = 25f,
maxSpeed = 50f,
damping = 0.9f,
spread = 360,
colors = listOf(0xfce18a, 0xff726d, 0xf4306d, 0xb48def),
position = Position.Relative(relativeX, relativeY),
emitter = Emitter(duration = 1, TimeUnit.SECONDS).perSecond(80)
)
)
binding.konfettiView.postDelayed({
startActivity(Intent(this, MainActivity::class.java))
finish()
}, 1500)
}
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="?attr/colorPrimary"/>
<item android:color="?attr/colorSurfaceContainer"/>
</selector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="?attr/colorOnPrimaryContainer"/>
<item android:color="?attr/colorOnSurfaceVariant"/>
</selector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M440,800L440,496L240,296L240,400L160,400L160,160L400,160L400,240L296,240L520,464L520,800L440,800ZM594,424L536,366L664,240L560,240L560,160L800,160L800,400L720,400L720,296L594,424Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM760,800L760,680Q760,636 735.5,595.5Q711,555 666,526Q717,532 762,546.5Q807,561 846,582Q882,602 901,626.5Q920,651 920,680L920,800L760,800ZM360,480Q294,480 247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480ZM760,320Q760,386 713,433Q666,480 600,480Q589,480 572,477.5Q555,475 544,472Q571,440 585.5,401Q600,362 600,320Q600,278 585.5,239Q571,200 544,168Q558,163 572,161.5Q586,160 600,160Q666,160 713,207Q760,254 760,320ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM360,400Q393,400 416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400ZM360,720L360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.42L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9c0,-2.74 -1.23,-5.18 -3.17,-6.83z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceVariant" />
<corners android:radius="50dp" />
</shape>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M14.17,13.71l1.4,-2.42c0.09,-0.15 0.05,-0.34 -0.08,-0.45l-1.48,-1.16c0.03,-0.22 0.05,-0.45 0.05,-0.68s-0.02,-0.46 -0.05,-0.69l1.48,-1.16c0.13,-0.11 0.17,-0.3 0.08,-0.45l-1.4,-2.42c-0.09,-0.15 -0.27,-0.21 -0.43,-0.15L12,4.83c-0.36,-0.28 -0.75,-0.51 -1.18,-0.69l-0.26,-1.85C10.53,2.13 10.38,2 10.21,2h-2.8C7.24,2 7.09,2.13 7.06,2.3L6.8,4.15C6.38,4.33 5.98,4.56 5.62,4.84l-1.74,-0.7c-0.16,-0.06 -0.34,0 -0.43,0.15l-1.4,2.42C1.96,6.86 2,7.05 2.13,7.16l1.48,1.16C3.58,8.54 3.56,8.77 3.56,9s0.02,0.46 0.05,0.69l-1.48,1.16C2,10.96 1.96,11.15 2.05,11.3l1.4,2.42c0.09,0.15 0.27,0.21 0.43,0.15l1.74,-0.7c0.36,0.28 0.75,0.51 1.18,0.69l0.26,1.85C7.09,15.87 7.24,16 7.41,16h2.8c0.17,0 0.32,-0.13 0.35,-0.3l0.26,-1.85c0.42,-0.18 0.82,-0.41 1.18,-0.69l1.74,0.7C13.9,13.92 14.08,13.86 14.17,13.71zM8.81,11c-1.1,0 -2,-0.9 -2,-2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2C10.81,10.1 9.91,11 8.81,11z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M21.92,18.67l-0.96,-0.74c0.02,-0.14 0.04,-0.29 0.04,-0.44c0,-0.15 -0.01,-0.3 -0.04,-0.44l0.95,-0.74c0.08,-0.07 0.11,-0.19 0.05,-0.29l-0.9,-1.55c-0.05,-0.1 -0.17,-0.13 -0.28,-0.1l-1.11,0.45c-0.23,-0.18 -0.48,-0.33 -0.76,-0.44l-0.17,-1.18C18.73,13.08 18.63,13 18.53,13h-1.79c-0.11,0 -0.21,0.08 -0.22,0.19l-0.17,1.18c-0.27,0.12 -0.53,0.26 -0.76,0.44l-1.11,-0.45c-0.1,-0.04 -0.22,0 -0.28,0.1l-0.9,1.55c-0.05,0.1 -0.04,0.22 0.05,0.29l0.95,0.74c-0.02,0.14 -0.03,0.29 -0.03,0.44c0,0.15 0.01,0.3 0.03,0.44l-0.95,0.74c-0.08,0.07 -0.11,0.19 -0.05,0.29l0.9,1.55c0.05,0.1 0.17,0.13 0.28,0.1l1.11,-0.45c0.23,0.18 0.48,0.33 0.76,0.44l0.17,1.18c0.02,0.11 0.11,0.19 0.22,0.19h1.79c0.11,0 0.21,-0.08 0.22,-0.19l0.17,-1.18c0.27,-0.12 0.53,-0.26 0.75,-0.44l1.12,0.45c0.1,0.04 0.22,0 0.28,-0.1l0.9,-1.55C22.03,18.86 22,18.74 21.92,18.67zM17.63,18.83c-0.74,0 -1.35,-0.6 -1.35,-1.35s0.6,-1.35 1.35,-1.35s1.35,0.6 1.35,1.35S18.37,18.83 17.63,18.83z"/>
</vector>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true">
<shape android:shape="oval">
<solid android:color="?attr/colorPrimary" />
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="?attr/colorSurfaceContainerLowest" />
</shape>
</item>
</selector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="?attr/colorSurfaceContainerLowest" />
<stroke
android:width="2dp"
android:color="?attr/colorOutline"/>
</shape>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880L373,720L160,720Q127,720 103.5,696.5Q80,673 80,640L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L587,720L480,880ZM480,736L544,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640L416,640L480,736ZM480,400L480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Z"/>
</vector>

View file

@ -1,100 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp"
tools:context=".MainActivity">
android:layout_height="match_parent">
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/description"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Main content -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/scrollView"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="16dp"
android:layout_marginVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/description"
app:layout_constraintBottom_toTopOf="@id/toggle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/topAppBar"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
app:titleTextColor="?attr/colorOnSurface"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/app_name"
app:titleTextAppearance="@style/Toolbar.Title.Small"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
</com.google.android.material.appbar.MaterialToolbar>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/topAppBar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.slider.Slider
android:id="@+id/redirectionDelay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stepSize="0.5"
android:valueFrom="2"
android:valueTo="4"
android:contentDescription="@string/redirection_delay_description" />
<TextView
android:id="@+id/description2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/redirection_delay_description"
android:textSize="12sp" />
<Space
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/popupPosition"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:endIconMode="custom"
app:endIconDrawable="@drawable/ic_baseline_check_circle_24"
android:hint="@string/popup_position">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<Space
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="8dp"
android:background="?android:attr/listDivider" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/serviceRecycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:scrollbars="vertical" />
</LinearLayout>
</ScrollView>
<ToggleButton
android:id="@+id/toggle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingVertical="12dp"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Sliding menu -->
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:menu="@menu/main_menu" />
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp">
<LinearLayout
android:id="@+id/contentWrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ImageView
android:id="@+id/appIcon"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginBottom="8dp"
android:contentDescription="@string/app_name"
android:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/appName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/appDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/description"
android:textAlignment="center"
android:textSize="14sp"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/activateButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/activate"
android:layout_marginTop="32dp"
app:cornerRadius="24dp" />
<TextView
android:id="@+id/activateDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/activate_description"
android:textAlignment="center"
android:textSize="14sp"
android:layout_marginTop="16dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<nl.dionsegijn.konfetti.xml.KonfettiView
android:id="@+id/konfettiView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp">
<LinearLayout
android:id="@+id/contentWrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ImageView
android:id="@+id/appIcon"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginBottom="8dp"
android:contentDescription="@string/app_name"
android:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/appName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/appDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/description"
android:textAlignment="center"
android:textSize="14sp"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/sourceButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/source_code"
android:layout_marginTop="24dp"
app:cornerRadius="24dp" />
<Button
android:id="@+id/licenseButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/license"
android:layout_marginTop="8dp"
app:cornerRadius="24dp" />
<Button
android:id="@+id/secretButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Secret"
android:layout_marginTop="16dp"
android:visibility="gone"
app:cornerRadius="24dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<androidx.appcompat.widget.SearchView
android:id="@+id/contactSearch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:background="@drawable/search_background"
android:iconifiedByDefault="false"
android:queryHint="@string/contact_search"
android:layout_marginTop="8dp"
android:layout_marginHorizontal="4dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contactRecycler"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/contactSearch"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:clipToPadding="false"
android:paddingBottom="8dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/donate_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<!-- Intro section -->
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/donate_title"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/descText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/donate_description"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/tokenDisplay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true" />
<TextView
android:id="@+id/reminderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/donate_toast_reminder"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<Button
android:id="@+id/kofiButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate_button"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/postDonatePrompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/donate_post_prompt"
android:textSize="16sp"
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" />
<Button
android:id="@+id/openTokenSection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate_have_token"
android:layout_marginBottom="16dp" />
<!-- Token activation section (initially hidden) -->
<LinearLayout
android:id="@+id/tokenSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/instruction"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/donate_token_instruction"
android:textSize="16sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/donate_token_hint"
android:inputType="text"
android:maxLength="32"
android:layout_marginBottom="12dp"/>
<Button
android:id="@+id/verifyButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate_token_activate" />
<TextView
android:id="@+id/resultText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:layout_marginTop="16dp"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,37 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center">
<nl.dionsegijn.konfetti.xml.KonfettiView
android:id="@+id/confettiView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
<View
android:layout_width="104dp"
android:layout_height="104dp"
android:layout_gravity="center"
android:background="@drawable/toggle_button_bg_outline" />
<com.google.android.material.button.MaterialButton
android:id="@+id/toggle"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="center"
android:checkable="true"
android:insetTop="0dp"
android:insetBottom="0dp"
android:insetLeft="0dp"
android:insetRight="0dp"
android:text=""
app:icon="@drawable/ic_power_settings_new_24"
app:iconTint="@color/toggle_button_icon"
app:backgroundTint="@color/toggle_button_bg"
app:iconSize="48dp"
app:iconGravity="textTop"
app:iconPadding="0dp"
app:cornerRadius="48dp" />
</FrameLayout>

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- Delay label -->
<TextView
android:id="@+id/delayDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/redirection_delay_description"
android:textSize="12sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Delay slider -->
<com.google.android.material.slider.Slider
android:id="@+id/redirectionDelay"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:stepSize="0.5"
android:valueFrom="2"
android:valueTo="4"
android:contentDescription="@string/redirection_delay_description"
app:layout_constraintTop_toBottomOf="@id/delayDescription"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp" />
<!-- Position label -->
<TextView
android:id="@+id/heightDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/popup_position"
android:textSize="12sp"
app:layout_constraintTop_toBottomOf="@id/redirectionDelay"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
<!-- Position slider -->
<com.google.android.material.slider.Slider
android:id="@+id/popupHeightSlider"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:valueFrom="0"
android:valueTo="100"
android:stepSize="1"
android:contentDescription="@string/popup_position"
app:layout_constraintTop_toBottomOf="@id/heightDescription"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp" />
<!-- Animation label -->
<TextView
android:id="@+id/popupEffectLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/popup_effect_label"
android:textSize="12sp"
app:layout_constraintTop_toBottomOf="@id/popupHeightSlider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
<!-- Animation dropdown -->
<Spinner
android:id="@+id/popupEffectSpinner"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/popupEffectLabel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp" />
<!-- Test button -->
<Button
android:id="@+id/popupPreview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/test"
app:layout_constraintTop_toBottomOf="@id/popupEffectSpinner"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- Redirect on WiFi -->
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchRedirectWifi"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/redirect_wifi"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Redirect on Data -->
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchRedirectData"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/redirect_data"
app:layout_constraintTop_toBottomOf="@id/switchRedirectWifi"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
<!-- Redirect only international numbers -->
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchRedirectInternational"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/redirect_international"
app:layout_constraintTop_toBottomOf="@id/switchRedirectData"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
<!-- Redirect only on roaming -->
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchRedirectRoaming"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/redirect_roaming"
app:layout_constraintTop_toBottomOf="@id/switchRedirectInternational"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/serviceRecycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:scrollbars="vertical"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/serviceHeader"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/serviceHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/services_desc"
android:textSize="12sp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/serviceRecycler"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<TextView
android:id="@+id/contactName"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:text="Name" />
<CheckBox
android:id="@+id/contactAllowed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>
</LinearLayout>

View file

@ -5,10 +5,12 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
app:cardCornerRadius="24dp"
app:cardElevation="4dp"
app:cardBackgroundColor="?attr/colorSurface"
android:background="@android:color/white">
android:padding="24dp"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="wrap_content"
@ -20,7 +22,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textAlignment="center"
@ -35,7 +36,6 @@
android:layout_marginBottom="8dp"
android:indeterminate="false"
android:max="100"
style="@android:style/Widget.ProgressBar.Horizontal"
android:progressDrawable="@drawable/progress_drawable"/>
style="@android:style/Widget.ProgressBar.Horizontal"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/secretRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#CC000000">
<partisan.weforge.xyz.pulse.SecretView
android:id="@+id/secretView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.switchmaterial.SwitchMaterial xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/globalPopupToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:checked="true" />

View file

@ -0,0 +1,58 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- Home (main screen toggle) -->
<group android:checkableBehavior="none">
<item
android:id="@+id/action_home"
android:title="@string/home_name"
android:icon="@drawable/ic_power_settings_new_24"
app:showAsAction="never" />
</group>
<!-- Settings section -->
<!-- <item
android:id="@+id/section_settings"
android:title="@string/settings_name"
android:enabled="false" /> -->
<group android:checkableBehavior="none">
<item
android:id="@+id/action_contacts"
android:title="@string/whitelist_name"
android:icon="@drawable/group_24px"
app:showAsAction="never" />
<item
android:id="@+id/action_popup_settings"
android:title="@string/popup_name"
android:icon="@drawable/tooltip_24px"
app:showAsAction="never" />
<item
android:id="@+id/action_services"
android:title="@string/services_name"
android:icon="@drawable/services_24"
app:showAsAction="never" />
<item
android:id="@+id/action_redirect_settings"
android:title="@string/redirect_name"
android:icon="@drawable/call_split_24px"
app:showAsAction="never" />
</group>
<!-- About section -->
<!-- <item
android:id="@+id/section_about"
android:title="@string/about_name"
android:enabled="false" /> -->
<group android:checkableBehavior="none">
<item
android:id="@+id/action_donate"
android:title="Donate"
android:icon="@drawable/heart_24"
app:showAsAction="never" />
<item
android:id="@+id/action_about"
android:title="@string/about_name"
android:icon="@drawable/info_24px"
app:showAsAction="never" />
</group>
</menu>

View file

@ -0,0 +1,7 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/globalPopupToggle"
app:actionLayout="@layout/switch_item"
app:showAsAction="always" />
</menu>

View file

@ -9,5 +9,4 @@
<string name="destination_whatsapp">WhatsApp</string>
<string name="redirection_delay_description">Задержка до того, как звонок будет перенаправлен.</string>
<string name="popup_position">Позиция всплывающего окна</string>
<string name="fallback">Обратная совместимость</string>
</resources>

View file

@ -14,4 +14,6 @@
<color name="colorOnSecondary">#000000</color>
<color name="launcher_background">@color/colorPrimary</color>
<color name="colorSurfaceVariant">#2B3542</color>
</resources>

View file

@ -1,13 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Pulse</string>
<string name="description">The app will try to redirect outgoing calls to Signal/Telegram/Threema/WhatsApp if available. For work it requires many permissions. Click on the toggle and grant permissions until it turns ON.</string>
<string name="description">Redirecting outgoing calls to E2EE apps.</string>
<string name="popup">Redirecting to %1$s</string>
<string name="home_name">Home</string>
<string name="settings_name">Settings</string>
<string name="popup_name">Popup</string>
<string name="services_name">Services</string>
<string name="whitelist_name">Allowlist</string>
<string name="redirect_name">Redirect</string>
<string name="tools_name">Tools</string>
<string name="about_name">About</string>
<string name="donate_name">Donate</string>
<string name="destination_signal">Signal</string>
<string name="destination_telegram">Telegram</string>
<string name="destination_threema">Threema</string>
<string name="destination_whatsapp">WhatsApp</string>
<string name="redirection_delay_description">The delay before a call will be redirected.</string>
<string name="services_desc">Here you can enable or disable redirection to individual services and change their priority by dragging them. Redirection will be handled in order from top to bottom.</string>
<string name="contact_search">Filter contacts</string>
<string name="popup_position">Popup position</string>
<string name="fallback">Fallback</string>
<string name="activate_description">To start, grant the required permissions by tapping the Activate button.</string>
<string name="activate">Activate</string>
<string name="navigation_drawer_open">Open menu</string>
<string name="test">Test</string>
<string name="source_code">Source Code</string>
<string name="license">License</string>
<!-- Redirect Settings -->
<string name="redirect_wifi">Redirect while using Wi-Fi</string>
<string name="redirect_data">Redirect while on mobile data</string>
<string name="redirect_international">Redirect only international numbers</string>
<string name="redirect_roaming">Redirect only if roaming</string>
<!-- Popup Animations -->
<string name="popup_effect_label">Popup Animation</string>
<string-array name="popup_effects">
<item>None</item>
<item>Fade</item>
<item>Slide</item>
<item>Bounce</item>
<item>Flop</item>
<item>Matrix</item>
<item>Slide Snap</item>
<item>Gamer Mode</item>
<item>Random</item>
</string-array>
<!-- 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>
<string name="donate_button">Donate via Ko-fi 💙</string>
<string name="donate_post_prompt">Already donated? Tap below to activate your token.</string>
<string name="donate_have_token">I have a token</string>
<string name="donate_token_instruction">Enter your Ko-fi token:</string>
<string name="donate_token_hint">token: abcd1234efgh5678</string>
<string name="donate_token_activate">Activate Token</string>
<string name="donate_lock">Donate to unlock this effect</string>
<!-- DonateFragment -->
<string name="donate_token_copied">Token copied to clipboard</string>
<string name="donate_token_invalid_format">Invalid token format</string>
<string name="donate_missing_permission">❌ Missing INTERNET permission</string>
<string name="donate_no_internet">❌ No internet access</string>
<string name="donate_server_unreachable">❌ Activation server is unreachable</string>
<string name="donate_server_not_responding">❌ Server not responding</string>
<string name="donate_token_check_failed">❌ Could not check token</string>
<string name="donate_token_invalid">❌ Invalid or expired token</string>
<string name="donate_activation_failed">❌ Activation failed</string>
<string name="donate_token_activated">✅ Token activated!</string>
<string name="donate_token_left">You have %1$s activations left.</string>
<string name="donate_token_already_activated">✅ Already activated</string>
<string name="donate_toast_reminder">Make sure to include your token in the donation message to get rewarded 😊</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="RoundIconShape" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">100%</item>
</style>
<style name="Toolbar.Title.Small" parent="TextAppearance.Widget.AppCompat.Toolbar.Title">
<item name="android:textSize">16sp</item>
</style>
</resources>

View file

@ -8,8 +8,10 @@
<item name="colorSecondary">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<item name="colorSurface">@color/white</item>
<item name="colorSurface">@color/white</item>
<item name="colorOnSurface">@color/black</item>
<item name="textAppearanceBodyMedium">@style/TextAppearance.Material3.BodyMedium</item>
<item name="colorSurfaceContainerLowest">?attr/colorSurfaceContainerLowest</item>
<item name="colorSurfaceVariant">@color/colorSurfaceVariant</item>
</style>
</resources>

View file

@ -1,17 +0,0 @@
package partisan.weforge.xyz.pulse
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

View file

@ -1,2 +1,5 @@
Forked from Red and renamed to Pulse.
Changed Icons and graphic.
v1.3.0
- Forked from Red and renamed to Pulse.
- Changed Icons and graphic.
- Added material you icon.
- Added options to toggle and change priority to individual redirect services.

View file

@ -1 +1,2 @@
Added material you icon.
v1.3.1
- Updated metadata and removed some background Google BLOB to improve compliance with IzzyOnDroid repo.

View file

@ -1 +1,2 @@
Added options to toggle and change priority to individual redirect services.
v1.3.2
- Fixed crash related to redirect popup

View file

@ -1 +1,3 @@
Updated metadata and removed some background Google BLOB to improve compliance with IzzyOnDroid repo.
v1.4.0
- Added progress bar to popup, to better indicate loading
- Updated appstore icon

View file

@ -1 +1,2 @@
Fixed crash related to redirect popup
v1.4.1
- Dependency update

View file

@ -0,0 +1,22 @@
v2.0.0
- Reworked the entire UI
- Added welcome screen to check for required permissions
- New landing page with a single toggle, moved settings to separate menus
- Added side menu with:
- Allowlist
- Redirection: toggle on Wi-Fi, etc.
- Services: toggle individual redirect services and set their priority
- Popup: change position, animation, and duration
- Added About section with:
- Donate
- About the app
Fixes:
- Popup now uses system Material colors
- Fixed issue where service priority changes didn't work
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.

View file

@ -1,15 +1,33 @@
Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp if available.
Redirect calls to Signal, Telegram, Threema, or WhatsApp.
You can cancel redirection by clicking on "Redirecting to.." popup.
---
Permissions:
* `ACCESS_NETWORK_STATE` - check internet is available
* `CALL_PHONE` - make a call via messenger
* `READ_CONTACTS` - check contact has a messenger record
* `SYSTEM_ALERT_WINDOW` - show redirecting popup and launch an activity from background
* `CALL_REDIRECTION` - process outgoing call
**Features:**
- Material You design
- Popup with cancel option
- Extensive settings panel:
- Toggle per-service support
- Redirection only on Wi-Fi/Data
- Allowlist specific contacts
- Change per-service priority
- Customize popup position, animation, and duration
...
All permissions are mandatory.
**Supports:**
- Signal
- Telegram
- Threema
- WhatsApp
It is Free Open Source Software.
License: GPL-3
**Permissions required:**
- `CALL_PHONE` - initiate calls via messenger
- `READ_CONTACTS` - check contact compatibility
- `READ_PHONE_NUMBERS` - detect outgoing call
- `SYSTEM_ALERT_WINDOW` - show popup overlay
- `ACCESS_NETWORK_STATE` - check connectivity
- `INTERNET` - check connectivity and verify donates
Currently all of the permissions are required.
**License:** GPL-3.0
Free and open source

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View file

@ -1 +1 @@
Redirect outgoing calls to Signal/Telegram/Threema/Whatsapp
Redirecting outgoing calls to E2EE apps.

View file

@ -1,17 +0,0 @@
Petite application redirigereant les appels sortants vers Signal/Telegram/Threema/Whatsapp si ils sont
disponibles.
Vous pouvez annuler la redirection en cliquant sur la fenêtre contextuelle "Redirection vers...".
Autorisations:
* `ACCESS_NETWORK_STATE` - Vérifié la disponibilité d\'accès à internet
* `CALL_PHONE` - Passer un appel via messenger
* `READ_CONTACTS` - Vérifier que le contact a un enregistreur de message
* `SYSTEM_ALERT_WINDOW` - Afficher une fenêtre contextuelle de redirection et lancer une activité en
arrière-plan
* `CALL_REDIRECTION` - Traiter les appels sortants
Toutes les autorisations sont obligatoires.
C'est un logiciel libre et gratuit.
Licence : GPL-3

View file

@ -1 +0,0 @@
Rediriger les appels sortants vers Signal/Telegram/Threema/Whatsapp

View file

@ -1 +0,0 @@
Pulse

View file

@ -1,16 +0,0 @@
Мини приложение для перенаправления исходящих вызовов в Signal/Telegram/Threema/Whatsapp.
Вы можете отменить перенаправление, кликнув на всплывающее сообщение "Перенаправление в..".
Разрешения:
* `ACCESS_NETWORK_STATE` - проверить наличие интернета
* `CALL_PHONE` - позвонить через мессенджер
* `READ_CONTACTS - проверить контакт на наличие записи из мессенджера
* `SYSTEM_ALERT_WINDOW` - показать всплывающее сообщение о перенаправлении и запустить активити из
фона
* `CALL_REDIRECTION` - обработать исходящий вызов
Все разрешения обязательны для работы приложения.
Это свободное программное обеспечение с открытым исходным кодом.
Лицензия: GPL-3

View file

@ -1 +0,0 @@
Перенаправление исходящих вызовов в Signal/Telegram/Threema/Whatsapp

View file

@ -1 +0,0 @@
Pulse

Binary file not shown.

View file

@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

49
gradlew vendored
View file

@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -80,13 +82,11 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@ -133,22 +133,29 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -193,18 +200,28 @@ if "$cygwin" || "$msys" ; then
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.

41
gradlew.bat vendored
View file

@ -13,8 +13,10 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%" == "" @echo off
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@ -25,7 +27,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal