diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml
index 452681c..40c841a 100644
--- a/.forgejo/workflows/release.yaml
+++ b/.forgejo/workflows/release.yaml
@@ -1,6 +1,9 @@
name: Android Release Build
on:
+ push:
+ tags:
+ - '*'
workflow_dispatch: {}
jobs:
diff --git a/.gitignore b/.gitignore
index 9a16767..f36e40f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,5 +14,4 @@
.cxx
local.properties
release-key.jks
-check.py
-round.sh
\ No newline at end of file
+check.py
\ No newline at end of file
diff --git a/README.md b/README.md
index 9ac9d9e..225e496 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
-Redirecting outgoing calls to E2EE apps.
+Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.
---
@@ -30,41 +30,14 @@ Redirecting outgoing calls to E2EE apps.
-
+
-
-
-
-
-
-
-
-
-
+
-# 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)
@@ -95,14 +68,13 @@ Install it, and you’re done!
# Permissions
-- `ACCESS_NETWORK_STATE` – check connectivity
-- `CALL_PHONE` – make a call via messenger
-- `READ_CONTACTS` – check if contact has a messenger
-- `READ_PHONE_NUMBERS` – detect outgoing call
-- `SYSTEM_ALERT_WINDOW` – show redirecting popup and launch from background
-- `INTERNET` – check connectivity and verify donates
+* ACCESS_NETWORK_STATE - check 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
-Currently all of the permissions are required.
+All permissions are mandatory.
# License
diff --git a/app/build.gradle b/app/build.gradle
index 6de77a6..0bb1039 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId = "partisan.weforge.xyz.pulse"
minSdk = 29
targetSdk = 34
- versionCode = 14
- versionName = "2.0.0"
+ versionCode = 13
+ versionName = "1.4.1"
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,15 +62,11 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'com.google.android.material:material:1.13.0-alpha13'
+ implementation 'com.google.android.material:material:1.12.0'
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/partisan/weforge/xyz/pulse/ExampleInstrumentedTest.kt b/app/src/androidTest/java/me/lucky/red/ExampleInstrumentedTest.kt
similarity index 100%
rename from app/src/androidTest/java/partisan/weforge/xyz/pulse/ExampleInstrumentedTest.kt
rename to app/src/androidTest/java/me/lucky/red/ExampleInstrumentedTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 486c998..a54dd88 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,9 +4,7 @@
-
-
-
= 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 5eae072..f9d46b4 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt
@@ -4,13 +4,10 @@ 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() {
@@ -61,97 +58,31 @@ class CallRedirectionService : CallRedirectionService() {
initialPhoneAccount: PhoneAccountHandle,
allowInteractiveResponse: Boolean,
) {
- 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")
+ if (!prefs.isEnabled || !hasInternet() || !allowInteractiveResponse) {
placeCallUnmodified()
return
}
val records: Array
try {
- records = getRecordsFromPhoneNumber(phoneNumber)
- Log.d("Redirection", "Found ${records.size} raw records for contact")
+ records = getRecordsFromPhoneNumber(handle.schemeSpecificPart)
} 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
}
- 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()
- }
+ window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return)
}
@RequiresPermission(Manifest.permission.READ_CONTACTS)
@@ -203,36 +134,8 @@ 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 { // This "hasInternet" func is (kinda) re-defined in Donation Fragment
+ private fun hasInternet(): Boolean {
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
deleted file mode 100644
index d581f05..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/ContactAdapter.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-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
deleted file mode 100644
index 798f51a..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/ContactEntry.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 370281d..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-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
deleted file mode 100644
index a8c8152..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt
+++ /dev/null
@@ -1,213 +0,0 @@
-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 77883b6..2658d4c 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt
@@ -1,153 +1,193 @@
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.view.View
-import android.view.Menu
-import android.view.MenuItem
+import android.provider.Settings
+import androidx.activity.result.contract.ActivityResultContracts
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 var popupSwitch: SwitchMaterial? = null
- private var popupMenuItem: MenuItem? = null
+ private val registerForCallRedirectionRole =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
- val popupToggle: SwitchMaterial
- get() = findViewById(R.id.globalPopupToggle)
+ private val registerForGeneralPermissions =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {}
+
+ private val registerForDrawOverlays =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {}
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)
- 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()
- }
+ 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
}
}
- 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
- }
-
- 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)
+ 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")),
+ )
+
+ 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)
+ }
+
+ // 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 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 requestPermissions() {
+ when {
+ !hasGeneralPermissions() -> requestGeneralPermissions()
+ !hasDrawOverlays() -> requestDrawOverlays()
+ !hasCallRedirectionRole() -> requestCallRedirectionRole()
+ }
}
private fun hasPermissions(): Boolean {
- return hasGeneralPermissions(this) &&
- hasDrawOverlays(this) &&
- hasCallRedirectionRole(this)
+ 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
}
}
diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt
deleted file mode 100644
index 9e8c3b4..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index 01b61c4..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/MatrixRainView.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-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
deleted file mode 100644
index 9b9eb78..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-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
deleted file mode 100644
index 5a504d3..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/PopupSettingsFragment.kt
+++ /dev/null
@@ -1,152 +0,0 @@
-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 a2bd984..d5f4aa1 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt
@@ -3,49 +3,30 @@ 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 = DynamicColors.wrapContextIfAvailable(
- ContextThemeWrapper(ctx, R.style.Theme_Pulse)
- )
+ private val themedCtx = 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 inflater = LayoutInflater.from(themedCtx)
- private val view = inflater.inflate(R.layout.popup, null)
+ private val view = LayoutInflater.from(themedCtx).inflate(R.layout.popup, null)
private val layoutParams = WindowManager.LayoutParams().apply {
format = PixelFormat.TRANSLUCENT
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
@@ -55,75 +36,57 @@ class PopupWindow(
height = WindowManager.LayoutParams.WRAP_CONTENT
y = prefs.popupPosition
}
- private var currentEffect: PopupEffect = PopupEffect.NONE
- private var matrixOverlay: View? = null
- private var gamerAnimator: ValueAnimator? = null
- private var timer: Timer? = null
+ private var timer: Timer? = null
init {
view.setOnClickListener {
cancel()
service?.get()?.placeCallUnmodified()
}
-
- // This is utterly stupid, but it works
- applyResolvedColors(view)
}
- fun preview(isLongPress: Boolean = false) {
+ fun preview() {
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()
-
- val duration = if (isLongPress) prefs.redirectionDelay * 5 else prefs.redirectionDelay
- timer?.cancel()
- timer = Timer()
- timer?.schedule(timerTask {
- remove()
- }, duration)
}
fun show(uri: Uri, destinationId: Int) {
- val svc = service?.get() ?: return
-
+ val service = service?.get() ?: return
+ if (!remove()) {
+ service.placeCallUnmodified()
+ 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 (!remove()) {
+ service.placeCallUnmodified()
+ return@timerTask
}
+ 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()
- svc.placeCallUnmodified()
+ service.placeCallUnmodified()
}
}
@@ -144,7 +107,7 @@ class PopupWindow(
}
@RequiresPermission(Manifest.permission.CALL_PHONE)
- fun call(data: Uri) {
+ private fun call(data: Uri) {
Intent(Intent.ACTION_VIEW).apply {
this.data = data
flags = Intent.FLAG_ACTIVITY_NEW_TASK
@@ -152,298 +115,27 @@ 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)
- animateAppear()
- } catch (exc: Exception) {
- Log.e("PopupWindow", "Failed to add popup view", exc)
+ } catch (exc: WindowManager.BadTokenException) {
return false
}
return true
}
- 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
+ private fun remove(): Boolean {
+ try {
+ windowManager?.removeView(view)
+ } catch (_: IllegalArgumentException) {
+ } catch (_: WindowManager.BadTokenException) {
+ return 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 753fdb5..5909a36 100644
--- a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt
+++ b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt
@@ -4,84 +4,24 @@ import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
-class Preferences(private val context: Context) {
-
+class Preferences(ctx: 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(context)
+ private val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
- // 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 isEnabled: Boolean
+ get() = prefs.getBoolean(ENABLED, prefs.getBoolean(SERVICE_ENABLED, false))
+ set(value) = prefs.edit { putBoolean(ENABLED, value) }
var redirectionDelay: Long
get() = prefs.getLong(REDIRECTION_DELAY, DEFAULT_REDIRECTION_DELAY)
@@ -91,87 +31,26 @@ class Preferences(private val context: 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"
- 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 }
- }
-
+ /** Whether this service is enabled */
fun isServiceEnabled(mimetype: String): Boolean {
return prefs.getBoolean(makeKeyEnabled(mimetype), true)
}
+ /** Current priority for this service (lower = higher priority) */
fun getServicePriority(mimetype: String): Int {
- 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()
+ return prefs.getInt(makeKeyPriority(mimetype), Int.MAX_VALUE)
}
+ /** 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
deleted file mode 100644
index 8bf0f55..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/RedirectSettingsFragment.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-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
deleted file mode 100644
index 14ed3db..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index 64efcce..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt
+++ /dev/null
@@ -1,902 +0,0 @@
-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 59edda9..6565c3b 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.bindingAdapterPosition
- val to = target.bindingAdapterPosition
+ val from = viewHolder.adapterPosition
+ val to = target.adapterPosition
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
deleted file mode 100644
index add151a..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/ServicesFragment.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-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
deleted file mode 100644
index e19473b..0000000
--- a/app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-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
deleted file mode 100644
index 1c33f06..0000000
--- a/app/src/main/res/color/toggle_button_bg.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/color/toggle_button_icon.xml b/app/src/main/res/color/toggle_button_icon.xml
deleted file mode 100644
index ae21493..0000000
--- a/app/src/main/res/color/toggle_button_icon.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/call_split_24px.xml b/app/src/main/res/drawable/call_split_24px.xml
deleted file mode 100644
index 0056d80..0000000
--- a/app/src/main/res/drawable/call_split_24px.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/group_24px.xml b/app/src/main/res/drawable/group_24px.xml
deleted file mode 100644
index 556499a..0000000
--- a/app/src/main/res/drawable/group_24px.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/heart_24.xml b/app/src/main/res/drawable/heart_24.xml
deleted file mode 100644
index 3edfe1d..0000000
--- a/app/src/main/res/drawable/heart_24.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/heart_filled_24.xml b/app/src/main/res/drawable/heart_filled_24.xml
deleted file mode 100644
index cee1854..0000000
--- a/app/src/main/res/drawable/heart_filled_24.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
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
deleted file mode 100644
index f134c90..0000000
--- a/app/src/main/res/drawable/ic_power_settings_new_24.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/info_24px.xml b/app/src/main/res/drawable/info_24px.xml
deleted file mode 100644
index 3186ebf..0000000
--- a/app/src/main/res/drawable/info_24px.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml
deleted file mode 100644
index 89c2de3..0000000
--- a/app/src/main/res/drawable/search_background.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/services_24.xml b/app/src/main/res/drawable/services_24.xml
deleted file mode 100644
index 5bbe6bf..0000000
--- a/app/src/main/res/drawable/services_24.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/toggle_button_bg.xml b/app/src/main/res/drawable/toggle_button_bg.xml
deleted file mode 100644
index 4d684f6..0000000
--- a/app/src/main/res/drawable/toggle_button_bg.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
- -
-
-
-
-
- -
-
-
-
-
-
diff --git a/app/src/main/res/drawable/toggle_button_bg_outline.xml b/app/src/main/res/drawable/toggle_button_bg_outline.xml
deleted file mode 100644
index a459ecd..0000000
--- a/app/src/main/res/drawable/toggle_button_bg_outline.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/tooltip_24px.xml b/app/src/main/res/drawable/tooltip_24px.xml
deleted file mode 100644
index 5770d07..0000000
--- a/app/src/main/res/drawable/tooltip_24px.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c0ed222..1ef4824 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,45 +1,100 @@
-
+ android:layout_height="match_parent"
+ android:padding="32dp"
+ tools:context=".MainActivity">
-
-
+
-
-
+
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_welcome.xml b/app/src/main/res/layout/activity_welcome.xml
deleted file mode 100644
index 77183e8..0000000
--- a/app/src/main/res/layout/activity_welcome.xml
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
deleted file mode 100644
index 3e85ab7..0000000
--- a/app/src/main/res/layout/fragment_about.xml
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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
deleted file mode 100644
index f154b17..0000000
--- a/app/src/main/res/layout/fragment_contacts.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_donate.xml b/app/src/main/res/layout/fragment_donate.xml
deleted file mode 100644
index 7efc12e..0000000
--- a/app/src/main/res/layout/fragment_donate.xml
+++ /dev/null
@@ -1,114 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
deleted file mode 100644
index 86b4dee..0000000
--- a/app/src/main/res/layout/fragment_main.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_popup_settings.xml b/app/src/main/res/layout/fragment_popup_settings.xml
deleted file mode 100644
index d9fb419..0000000
--- a/app/src/main/res/layout/fragment_popup_settings.xml
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_redirect_settings.xml b/app/src/main/res/layout/fragment_redirect_settings.xml
deleted file mode 100644
index f9f48ad..0000000
--- a/app/src/main/res/layout/fragment_redirect_settings.xml
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_service_settings.xml b/app/src/main/res/layout/fragment_service_settings.xml
deleted file mode 100644
index 03da59f..0000000
--- a/app/src/main/res/layout/fragment_service_settings.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/item_contact.xml b/app/src/main/res/layout/item_contact.xml
deleted file mode 100644
index d7e3e51..0000000
--- a/app/src/main/res/layout/item_contact.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/popup.xml b/app/src/main/res/layout/popup.xml
index 1d401d5..1b46631 100644
--- a/app/src/main/res/layout/popup.xml
+++ b/app/src/main/res/layout/popup.xml
@@ -5,12 +5,10 @@
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"
- android:padding="24dp"
- app:cardBackgroundColor="?attr/colorSurface">
+ app:cardBackgroundColor="?attr/colorSurface"
+ android:background="@android:color/white">
+ style="@android:style/Widget.ProgressBar.Horizontal"
+ android:progressDrawable="@drawable/progress_drawable"/>
diff --git a/app/src/main/res/layout/secret_overlay.xml b/app/src/main/res/layout/secret_overlay.xml
deleted file mode 100644
index c5d6cf3..0000000
--- a/app/src/main/res/layout/secret_overlay.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/layout/switch_item.xml b/app/src/main/res/layout/switch_item.xml
deleted file mode 100644
index ece0273..0000000
--- a/app/src/main/res/layout/switch_item.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml
deleted file mode 100644
index 6009c93..0000000
--- a/app/src/main/res/menu/main_menu.xml
+++ /dev/null
@@ -1,58 +0,0 @@
-
diff --git a/app/src/main/res/menu/topbar_toggle.xml b/app/src/main/res/menu/topbar_toggle.xml
deleted file mode 100644
index 157a5d9..0000000
--- a/app/src/main/res/menu/topbar_toggle.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index ba94153..4caa5d3 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -9,4 +9,5 @@
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 b338b7d..daa1613 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -14,6 +14,4 @@
#000000
@color/colorPrimary
-
- #2B3542
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2ebeab5..fe77fda 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,75 +1,13 @@
Pulse
- Redirecting outgoing calls to E2EE apps.
+ 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 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
- 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 😊
+ Fallback
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
deleted file mode 100644
index 1464804..0000000
--- a/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 07f34f0..ad74a04 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -8,10 +8,8 @@
- @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
new file mode 100644
index 0000000..f6784ae
--- /dev/null
+++ b/app/src/test/java/me/lucky/red/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000..c4df362
Binary files /dev/null and b/data/screenshot-redirecting.png differ
diff --git a/data/screenshot.png b/data/screenshot.png
new file mode 100644
index 0000000..92a3059
Binary files /dev/null and b/data/screenshot.png differ
diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt
index 82b84fe..1b0de33 100644
--- a/fastlane/metadata/android/en-US/changelogs/10.txt
+++ b/fastlane/metadata/android/en-US/changelogs/10.txt
@@ -1,5 +1,2 @@
-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
+Forked from Red and renamed to Pulse.
+Changed Icons and graphic.
\ 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 d0dba68..062b81d 100644
--- a/fastlane/metadata/android/en-US/changelogs/11.txt
+++ b/fastlane/metadata/android/en-US/changelogs/11.txt
@@ -1,2 +1 @@
-v1.3.1
-- Updated metadata and removed some background Google BLOB to improve compliance with IzzyOnDroid repo.
\ No newline at end of file
+Added material you icon.
\ 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 fe29712..6011cef 100644
--- a/fastlane/metadata/android/en-US/changelogs/12.txt
+++ b/fastlane/metadata/android/en-US/changelogs/12.txt
@@ -1,2 +1 @@
-v1.3.2
-- Fixed crash related to redirect popup
\ No newline at end of file
+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/13.txt b/fastlane/metadata/android/en-US/changelogs/13.txt
index 85677ac..664f914 100644
--- a/fastlane/metadata/android/en-US/changelogs/13.txt
+++ b/fastlane/metadata/android/en-US/changelogs/13.txt
@@ -1,3 +1 @@
-v1.4.0
-- Added progress bar to popup, to better indicate loading
-- Updated appstore icon
\ No newline at end of file
+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/14.txt b/fastlane/metadata/android/en-US/changelogs/14.txt
index c0466cf..01c603f 100644
--- a/fastlane/metadata/android/en-US/changelogs/14.txt
+++ b/fastlane/metadata/android/en-US/changelogs/14.txt
@@ -1,2 +1 @@
-v1.4.1
-- Dependency update
\ No newline at end of file
+Fixed crash related to redirect popup
\ 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
deleted file mode 100644
index 54dbb99..0000000
--- a/fastlane/metadata/android/en-US/changelogs/15.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-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 09bf845..33e0227 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,33 +1,15 @@
-Redirect calls to Signal, Telegram, Threema, or WhatsApp.
+Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp if available.
----
+You can cancel redirection by clicking on "Redirecting to.." popup.
-**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
- ...
+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
-**Supports:**
-- Signal
-- Telegram
-- Threema
-- WhatsApp
+All permissions are mandatory.
-**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
+It is Free Open Source Software.
+License: GPL-3
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png
index 8e29b3c..fd22a85 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 9b7a341..cedd458 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 caf1cf6..4107576 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 5051485..8054120 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
deleted file mode 100644
index e516aef..0000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
deleted file mode 100644
index 4cf3e52..0000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
deleted file mode 100644
index ae3381c..0000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
deleted file mode 100644
index 356da4a..0000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index aa3476a..9a08a39 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Redirecting outgoing calls to E2EE apps.
\ No newline at end of file
+Redirect outgoing calls to Signal/Telegram/Threema/Whatsapp
diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt
new file mode 100644
index 0000000..52007ec
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/full_description.txt
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000..f14d074
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/short_description.txt
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..f56e47b
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/title.txt
@@ -0,0 +1 @@
+Pulse
diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt
new file mode 100644
index 0000000..ac72662
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/full_description.txt
@@ -0,0 +1,16 @@
+Мини приложение для перенаправления исходящих вызовов в 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
new file mode 100644
index 0000000..b8ddc1b
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/short_description.txt
@@ -0,0 +1 @@
+Перенаправление исходящих вызовов в Signal/Telegram/Threema/Whatsapp
diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt
new file mode 100644
index 0000000..f56e47b
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/title.txt
@@ -0,0 +1 @@
+Pulse
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 61c57cf..7454180 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 6925085..cad3db0 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,5 @@
distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
distributionPath=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
+zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index ef07e01..1b6c787 100755
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
#!/bin/sh
#
-# Copyright © 2015 the original authors.
+# Copyright © 2015-2021 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,8 +15,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
-# SPDX-License-Identifier: Apache-2.0
-#
##############################################################################
#
@@ -57,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -82,11 +80,13 @@ do
esac
done
-# This is normally unused
-# shellcheck disable=SC2034
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
-# 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
+
+# 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"'
# 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="\\\"\\\""
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -133,29 +133,22 @@ location of your Java installation."
fi
else
JAVACMD=java
- 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.
+ which java >/dev/null 2>&1 || 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
@@ -200,28 +193,18 @@ if "$cygwin" || "$msys" ; then
done
fi
-
-# 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.
+# 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.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
- -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ org.gradle.wrapper.GradleWrapperMain \
"$@"
-# 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 5eed7ee..ac1b06f 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,10 +13,8 @@
@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
@@ -27,8 +25,7 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%"=="" set DIRNAME=.
-@rem This is normally unused
+if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -43,13 +40,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if %ERRORLEVEL% equ 0 goto execute
+if "%ERRORLEVEL%" == "0" goto execute
-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
+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.
goto fail
@@ -59,34 +56,32 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-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
+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.
goto fail
:execute
@rem Setup the command line
-set CLASSPATH=
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
-"%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" %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
+if "%ERRORLEVEL%"=="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!
-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%
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal