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.

- Main screen + Main screen      - Redirecting popup - -      - - Redirecting popup - -      - - Redirecting popup + Redirecting 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 - - ... - -# 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 @@ - - - - - - - - - - - - - -