diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml
index 40c841a..452681c 100644
--- a/.forgejo/workflows/release.yaml
+++ b/.forgejo/workflows/release.yaml
@@ -1,9 +1,6 @@
name: Android Release Build
on:
- push:
- tags:
- - '*'
workflow_dispatch: {}
jobs:
diff --git a/.gitignore b/.gitignore
index f36e40f..9a16767 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,5 @@
.cxx
local.properties
release-key.jks
-check.py
\ No newline at end of file
+check.py
+round.sh
\ No newline at end of file
diff --git a/README.md b/README.md
index 225e496..9ac9d9e 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
-Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.
+Redirecting outgoing calls to E2EE apps.
---
@@ -30,14 +30,41 @@ Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.
-
+
-
+
+
+
+
+
+
+
+
+
+# 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 you’re 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
diff --git a/app/build.gradle b/app/build.gradle
index 0bb1039..6de77a6 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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
}
diff --git a/app/src/androidTest/java/me/lucky/red/ExampleInstrumentedTest.kt b/app/src/androidTest/java/partisan/weforge/xyz/pulse/ExampleInstrumentedTest.kt
similarity index 100%
rename from app/src/androidTest/java/me/lucky/red/ExampleInstrumentedTest.kt
rename to app/src/androidTest/java/partisan/weforge/xyz/pulse/ExampleInstrumentedTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a54dd88..486c998 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,7 +4,9 @@
+
+
+
= 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
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt
index f9d46b4..5eae072 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt
@@ -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
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) &&
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ContactAdapter.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ContactAdapter.kt
new file mode 100644
index 0000000..d581f05
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/ContactAdapter.kt
@@ -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
+) : RecyclerView.Adapter() {
+
+ 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()
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ContactEntry.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ContactEntry.kt
new file mode 100644
index 0000000..798f51a
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/ContactEntry.kt
@@ -0,0 +1,6 @@
+package partisan.weforge.xyz.pulse
+
+data class ContactEntry(
+ val name: String,
+ val phoneNumber: String
+)
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt
new file mode 100644
index 0000000..370281d
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt
@@ -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.R.id.search_src_text
+ )
+ editText.isFocusable = true
+ editText.isFocusableInTouchMode = true
+ editText.setTextColor(Color.WHITE)
+ editText.setHintTextColor(Color.LTGRAY)
+
+ val searchPlate = searchView.findViewById(androidx.appcompat.R.id.search_plate)
+ searchPlate.setBackgroundColor(Color.TRANSPARENT)
+ searchPlate.setPadding(0, 0, 0, 0)
+ }
+
+ private fun getContacts(): List {
+ 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()
+
+ 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
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt
new file mode 100644
index 0000000..a8c8152
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt
index 2658d4c..77883b6 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt
@@ -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)
}
}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt
new file mode 100644
index 0000000..9e8c3b4
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt
@@ -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(R.id.toggle)
+ val konfetti = view.findViewById(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
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MatrixRainView.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MatrixRainView.kt
new file mode 100644
index 0000000..01b61c4
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/MatrixRainView.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt b/app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt
new file mode 100644
index 0000000..9b9eb78
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt
@@ -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
+}
\ No newline at end of file
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/PopupSettingsFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/PopupSettingsFragment.kt
new file mode 100644
index 0000000..5a504d3
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/PopupSettingsFragment.kt
@@ -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()!!
+ 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
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt
index d5f4aa1..a2bd984 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt
@@ -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?,
) {
- 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(R.id.description)?.setTextColor(onSurface)
+
+ view.findViewById(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)
+ }
+ }
}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt
index 5909a36..753fdb5 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt
@@ -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 {
+ 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 {
+ val stored = prefs.getString(SERVICE_ORDER_KEY, null)
+ return stored?.split("|")?.filter { it.isNotBlank() } ?: emptyList()
+ }
+
+ fun setServiceOrder(order: List) {
+ 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
+ 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
+ }
}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/RedirectSettingsFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/RedirectSettingsFragment.kt
new file mode 100644
index 0000000..8bf0f55
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/RedirectSettingsFragment.kt
@@ -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(R.id.switchRedirectWifi)
+ val switchData = view.findViewById(R.id.switchRedirectData)
+ val switchInternational = view.findViewById(R.id.switchRedirectInternational)
+ val switchRoaming = view.findViewById(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
+ }
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt
new file mode 100644
index 0000000..14ed3db
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt
@@ -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())
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt b/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt
new file mode 100644
index 0000000..64efcce
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt
@@ -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()
+ private val enemyBullets = mutableListOf()
+ private val enemies = mutableListOf()
+ private val rockets = mutableListOf()
+ private val stars = mutableListOf()
+ private val explosions = mutableListOf()
+ private val rocketTrails = mutableListOf>()
+ private val pickups = mutableListOf()
+ private val playerMissiles = mutableListOf()
+
+ 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> = 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> = 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 0–1 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)
+ }
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt
index 6565c3b..59edda9 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt
@@ -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
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ServicesFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ServicesFragment.kt
new file mode 100644
index 0000000..add151a
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/ServicesFragment.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt b/app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt
new file mode 100644
index 0000000..e19473b
--- /dev/null
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/res/color/toggle_button_bg.xml b/app/src/main/res/color/toggle_button_bg.xml
new file mode 100644
index 0000000..1c33f06
--- /dev/null
+++ b/app/src/main/res/color/toggle_button_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/color/toggle_button_icon.xml b/app/src/main/res/color/toggle_button_icon.xml
new file mode 100644
index 0000000..ae21493
--- /dev/null
+++ b/app/src/main/res/color/toggle_button_icon.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/call_split_24px.xml b/app/src/main/res/drawable/call_split_24px.xml
new file mode 100644
index 0000000..0056d80
--- /dev/null
+++ b/app/src/main/res/drawable/call_split_24px.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/group_24px.xml b/app/src/main/res/drawable/group_24px.xml
new file mode 100644
index 0000000..556499a
--- /dev/null
+++ b/app/src/main/res/drawable/group_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/heart_24.xml b/app/src/main/res/drawable/heart_24.xml
new file mode 100644
index 0000000..3edfe1d
--- /dev/null
+++ b/app/src/main/res/drawable/heart_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/heart_filled_24.xml b/app/src/main/res/drawable/heart_filled_24.xml
new file mode 100644
index 0000000..cee1854
--- /dev/null
+++ b/app/src/main/res/drawable/heart_filled_24.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_power_settings_new_24.xml b/app/src/main/res/drawable/ic_power_settings_new_24.xml
new file mode 100644
index 0000000..f134c90
--- /dev/null
+++ b/app/src/main/res/drawable/ic_power_settings_new_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/info_24px.xml b/app/src/main/res/drawable/info_24px.xml
new file mode 100644
index 0000000..3186ebf
--- /dev/null
+++ b/app/src/main/res/drawable/info_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml
new file mode 100644
index 0000000..89c2de3
--- /dev/null
+++ b/app/src/main/res/drawable/search_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/services_24.xml b/app/src/main/res/drawable/services_24.xml
new file mode 100644
index 0000000..5bbe6bf
--- /dev/null
+++ b/app/src/main/res/drawable/services_24.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/toggle_button_bg.xml b/app/src/main/res/drawable/toggle_button_bg.xml
new file mode 100644
index 0000000..4d684f6
--- /dev/null
+++ b/app/src/main/res/drawable/toggle_button_bg.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/toggle_button_bg_outline.xml b/app/src/main/res/drawable/toggle_button_bg_outline.xml
new file mode 100644
index 0000000..a459ecd
--- /dev/null
+++ b/app/src/main/res/drawable/toggle_button_bg_outline.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/tooltip_24px.xml b/app/src/main/res/drawable/tooltip_24px.xml
new file mode 100644
index 0000000..5770d07
--- /dev/null
+++ b/app/src/main/res/drawable/tooltip_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 1ef4824..c0ed222 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,100 +1,45 @@
-
+ android:layout_height="match_parent">
-
+
+
-
+
+
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/app/src/main/res/layout/activity_welcome.xml b/app/src/main/res/layout/activity_welcome.xml
new file mode 100644
index 0000000..77183e8
--- /dev/null
+++ b/app/src/main/res/layout/activity_welcome.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
new file mode 100644
index 0000000..3e85ab7
--- /dev/null
+++ b/app/src/main/res/layout/fragment_about.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_contacts.xml b/app/src/main/res/layout/fragment_contacts.xml
new file mode 100644
index 0000000..f154b17
--- /dev/null
+++ b/app/src/main/res/layout/fragment_contacts.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_donate.xml b/app/src/main/res/layout/fragment_donate.xml
new file mode 100644
index 0000000..7efc12e
--- /dev/null
+++ b/app/src/main/res/layout/fragment_donate.xml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
new file mode 100644
index 0000000..86b4dee
--- /dev/null
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_popup_settings.xml b/app/src/main/res/layout/fragment_popup_settings.xml
new file mode 100644
index 0000000..d9fb419
--- /dev/null
+++ b/app/src/main/res/layout/fragment_popup_settings.xml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_redirect_settings.xml b/app/src/main/res/layout/fragment_redirect_settings.xml
new file mode 100644
index 0000000..f9f48ad
--- /dev/null
+++ b/app/src/main/res/layout/fragment_redirect_settings.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_service_settings.xml b/app/src/main/res/layout/fragment_service_settings.xml
new file mode 100644
index 0000000..03da59f
--- /dev/null
+++ b/app/src/main/res/layout/fragment_service_settings.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_contact.xml b/app/src/main/res/layout/item_contact.xml
new file mode 100644
index 0000000..d7e3e51
--- /dev/null
+++ b/app/src/main/res/layout/item_contact.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/popup.xml b/app/src/main/res/layout/popup.xml
index 1b46631..1d401d5 100644
--- a/app/src/main/res/layout/popup.xml
+++ b/app/src/main/res/layout/popup.xml
@@ -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">
+ style="@android:style/Widget.ProgressBar.Horizontal"/>
diff --git a/app/src/main/res/layout/secret_overlay.xml b/app/src/main/res/layout/secret_overlay.xml
new file mode 100644
index 0000000..c5d6cf3
--- /dev/null
+++ b/app/src/main/res/layout/secret_overlay.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/switch_item.xml b/app/src/main/res/layout/switch_item.xml
new file mode 100644
index 0000000..ece0273
--- /dev/null
+++ b/app/src/main/res/layout/switch_item.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml
new file mode 100644
index 0000000..6009c93
--- /dev/null
+++ b/app/src/main/res/menu/main_menu.xml
@@ -0,0 +1,58 @@
+
diff --git a/app/src/main/res/menu/topbar_toggle.xml b/app/src/main/res/menu/topbar_toggle.xml
new file mode 100644
index 0000000..157a5d9
--- /dev/null
+++ b/app/src/main/res/menu/topbar_toggle.xml
@@ -0,0 +1,7 @@
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 4caa5d3..ba94153 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -9,5 +9,4 @@
WhatsApp
Задержка до того, как звонок будет перенаправлен.
Позиция всплывающего окна
- Обратная совместимость
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index daa1613..b338b7d 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -14,4 +14,6 @@
#000000
@color/colorPrimary
+
+ #2B3542
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fe77fda..2ebeab5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,13 +1,75 @@
Pulse
- 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.
+ Redirecting outgoing calls to E2EE apps.
Redirecting to %1$s
+ Home
+ Settings
+ Popup
+ Services
+ Allowlist
+ Redirect
+ Tools
+ About
+ Donate
Signal
Telegram
Threema
WhatsApp
The delay before a call will be redirected.
+ 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.
+ Filter contacts
Popup position
- Fallback
+ To start, grant the required permissions by tapping the Activate button.
+ Activate
+ Open menu
+ Test
+ Source Code
+ License
+
+
+ Redirect while using Wi-Fi
+ Redirect while on mobile data
+ Redirect only international numbers
+ Redirect only if roaming
+
+
+ Popup Animation
+
+ - None
+ - Fade
+ - Slide
+ - Bounce
+ - Flop
+ - Matrix
+ - Slide Snap
+ - Gamer Mode
+ - Random
+
+
+
+ Support Pulse Development 💖
+ Pulse is free and open-source. You can support future development through Ko-fi. As a thank-you, donors get special popup animation effects!
+ Donate via Ko-fi 💙
+ Already donated? Tap below to activate your token.
+ I have a token
+ Enter your Ko-fi token:
+ token: abcd1234efgh5678
+ Activate Token
+ Donate to unlock this effect
+
+
+ Token copied to clipboard
+ Invalid token format
+ ❌ Missing INTERNET permission
+ ❌ No internet access
+ ❌ Activation server is unreachable
+ ❌ Server not responding
+ ❌ Could not check token
+ ❌ Invalid or expired token
+ ❌ Activation failed
+ ✅ Token activated!
+ You have %1$s activations left.
+ ✅ Already activated
+ Make sure to include your token in the donation message to get rewarded 😊
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..1464804
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index ad74a04..07f34f0 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -8,8 +8,10 @@
- @color/teal_200
- @color/black
- - @color/white
+ - @color/white
- @color/black
- @style/TextAppearance.Material3.BodyMedium
+ - ?attr/colorSurfaceContainerLowest
+ - @color/colorSurfaceVariant
\ No newline at end of file
diff --git a/app/src/test/java/me/lucky/red/ExampleUnitTest.kt b/app/src/test/java/me/lucky/red/ExampleUnitTest.kt
deleted file mode 100644
index f6784ae..0000000
--- a/app/src/test/java/me/lucky/red/ExampleUnitTest.kt
+++ /dev/null
@@ -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)
- }
-}
\ No newline at end of file
diff --git a/data/screenshot-redirecting.png b/data/screenshot-redirecting.png
deleted file mode 100644
index c4df362..0000000
Binary files a/data/screenshot-redirecting.png and /dev/null differ
diff --git a/data/screenshot.png b/data/screenshot.png
deleted file mode 100644
index 92a3059..0000000
Binary files a/data/screenshot.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt
index 1b0de33..82b84fe 100644
--- a/fastlane/metadata/android/en-US/changelogs/10.txt
+++ b/fastlane/metadata/android/en-US/changelogs/10.txt
@@ -1,2 +1,5 @@
-Forked from Red and renamed to Pulse.
-Changed Icons and graphic.
\ No newline at end of file
+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.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt
index 062b81d..d0dba68 100644
--- a/fastlane/metadata/android/en-US/changelogs/11.txt
+++ b/fastlane/metadata/android/en-US/changelogs/11.txt
@@ -1 +1,2 @@
-Added material you icon.
\ No newline at end of file
+v1.3.1
+- Updated metadata and removed some background Google BLOB to improve compliance with IzzyOnDroid repo.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/12.txt b/fastlane/metadata/android/en-US/changelogs/12.txt
index 6011cef..fe29712 100644
--- a/fastlane/metadata/android/en-US/changelogs/12.txt
+++ b/fastlane/metadata/android/en-US/changelogs/12.txt
@@ -1 +1,2 @@
-Added options to toggle and change priority to individual redirect services.
\ No newline at end of file
+v1.3.2
+- Fixed crash related to redirect popup
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/13.txt b/fastlane/metadata/android/en-US/changelogs/13.txt
index 664f914..85677ac 100644
--- a/fastlane/metadata/android/en-US/changelogs/13.txt
+++ b/fastlane/metadata/android/en-US/changelogs/13.txt
@@ -1 +1,3 @@
-Updated metadata and removed some background Google BLOB to improve compliance with IzzyOnDroid repo.
\ No newline at end of file
+v1.4.0
+- Added progress bar to popup, to better indicate loading
+- Updated appstore icon
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/14.txt b/fastlane/metadata/android/en-US/changelogs/14.txt
index 01c603f..c0466cf 100644
--- a/fastlane/metadata/android/en-US/changelogs/14.txt
+++ b/fastlane/metadata/android/en-US/changelogs/14.txt
@@ -1 +1,2 @@
-Fixed crash related to redirect popup
\ No newline at end of file
+v1.4.1
+- Dependency update
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/15.txt b/fastlane/metadata/android/en-US/changelogs/15.txt
new file mode 100644
index 0000000..54dbb99
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/15.txt
@@ -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.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 33e0227..09bf845 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -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
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png
index fd22a85..8e29b3c 100644
Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
index cedd458..9b7a341 100644
Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
index 4107576..caf1cf6 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
index 8054120..5051485 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
new file mode 100644
index 0000000..e516aef
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
new file mode 100644
index 0000000..4cf3e52
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
new file mode 100644
index 0000000..ae3381c
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
new file mode 100644
index 0000000..356da4a
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index 9a08a39..aa3476a 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Redirect outgoing calls to Signal/Telegram/Threema/Whatsapp
+Redirecting outgoing calls to E2EE apps.
\ No newline at end of file
diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt
deleted file mode 100644
index 52007ec..0000000
--- a/fastlane/metadata/android/fr-FR/full_description.txt
+++ /dev/null
@@ -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
diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt
deleted file mode 100644
index f14d074..0000000
--- a/fastlane/metadata/android/fr-FR/short_description.txt
+++ /dev/null
@@ -1 +0,0 @@
-Rediriger les appels sortants vers Signal/Telegram/Threema/Whatsapp
diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt
deleted file mode 100644
index f56e47b..0000000
--- a/fastlane/metadata/android/fr-FR/title.txt
+++ /dev/null
@@ -1 +0,0 @@
-Pulse
diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt
deleted file mode 100644
index ac72662..0000000
--- a/fastlane/metadata/android/ru-RU/full_description.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Мини приложение для перенаправления исходящих вызовов в Signal/Telegram/Threema/Whatsapp.
-
-Вы можете отменить перенаправление, кликнув на всплывающее сообщение "Перенаправление в..".
-
-Разрешения:
-* `ACCESS_NETWORK_STATE` - проверить наличие интернета
-* `CALL_PHONE` - позвонить через мессенджер
-* `READ_CONTACTS - проверить контакт на наличие записи из мессенджера
-* `SYSTEM_ALERT_WINDOW` - показать всплывающее сообщение о перенаправлении и запустить активити из
-фона
-* `CALL_REDIRECTION` - обработать исходящий вызов
-
-Все разрешения обязательны для работы приложения.
-
-Это свободное программное обеспечение с открытым исходным кодом.
-Лицензия: GPL-3
diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt
deleted file mode 100644
index b8ddc1b..0000000
--- a/fastlane/metadata/android/ru-RU/short_description.txt
+++ /dev/null
@@ -1 +0,0 @@
-Перенаправление исходящих вызовов в Signal/Telegram/Threema/Whatsapp
diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt
deleted file mode 100644
index f56e47b..0000000
--- a/fastlane/metadata/android/ru-RU/title.txt
+++ /dev/null
@@ -1 +0,0 @@
-Pulse
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 7454180..61c57cf 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index cad3db0..6925085 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/gradlew b/gradlew
index 1b6c787..ef07e01 100755
--- a/gradlew
+++ b/gradlew
@@ -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.
diff --git a/gradlew.bat b/gradlew.bat
index ac1b06f..5eed7ee 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -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