diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index 40c841a..452681c 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -1,9 +1,6 @@ name: Android Release Build on: - push: - tags: - - '*' workflow_dispatch: {} jobs: diff --git a/.gitignore b/.gitignore index f36e40f..9a16767 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ .cxx local.properties release-key.jks -check.py \ No newline at end of file +check.py +round.sh \ No newline at end of file diff --git a/README.md b/README.md index 225e496..9ac9d9e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

-Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp. +Redirecting outgoing calls to E2EE apps.

--- @@ -30,14 +30,41 @@ Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.

- 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) @@ -68,13 +95,14 @@ Install it, and you’re done! # Permissions -* ACCESS_NETWORK_STATE - check internet is available -* CALL_PHONE - make a call via messenger -* READ_CONTACTS - check contact has a messenger record -* SYSTEM_ALERT_WINDOW - show redirecting popup and launch an activity from background -* CALL_REDIRECTION - process outgoing call +- `ACCESS_NETWORK_STATE` – check connectivity +- `CALL_PHONE` – make a call via messenger +- `READ_CONTACTS` – check if contact has a messenger +- `READ_PHONE_NUMBERS` – detect outgoing call +- `SYSTEM_ALERT_WINDOW` – show redirecting popup and launch from background +- `INTERNET` – check connectivity and verify donates -All permissions are mandatory. +Currently all of the permissions are required. # License diff --git a/app/build.gradle b/app/build.gradle index 0bb1039..6de77a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId = "partisan.weforge.xyz.pulse" minSdk = 29 targetSdk = 34 - versionCode = 13 - versionName = "1.4.1" + versionCode = 14 + versionName = "2.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -29,7 +29,7 @@ android { buildTypes { release { minifyEnabled = false - signingConfig signingConfigs.release + signingConfig = signingConfigs.release proguardFiles(getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro') } } @@ -62,11 +62,15 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.12.0' + implementation 'com.google.android.material:material:1.13.0-alpha13' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.cardview:cardview:1.0.0' + implementation "androidx.browser:browser:1.7.0" + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.32' + implementation 'nl.dionsegijn:konfetti-xml:2.0.2' // This library holds the fabric of reality together please dont remove it at any costs >:3 } diff --git a/app/src/androidTest/java/me/lucky/red/ExampleInstrumentedTest.kt b/app/src/androidTest/java/partisan/weforge/xyz/pulse/ExampleInstrumentedTest.kt similarity index 100% rename from app/src/androidTest/java/me/lucky/red/ExampleInstrumentedTest.kt rename to app/src/androidTest/java/partisan/weforge/xyz/pulse/ExampleInstrumentedTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a54dd88..486c998 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,9 @@ + + + = 5) { + binding.secretButton.visibility = View.VISIBLE + } + } + + binding.secretButton.setOnClickListener { + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, SecretFragment()) + .addToBackStack(null) + .commit() + } + } + + private fun openUrl(url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt index f9d46b4..5eae072 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt @@ -4,10 +4,13 @@ import android.Manifest import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.Uri +import android.util.Log import android.provider.ContactsContract import android.telecom.CallRedirectionService import android.telecom.PhoneAccountHandle +import android.telephony.TelephonyManager import androidx.annotation.RequiresPermission +import com.google.i18n.phonenumbers.PhoneNumberUtil import java.lang.ref.WeakReference class CallRedirectionService : CallRedirectionService() { @@ -58,31 +61,97 @@ class CallRedirectionService : CallRedirectionService() { initialPhoneAccount: PhoneAccountHandle, allowInteractiveResponse: Boolean, ) { - if (!prefs.isEnabled || !hasInternet() || !allowInteractiveResponse) { + Log.d("Redirection", "onPlaceCall triggered: uri=$handle, interactive=$allowInteractiveResponse") + + val capabilities = connectivityManager + ?.getNetworkCapabilities(connectivityManager?.activeNetwork) + + val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + val isCellular = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true + Log.d("Redirection", "isWifi=$isWifi, isCellular=$isCellular") + + val shouldRedirect = when { + isWifi && !prefs.redirectOnWifi -> false + isCellular && !prefs.redirectOnData -> false + else -> true + } + + if (!prefs.isEnabled) { + Log.d("Redirection", "Aborting: redirection disabled in prefs") + placeCallUnmodified() + return + } + + if (!shouldRedirect) { + Log.d("Redirection", "Aborting: redirection blocked by current network preference") + placeCallUnmodified() + return + } + + if (!hasInternet()) { + Log.d("Redirection", "Aborting: no internet connection detected") + placeCallUnmodified() + return + } + + if (!allowInteractiveResponse) { + Log.d("Redirection", "Aborting: interactive response not allowed by system") + placeCallUnmodified() + return + } + + if (prefs.redirectIfRoaming && !isOutsideHomeCountry()) { + Log.d("Redirection", "Aborting: redirect only while roaming, but we're inside home country") + placeCallUnmodified() + return + } + + val phoneNumber = handle.schemeSpecificPart + Log.d("Redirection", "Resolved phone number: $phoneNumber") + + if (prefs.redirectInternationalOnly && !isInternationalNumber(phoneNumber)) { + Log.d("Redirection", "Aborting: number is not international and pref requires it") + placeCallUnmodified() + return + } + + if (prefs.isBlacklistEnabled && !prefs.isContactWhitelisted(phoneNumber)) { + Log.d("Redirection", "Aborting: number is not in whitelist while blacklist is enabled") placeCallUnmodified() return } val records: Array try { - records = getRecordsFromPhoneNumber(handle.schemeSpecificPart) + records = getRecordsFromPhoneNumber(phoneNumber) + Log.d("Redirection", "Found ${records.size} raw records for contact") } catch (exc: SecurityException) { + Log.w("Redirection", "SecurityException during record fetch", exc) placeCallUnmodified() return } - // Filter to enabled services only val enabledRecords = records .filter { prefs.isServiceEnabled(it.mimetype) } .sortedBy { prefs.getServicePriority(it.mimetype) } + Log.d("Redirection", "Filtered to ${enabledRecords.size} enabled records") + val record = enabledRecords.firstOrNull() if (record == null) { + Log.d("Redirection", "Aborting: no suitable record found for redirection") placeCallUnmodified() return } - window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return) + Log.d("Redirection", "Redirecting call to: ${record.mimetype} → ${record.uri}") + + if (prefs.popupEnabled) { + window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return) + } else { + window.call(record.uri) + cancelCall() + } } @RequiresPermission(Manifest.permission.READ_CONTACTS) @@ -134,8 +203,36 @@ class CallRedirectionService : CallRedirectionService() { return results.toTypedArray() } + private fun isInternationalNumber(phoneNumber: String): Boolean { + val telephony = getSystemService(TelephonyManager::class.java) ?: return true + val simCountryIso = telephony.simCountryIso?.lowercase() ?: return true + + // Use libphonenumber to parse the number and get region + val util = PhoneNumberUtil.getInstance() + return try { + val numberProto = util.parse(phoneNumber, simCountryIso.uppercase()) + val numberRegion = util.getRegionCodeForNumber(numberProto)?.lowercase() + numberRegion != simCountryIso + } catch (e: Exception) { + true // treat as international if parsing fails + } + } + + fun isOutsideHomeCountry(): Boolean { + val telephony = getSystemService(TelephonyManager::class.java) ?: return true + + val simCountry = telephony.simCountryIso?.lowercase() + val networkCountry = telephony.networkCountryIso?.lowercase() + + // If SIM or network country can't be determined, assume we're abroad + if (simCountry.isNullOrBlank() || networkCountry.isNullOrBlank()) return true + + // If they don't match, you're abroad + return simCountry != networkCountry + } + @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE) - private fun hasInternet(): Boolean { + private fun hasInternet(): Boolean { // This "hasInternet" func is (kinda) re-defined in Donation Fragment val capabilities = connectivityManager ?.getNetworkCapabilities(connectivityManager?.activeNetwork) ?: return false return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ContactAdapter.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ContactAdapter.kt new file mode 100644 index 0000000..d581f05 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/ContactAdapter.kt @@ -0,0 +1,49 @@ +package partisan.weforge.xyz.pulse + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class ContactAdapter( + private val prefs: Preferences, + private val fullList: List +) : RecyclerView.Adapter() { + + private var filteredList = fullList.toMutableList() + + inner class ViewHolder(inflater: LayoutInflater, parent: ViewGroup) : + RecyclerView.ViewHolder(inflater.inflate(R.layout.item_contact, parent, false)) { + val contactName: TextView = itemView.findViewById(R.id.contactName) + val contactAllowed: CheckBox = itemView.findViewById(R.id.contactAllowed) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(LayoutInflater.from(parent.context), parent) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val contact = filteredList[position] + holder.contactName.text = contact.name + holder.contactAllowed.setOnCheckedChangeListener(null) + holder.contactAllowed.isChecked = prefs.isContactWhitelisted(contact.phoneNumber) + + holder.contactAllowed.setOnCheckedChangeListener { _, isChecked -> + prefs.setContactWhitelisted(contact.phoneNumber, isChecked) + } + } + + override fun getItemCount(): Int = filteredList.size + + fun filter(query: String) { + filteredList = if (query.isBlank()) { + fullList.toMutableList() + } else { + fullList.filter { + it.name.contains(query, ignoreCase = true) + }.toMutableList() + } + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ContactEntry.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ContactEntry.kt new file mode 100644 index 0000000..798f51a --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/ContactEntry.kt @@ -0,0 +1,6 @@ +package partisan.weforge.xyz.pulse + +data class ContactEntry( + val name: String, + val phoneNumber: String +) diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt new file mode 100644 index 0000000..370281d --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt @@ -0,0 +1,126 @@ +package partisan.weforge.xyz.pulse + +import android.content.ContentResolver +import android.os.Bundle +import android.provider.ContactsContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.graphics.Color +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import partisan.weforge.xyz.pulse.databinding.FragmentContactsBinding + +class ContactsFragment : Fragment() { + + private var _binding: FragmentContactsBinding? = null + private val binding get() = _binding!! + + private lateinit var prefs: Preferences + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentContactsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onResume() { + super.onResume() + (requireActivity() as? MainActivity)?.apply { + setAppBarTitle(getString(R.string.settings_name), getString(R.string.whitelist_name)) + setupPopupToggle(true, prefs.isBlacklistEnabled) { isChecked -> + prefs.isBlacklistEnabled = isChecked + binding.contactRecycler.isEnabled = isChecked + binding.contactRecycler.alpha = if (isChecked) 1f else 0.4f + } + } + + // Initial state + binding.contactRecycler.isEnabled = prefs.isBlacklistEnabled + binding.contactRecycler.alpha = if (prefs.isBlacklistEnabled) 1f else 0.4f + } + + override fun onPause() { + super.onPause() + (requireActivity() as? MainActivity)?.setupPopupToggle(false) + } + + private lateinit var adapter: ContactAdapter + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + prefs = Preferences(requireContext()) + + val contacts = getContacts() + adapter = ContactAdapter(prefs, contacts) + + binding.contactRecycler.layoutManager = LinearLayoutManager(requireContext()) + binding.contactRecycler.adapter = adapter + + binding.contactSearch.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean = false + override fun onQueryTextChange(newText: String?): Boolean { + adapter.filter(newText ?: "") + return true + } + }) + + val searchView = binding.contactSearch + + searchView.setIconifiedByDefault(false) + searchView.isIconified = false + searchView.isSubmitButtonEnabled = false + searchView.clearFocus() + + val editText = searchView.findViewById( + androidx.appcompat.R.id.search_src_text + ) + editText.isFocusable = true + editText.isFocusableInTouchMode = true + editText.setTextColor(Color.WHITE) + editText.setHintTextColor(Color.LTGRAY) + + val searchPlate = searchView.findViewById(androidx.appcompat.R.id.search_plate) + searchPlate.setBackgroundColor(Color.TRANSPARENT) + searchPlate.setPadding(0, 0, 0, 0) + } + + private fun getContacts(): List { + val resolver: ContentResolver = requireContext().contentResolver + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER + ) + + val cursor = resolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + null, + null, + "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC" + ) + + val results = mutableListOf() + + cursor?.use { + val nameIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) + val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + + while (it.moveToNext()) { + val name = it.getString(nameIndex) ?: continue + val number = it.getString(numberIndex) ?: continue + results.add(ContactEntry(name, number)) + } + } + + return results + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt new file mode 100644 index 0000000..a8c8152 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt @@ -0,0 +1,213 @@ +package partisan.weforge.xyz.pulse + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.browser.customtabs.CustomTabsIntent +import androidx.fragment.app.Fragment +import okhttp3.* +import partisan.weforge.xyz.pulse.databinding.FragmentDonateBinding +import java.io.IOException + + +class DonateFragment : Fragment() { + + private val client = OkHttpClient() + private val apiBase = "https://api.weforge.xyz/api" + private var _binding: FragmentDonateBinding? = null + private val binding get() = _binding!! + private lateinit var prefs: Preferences + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentDonateBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onResume() { + super.onResume() + (requireActivity() as? MainActivity)?.setAppBarTitle( + getString(R.string.about_name), getString(R.string.donate_name) + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + prefs = Preferences(requireContext()) + + // Pre-fill token field + binding.tokenInput.setText(prefs.donationToken) + + // Show toast and open Ko-fi + binding.kofiButton.setOnClickListener { + Toast.makeText( + requireContext(), + getString(R.string.donate_toast_reminder), + Toast.LENGTH_LONG + ).show() + val customTab = CustomTabsIntent.Builder().build() + customTab.launchUrl(requireContext(), Uri.parse("https://ko-fi.com/internetaddict")) + } + + binding.tokenDisplay.text = "token:${prefs.donationToken}" + binding.tokenDisplay.setOnClickListener { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("Ko-fi token", "token:${prefs.donationToken}") + clipboard.setPrimaryClip(clip) + Toast.makeText(context, getString(R.string.donate_token_copied), Toast.LENGTH_SHORT).show() + } + + // If already donated, update UI to show activation message and hide token entry controls + if (prefs.isDonationActivated) { + binding.postDonatePrompt.text = getString(R.string.donate_token_activated) + binding.openTokenSection.visibility = View.GONE + binding.tokenSection.visibility = View.GONE + } else { + // Show token entry section button if not activated + binding.openTokenSection.setOnClickListener { + binding.tokenSection.visibility = View.VISIBLE + binding.openTokenSection.visibility = View.GONE + } + } + + binding.verifyButton.setOnClickListener { + var token = binding.tokenInput.text.toString().trim() + + // Strip optional "token:" prefix + if (token.startsWith("token:")) { + token = token.removePrefix("token:") + } + + // Validate token format + if (token.length != 16) { + Toast.makeText(context, getString(R.string.donate_token_invalid_format), Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + prefs.donationToken = token + + if (prefs.isDonationActivated) { + binding.resultText.text = getString(R.string.donate_token_already_activated) + return@setOnClickListener + } + + // Step 0: Check INTERNET permission + if (!hasInternetPermission(requireContext())) { + binding.resultText.text = getString(R.string.donate_missing_permission) + return@setOnClickListener + } + + // Step 1: Try activation server first + val aliveRequest = Request.Builder().url("$apiBase/alive").build() + client.newCall(aliveRequest).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + // If server unreachable, fallback to internet check + val internetCheck = Request.Builder() + .url("https://deb.debian.org/") + .build() + + client.newCall(internetCheck).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + activity?.runOnUiThread { + binding.resultText.text = getString(R.string.donate_no_internet) + } + } + + override fun onResponse(call: Call, response: Response) { + activity?.runOnUiThread { + if (!response.isSuccessful || response.body?.string().isNullOrBlank()) { + binding.resultText.text = getString(R.string.donate_no_internet) + } else { + binding.resultText.text = getString(R.string.donate_server_unreachable) + } + } + } + }) + } + + override fun onResponse(call: Call, response: Response) { + if (response.body?.string()?.trim() != "true") { + activity?.runOnUiThread { + binding.resultText.text = getString(R.string.donate_server_not_responding) + } + return + } + + // Step 2: Check token + val checkRequest = Request.Builder() + .url("$apiBase/check?token=$token") + .build() + + client.newCall(checkRequest).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + activity?.runOnUiThread { + binding.resultText.text = getString(R.string.donate_token_check_failed) + } + } + + override fun onResponse(call: Call, response: Response) { + val result = response.body?.string()?.trim() + if (result == "0") { + activity?.runOnUiThread { + binding.resultText.text = getString(R.string.donate_token_invalid) + } + return + } + + // Step 3: Activate + val activateRequest = Request.Builder() + .url("$apiBase/activate?token=$token") + .build() + + client.newCall(activateRequest).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + activity?.runOnUiThread { + binding.resultText.text = getString(R.string.donate_activation_failed) + } + } + + override fun onResponse(call: Call, response: Response) { + val activateResult = response.body?.string()?.trim() + if (activateResult == "success") { + prefs.isDonationActivated = true + activity?.runOnUiThread { + val remaining = (result?.toIntOrNull() ?: 1) - 1 + binding.resultText.text = + getString(R.string.donate_token_activated) + "\n" + + getString(R.string.donate_token_left, remaining.toString()) + } + } else { + activity?.runOnUiThread { + binding.resultText.text = getString(R.string.donate_activation_failed) + } + } + } + }) + } + }) + } + }) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun hasInternetPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.INTERNET + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt index 2658d4c..77883b6 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt @@ -1,193 +1,153 @@ package partisan.weforge.xyz.pulse -import android.Manifest -import android.app.role.RoleManager import android.content.Intent -import android.content.pm.PackageManager import android.os.Bundle -import android.provider.Settings -import androidx.activity.result.contract.ActivityResultContracts +import android.view.View +import android.view.Menu +import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity -import androidx.core.widget.doAfterTextChanged -import java.lang.NumberFormatException -import android.text.InputType -import android.widget.CheckBox -import android.widget.EditText -import android.widget.LinearLayout -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ItemTouchHelper -import partisan.weforge.xyz.pulse.getServicePriority -import partisan.weforge.xyz.pulse.setServicePriority -import partisan.weforge.xyz.pulse.isServiceEnabled -import partisan.weforge.xyz.pulse.setServiceEnabled import partisan.weforge.xyz.pulse.databinding.ActivityMainBinding +import androidx.appcompat.app.ActionBarDrawerToggle +import partisan.weforge.xyz.pulse.REQUIRED_PERMISSIONS +import partisan.weforge.xyz.pulse.hasCallRedirectionRole +import partisan.weforge.xyz.pulse.hasDrawOverlays +import partisan.weforge.xyz.pulse.hasGeneralPermissions +import com.google.android.material.switchmaterial.SwitchMaterial class MainActivity : AppCompatActivity() { - companion object { - private val PERMISSIONS = arrayOf( - Manifest.permission.READ_CONTACTS, - Manifest.permission.CALL_PHONE, - ) - } private lateinit var binding: ActivityMainBinding private lateinit var prefs: Preferences - private lateinit var window: PopupWindow - private var roleManager: RoleManager? = null - private val registerForCallRedirectionRole = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + private var popupSwitch: SwitchMaterial? = null + private var popupMenuItem: MenuItem? = null - private val registerForGeneralPermissions = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {} - - private val registerForDrawOverlays = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + val popupToggle: SwitchMaterial + get() = findViewById(R.id.globalPopupToggle) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) + prefs = Preferences(this) + updateDonationIcon() setContentView(binding.root) - init() - setup() - } - - override fun onDestroy() { - super.onDestroy() - window.cancel() - } - - private fun init() { prefs = Preferences(this) - window = PopupWindow(this, null) - roleManager = getSystemService(RoleManager::class.java) - binding.apply { - redirectionDelay.value = (prefs.redirectionDelay / 1000).toFloat() - popupPosition.editText?.setText(prefs.popupPosition.toString()) - toggle.isChecked = prefs.isEnabled + setSupportActionBar(binding.topAppBar) + + val drawerToggle = ActionBarDrawerToggle( + this, + binding.drawerLayout, + binding.topAppBar, + R.string.navigation_drawer_open, + R.string.navigation_drawer_open // The "close" string is never actually shown in the UI, so I reuse "navigation_drawer_open" as sort of a placeholder + ) + binding.drawerLayout.addDrawerListener(drawerToggle) + drawerToggle.syncState() + + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, MainFragment()) + .commit() + + setupPopupToggle(false) + + binding.navigationView.setNavigationItemSelectedListener { item -> + when (item.itemId) { + R.id.action_home -> { + supportFragmentManager.popBackStack(null, androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE) + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, MainFragment()) + .commit() + true + } + R.id.action_popup_settings -> { + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, PopupSettingsFragment()) + .addToBackStack(null) + .commit() + true + } + R.id.action_about -> { + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, AboutFragment()) + .addToBackStack(null) + .commit() + true + } + R.id.action_services -> { + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, ServiceSettingsFragment()) + .addToBackStack(null) + .commit() + true + } + R.id.action_redirect_settings -> { + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, RedirectSettingsFragment()) + .addToBackStack(null) + .commit() + true + } + R.id.action_contacts -> { + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, ContactsFragment()) + .addToBackStack(null) + .commit() + true + } + R.id.action_donate -> { + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, DonateFragment()) + .addToBackStack(null) + .commit() + true + } + else -> false + }.also { + binding.drawerLayout.closeDrawers() + } } } - private fun setup() { - binding.apply { - redirectionDelay.setLabelFormatter { - String.format("%.1f", it) - } - redirectionDelay.addOnChangeListener { _, value, _ -> - prefs.redirectionDelay = (value * 1000).toLong() - } - popupPosition.setEndIconOnClickListener { - window.preview() - } - popupPosition.editText?.doAfterTextChanged { - try { - prefs.popupPosition = it?.toString()?.toInt() ?: return@doAfterTextChanged - } catch (exc: NumberFormatException) {} - } - toggle.setOnCheckedChangeListener { _, isChecked -> - if (isChecked && !hasPermissions()) { - toggle.isChecked = false - requestPermissions() - return@setOnCheckedChangeListener - } - prefs.isEnabled = isChecked - } - val services = listOf( - ServiceEntry("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call", R.string.destination_signal, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call")), - ServiceEntry("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call", R.string.destination_telegram, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call")), - ServiceEntry("vnd.android.cursor.item/vnd.ch.threema.app.call", R.string.destination_threema, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.ch.threema.app.call")), - ServiceEntry("vnd.android.cursor.item/vnd.com.whatsapp.voip.call", R.string.destination_whatsapp, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.com.whatsapp.voip.call")), - ) + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.topbar_toggle, menu) + popupMenuItem = menu.findItem(R.id.globalPopupToggle) + popupSwitch = popupMenuItem?.actionView?.findViewById(R.id.globalPopupToggle) + popupMenuItem?.isVisible = false // hide by default + return true + } - val adapter = ServiceAdapter( - context = this@MainActivity, - services = services.toMutableList(), - onReordered = { updatedList -> - updatedList.forEachIndexed { index, entry -> - setServicePriority(entry.mimetype, index) - } - } - ) - binding.serviceRecycler.adapter = adapter - binding.serviceRecycler.layoutManager = LinearLayoutManager(this@MainActivity) - - val touchHelper = ItemTouchHelper(adapter.dragHelper) - touchHelper.attachToRecyclerView(binding.serviceRecycler) - - adapter.setDragStartListener { viewHolder -> - touchHelper.startDrag(viewHolder) + fun setupPopupToggle( + visible: Boolean, + initialState: Boolean = false, + onToggle: ((Boolean) -> Unit)? = null + ) { + popupMenuItem?.isVisible = visible + popupSwitch?.apply { + setOnCheckedChangeListener(null) + isChecked = initialState + setOnCheckedChangeListener { _, isChecked -> + onToggle?.invoke(isChecked) } - - // binding.serviceConfigList.removeAllViews() - // for ((mimetype, labelRes) in mimetypes) { - // val checkbox = CheckBox(this@MainActivity).apply { - // text = getString(labelRes) - // isChecked = this@MainActivity.isServiceEnabled(mimetype) - // setOnCheckedChangeListener { _, checked -> - // this@MainActivity.setServiceEnabled(mimetype, checked) - // } - // } - - // val priorityInput = EditText(this@MainActivity).apply { - // inputType = InputType.TYPE_CLASS_NUMBER - // setEms(4) - // hint = "Priority" - // setText(this@MainActivity.getServicePriority(mimetype).toString()) - // setOnFocusChangeListener { _, hasFocus -> - // if (!hasFocus) { - // val value = text.toString().toIntOrNull() - // if (value != null) this@MainActivity.setServicePriority(mimetype, value) - // } - // } - // } - - // val row = LinearLayout(this@MainActivity).apply { - // orientation = LinearLayout.HORIZONTAL - // setPadding(0, 16, 0, 16) - // addView(checkbox, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)) - // addView(priorityInput, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)) - // } - - // binding.serviceConfigList.addView(row) - // } } } - private fun requestPermissions() { - when { - !hasGeneralPermissions() -> requestGeneralPermissions() - !hasDrawOverlays() -> requestDrawOverlays() - !hasCallRedirectionRole() -> requestCallRedirectionRole() - } + private fun updateDonationIcon() { + val donateItem = binding.navigationView.menu.findItem(R.id.action_donate) + donateItem.setIcon( + if (prefs.isDonationActivated) + R.drawable.heart_filled_24 + else + R.drawable.heart_24 + ) + } + + fun setAppBarTitle(vararg parts: String) { + binding.topAppBar.title = parts.joinToString(" > ") } private fun hasPermissions(): Boolean { - return hasGeneralPermissions() && hasDrawOverlays() && hasCallRedirectionRole() - } - - private fun requestDrawOverlays() { - registerForDrawOverlays.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) - } - - private fun requestGeneralPermissions() { - registerForGeneralPermissions.launch(PERMISSIONS) - } - - private fun hasGeneralPermissions(): Boolean { - return !PERMISSIONS.any { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } - } - - private fun hasDrawOverlays(): Boolean { - return Settings.canDrawOverlays(this) - } - - private fun requestCallRedirectionRole() { - registerForCallRedirectionRole - .launch(roleManager?.createRequestRoleIntent(RoleManager.ROLE_CALL_REDIRECTION)) - } - - private fun hasCallRedirectionRole(): Boolean { - return roleManager?.isRoleHeld(RoleManager.ROLE_CALL_REDIRECTION) ?: false + return hasGeneralPermissions(this) && + hasDrawOverlays(this) && + hasCallRedirectionRole(this) } } diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt new file mode 100644 index 0000000..9e8c3b4 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt @@ -0,0 +1,67 @@ +package partisan.weforge.xyz.pulse + +import android.os.Bundle +import android.os.SystemClock +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.android.material.button.MaterialButton +import nl.dionsegijn.konfetti.core.Party +import nl.dionsegijn.konfetti.core.Position +import nl.dionsegijn.konfetti.core.emitter.Emitter +import nl.dionsegijn.konfetti.xml.KonfettiView +import java.util.concurrent.TimeUnit + +class MainFragment : Fragment() { + + private lateinit var prefs: Preferences + private var lastConfettiTime = 0L + + override fun onResume() { + super.onResume() + (requireActivity() as? MainActivity)?.setAppBarTitle(getString(R.string.app_name)) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.fragment_main, container, false) + prefs = Preferences(requireContext()) + + val toggle = view.findViewById(R.id.toggle) + val konfetti = view.findViewById(R.id.confettiView) + + toggle.isCheckable = true + toggle.isChecked = prefs.isServiceEnabledByUser + + toggle.setOnClickListener { + // the button toggles itself internally since it's checkable + val isNowChecked = toggle.isChecked + prefs.isServiceEnabledByUser = isNowChecked + + if (isNowChecked && SystemClock.elapsedRealtime() - lastConfettiTime > 500) { + konfetti.start( + Party( + emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).perSecond(100), + speed = 30f, + maxSpeed = 40f, + damping = 0.85f, + spread = 360, + position = Position.Relative(0.5, 0.5) + ) + ) + lastConfettiTime = SystemClock.elapsedRealtime() + } + + toggle.post { + toggle.jumpDrawablesToCurrentState() + toggle.invalidate() + } + } + + return view + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MatrixRainView.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MatrixRainView.kt new file mode 100644 index 0000000..01b61c4 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/MatrixRainView.kt @@ -0,0 +1,48 @@ +package partisan.weforge.xyz.pulse + +import android.content.Context +import android.graphics.* +import android.view.View +import kotlin.random.Random + +class MatrixRainView(context: Context) : View(context) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.GREEN + textSize = 5f * resources.displayMetrics.density + typeface = Typeface.MONOSPACE + } + + private val charset = "01アイウエオカキクケコ".toCharArray() + private val random = Random + private var columns = 0 + private lateinit var yOffsets: IntArray + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + columns = w / paint.textSize.toInt() + yOffsets = IntArray(columns) { random.nextInt(h) } + } + + override fun onDraw(canvas: Canvas) { + // drawColor with transparent clear instead of black + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + + for (i in 0 until columns) { + val x = i * paint.textSize + val y = yOffsets[i].toFloat() + val char = charset[random.nextInt(charset.size)] + + paint.alpha = 255 + canvas.drawText(char.toString(), x, y, paint) + + paint.alpha = 100 + canvas.drawText(char.toString(), x, y - paint.textSize, paint) + + yOffsets[i] += paint.textSize.toInt() + if (yOffsets[i] > height) { + yOffsets[i] = 0 + } + } + postInvalidateDelayed(50) + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt b/app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt new file mode 100644 index 0000000..9b9eb78 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt @@ -0,0 +1,27 @@ +package partisan.weforge.xyz.pulse + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.provider.Settings +import android.app.role.RoleManager + +val REQUIRED_PERMISSIONS = arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.CALL_PHONE, +) + +fun hasGeneralPermissions(context: Context): Boolean { + return REQUIRED_PERMISSIONS.all { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } +} + +fun hasDrawOverlays(context: Context): Boolean { + return Settings.canDrawOverlays(context) +} + +fun hasCallRedirectionRole(context: Context): Boolean { + val roleManager = context.getSystemService(RoleManager::class.java) + return roleManager?.isRoleHeld(RoleManager.ROLE_CALL_REDIRECTION) ?: false +} \ No newline at end of file diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/PopupSettingsFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/PopupSettingsFragment.kt new file mode 100644 index 0000000..5a504d3 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/PopupSettingsFragment.kt @@ -0,0 +1,152 @@ +package partisan.weforge.xyz.pulse + +import android.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.AdapterView +import android.widget.CompoundButton +import android.widget.Spinner +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.core.content.getSystemService +import partisan.weforge.xyz.pulse.databinding.FragmentPopupSettingsBinding + +class PopupSettingsFragment : Fragment() { + + private var _binding: FragmentPopupSettingsBinding? = null + private val binding get() = _binding!! + + private lateinit var prefs: Preferences + private lateinit var window: PopupWindow + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentPopupSettingsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onResume() { + super.onResume() + updateSpinner() + (requireActivity() as? MainActivity)?.apply { + setAppBarTitle(getString(R.string.settings_name), getString(R.string.popup_name)) + setupPopupToggle(true, prefs.popupEnabled) { isChecked -> + prefs.popupEnabled = isChecked + updateControls(isChecked) + } + } + } + + override fun onPause() { + super.onPause() + (requireActivity() as? MainActivity)?.setupPopupToggle(false) + } + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + prefs = Preferences(requireContext()) + window = PopupWindow(requireContext(), null) + + binding.popupPreview.setOnClickListener { + window.preview(false) + } + binding.popupPreview.setOnLongClickListener { + window.preview(true) + true + } + + binding.redirectionDelay.value = (prefs.redirectionDelay / 1000).toFloat() + binding.redirectionDelay.setLabelFormatter { + String.format("%.1f", it) + } + binding.redirectionDelay.addOnChangeListener { _, value, _ -> + prefs.redirectionDelay = (value * 1000).toLong() + } + + updateSpinner() + + val screenHeight = getScreenHeightPx() + binding.popupHeightSlider.valueFrom = 0f + binding.popupHeightSlider.valueTo = screenHeight.toFloat() + binding.popupHeightSlider.value = prefs.popupPosition.toFloat() + binding.popupHeightSlider.addOnChangeListener { _, value, _ -> + prefs.popupPosition = value.toInt().coerceIn(0, screenHeight) + } + + updateControls(prefs.popupEnabled) + } + + private fun updateControls(enabled: Boolean) { + binding.redirectionDelay.isEnabled = enabled + binding.popupHeightSlider.isEnabled = enabled + binding.popupPreview.isEnabled = enabled + binding.popupEffectSpinner.isEnabled = enabled + binding.popupEffectLabel.isEnabled = enabled + } + + private fun getScreenHeightPx(): Int { + val wm = requireContext().getSystemService()!! + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bounds: Rect = wm.currentWindowMetrics.bounds + bounds.height() + } else { + val metrics = DisplayMetrics() + @Suppress("DEPRECATION") + requireActivity().windowManager.defaultDisplay.getMetrics(metrics) + metrics.heightPixels + } + } + + private fun updateSpinner() { + val allEffects = Preferences.PopupEffect.values() + val effectLabels = resources.getStringArray(R.array.popup_effects) + + val availableEffects = prefs.getAvailablePopupEffects() + listOf( + Preferences.PopupEffect.NONE, + Preferences.PopupEffect.RANDOM + ) + + val displayNames = allEffects.mapIndexed { index, effect -> + val baseName = effectLabels.getOrElse(index) { effect.name } + if (!prefs.isDonationActivated && effect !in availableEffects) + "$baseName 🔒" + else + baseName + } + + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, displayNames) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.popupEffectSpinner.adapter = adapter + + binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal) + + binding.popupEffectSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { + val selectedEffect = allEffects[position] + if (!prefs.isDonationActivated && selectedEffect !in prefs.getAvailablePopupEffects()) { + Toast.makeText(requireContext(), getString(R.string.donate_lock), Toast.LENGTH_SHORT).show() + binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal) + } else { + prefs.popupEffect = selectedEffect + } + } + + override fun onNothingSelected(parent: AdapterView<*>) {} + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt index d5f4aa1..a2bd984 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt @@ -3,30 +3,49 @@ package partisan.weforge.xyz.pulse import android.Manifest import android.content.Context import android.content.Intent +import android.graphics.Color import android.graphics.PixelFormat +import android.graphics.Rect import android.media.AudioManager import android.net.Uri import android.view.Gravity import android.view.LayoutInflater +import android.view.View import android.view.WindowManager import android.view.ContextThemeWrapper import android.widget.TextView import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.os.Handler +import android.os.Looper import android.widget.ProgressBar +import android.view.animation.OvershootInterpolator +import android.view.animation.DecelerateInterpolator import androidx.annotation.RequiresPermission +import androidx.core.content.res.use +import com.google.android.material.card.MaterialCardView import java.lang.ref.WeakReference import java.util.* import kotlin.concurrent.timerTask +import android.util.Log +import android.content.res.ColorStateList +import com.google.android.material.color.DynamicColors +import com.google.android.material.color.MaterialColors +import partisan.weforge.xyz.pulse.Preferences.PopupEffect +import partisan.weforge.xyz.pulse.MatrixRainView class PopupWindow( ctx: Context, private val service: WeakReference?, ) { - private val themedCtx = ContextThemeWrapper(ctx, R.style.Theme_Pulse) + private val themedCtx = DynamicColors.wrapContextIfAvailable( + ContextThemeWrapper(ctx, R.style.Theme_Pulse) + ) private val prefs = Preferences(themedCtx) private val windowManager = themedCtx.getSystemService(WindowManager::class.java) private val audioManager = themedCtx.getSystemService(AudioManager::class.java) - private val view = LayoutInflater.from(themedCtx).inflate(R.layout.popup, null) + private val inflater = LayoutInflater.from(themedCtx) + private val view = inflater.inflate(R.layout.popup, null) private val layoutParams = WindowManager.LayoutParams().apply { format = PixelFormat.TRANSLUCENT flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE @@ -36,57 +55,75 @@ class PopupWindow( height = WindowManager.LayoutParams.WRAP_CONTENT y = prefs.popupPosition } - private var timer: Timer? = null + private var currentEffect: PopupEffect = PopupEffect.NONE + private var matrixOverlay: View? = null + private var gamerAnimator: ValueAnimator? = null + private var timer: Timer? = null init { view.setOnClickListener { cancel() service?.get()?.placeCallUnmodified() } + + // This is utterly stupid, but it works + applyResolvedColors(view) } - fun preview() { + fun preview(isLongPress: Boolean = false) { remove() layoutParams.y = prefs.popupPosition + val destinations = listOf( R.string.destination_signal, R.string.destination_telegram, R.string.destination_threema, + // Whatsapp smells ) setDescription(destinations.random()) add() - } - fun show(uri: Uri, destinationId: Int) { - val service = service?.get() ?: return - if (!remove()) { - service.placeCallUnmodified() - return - } + val duration = if (isLongPress) prefs.redirectionDelay * 5 else prefs.redirectionDelay timer?.cancel() timer = Timer() timer?.schedule(timerTask { - if (!remove()) { - service.placeCallUnmodified() - return@timerTask + remove() + }, duration) + } + + fun show(uri: Uri, destinationId: Int) { + val svc = service?.get() ?: return + + timer?.cancel() + timer = Timer() + timer?.schedule(timerTask { + Handler(Looper.getMainLooper()).post { + if (!remove()) { + svc.placeCallUnmodified() + return@post + } + if (audioManager?.mode != AudioManager.MODE_IN_CALL) { + svc.placeCallUnmodified() + return@post + } + try { + call(uri) + } catch (exc: SecurityException) { + svc.placeCallUnmodified() + return@post + } + svc.cancelCall() } - if (audioManager?.mode != AudioManager.MODE_IN_CALL) { - service.placeCallUnmodified() - return@timerTask - } - try { - call(uri) - } catch (exc: SecurityException) { - service.placeCallUnmodified() - return@timerTask - } - service.cancelCall() }, prefs.redirectionDelay) + + layoutParams.y = prefs.popupPosition setDescription(destinationId) startProgressAnimation(prefs.redirectionDelay) + if (!add()) { + Log.w("PopupWindow", "add() failed – popup not shown, calling directly.") timer?.cancel() - service.placeCallUnmodified() + svc.placeCallUnmodified() } } @@ -107,7 +144,7 @@ class PopupWindow( } @RequiresPermission(Manifest.permission.CALL_PHONE) - private fun call(data: Uri) { + fun call(data: Uri) { Intent(Intent.ACTION_VIEW).apply { this.data = data flags = Intent.FLAG_ACTIVITY_NEW_TASK @@ -115,27 +152,298 @@ class PopupWindow( } } + private fun animateAppear() { + view.animate().cancel() + + // Always reset all transforms before animation + view.rotationX = 0f + view.alpha = 1f + view.translationX = 0f + view.translationY = 0f + view.scaleX = 1f + view.scaleY = 1f + + // Reset gamer effect if it was active before + gamerAnimator?.cancel() + gamerAnimator = null + + val card = view as MaterialCardView + themedCtx.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorOutline)).use { ta -> + val defaultStroke = ta.getColor(0, Color.DKGRAY) + card.strokeColor = defaultStroke + } + + val effect = when (prefs.popupEffect) { + PopupEffect.RANDOM -> prefs.getAvailablePopupEffects().random() + else -> prefs.popupEffect + } + currentEffect = effect + + when (effect) { + PopupEffect.NONE -> {} + PopupEffect.FADE -> { + view.alpha = 0f + view.animate().alpha(1f).setDuration(300).start() + } + PopupEffect.SCALE -> { // + view.translationX = view.width.toFloat() + view.alpha = 0f + view.animate() + .translationX(0f) + .alpha(1f) + .setDuration(350) + .setInterpolator(DecelerateInterpolator(2f)) + .start() + } + PopupEffect.BOUNCE -> { + view.scaleX = 0.7f + view.scaleY = 0.7f + view.animate().scaleX(1f).scaleY(1f) + .setInterpolator(OvershootInterpolator()) + .setDuration(400).start() + } + PopupEffect.FLOP -> { + view.rotationX = 90f + view.alpha = 0f + view.animate().rotationX(0f).alpha(1f) + .setDuration(350) + .setInterpolator(DecelerateInterpolator()) + .start() + } + PopupEffect.MATRIX -> { + val rainView = MatrixRainView(themedCtx) + matrixOverlay?.let { + try { + windowManager?.removeViewImmediate(it) + } catch (_: Exception) {} + } + matrixOverlay = rainView + + val popupBounds = Rect() + view.getGlobalVisibleRect(popupBounds) + + val overlayParams = WindowManager.LayoutParams().apply { + type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + format = PixelFormat.TRANSLUCENT + flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + width = view.width + height = view.height + gravity = Gravity.BOTTOM + x = 0 + y = layoutParams.y + } + + try { + windowManager?.addView(rainView, overlayParams) + } catch (e: Exception) { + Log.e("MatrixRain", "Failed to add rainView", e) + return + } + + // Fade-in popup over 500ms + view.alpha = 0f + view.animate().cancel() + view.animate().alpha(1f).setDuration(500).start() + + // Remove MatrixRainView in sync + rainView.animate().alpha(0f).setDuration(500).withEndAction { + try { + windowManager?.removeView(rainView) + matrixOverlay = null + } catch (_: Exception) {} + }.start() + } + PopupEffect.SLIDE_SNAP -> { + view.translationY = 200f + view.alpha = 0f + view.animate() + .translationY(0f) + .alpha(1f) + .setDuration(350) + .setInterpolator(OvershootInterpolator(2f)) + .start() + } + PopupEffect.GAMER_MODE -> { + val popupCard = view as MaterialCardView + val hsv = floatArrayOf(0f, 1f, 1f) + + gamerAnimator?.cancel() // Cancel any existing animator before starting new one + + gamerAnimator = ValueAnimator.ofFloat(0f, 360f).apply { + duration = 2000 + repeatCount = ValueAnimator.INFINITE + addUpdateListener { + hsv[0] = it.animatedValue as Float + popupCard.strokeColor = Color.HSVToColor(hsv) + } + start() + } + + view.alpha = 0f + view.animate() + .alpha(1f) + .setDuration(400) + .start() + } + else -> {} + } + } + + private fun animateDisappear(onEnd: () -> Unit) { + + // While the reset after animation can be a nice safety net, it's causing visual glitches and is already run at the start of every animation anyway + // val resetAndFinish = { + // view.animate().cancel() + // view.translationX = 0f + // view.translationY = 0f + // view.scaleX = 1f + // view.scaleY = 1f + // view.rotationX = 0f + // view.rotationY = 0f + // view.alpha = 1f + // Log.d("PopupWindow", "Reset and finish after disappear animation") + // onEnd() + // } + + val end = Runnable { + Log.d("PopupWindow", "Disappearance animation complete") + view.post { onEnd() } // defer by one frame to ensure alpha=0 is rendered + } + + when (currentEffect) { + + // PopupEffect.NONE -> falls to else + + PopupEffect.FADE -> view.animate() + .alpha(0f) + .setDuration(200) + .withEndAction(end) + .start() + + PopupEffect.SCALE -> view.animate() + .translationX(view.width.toFloat()) + .alpha(0f) + .setDuration(200) + .setInterpolator(DecelerateInterpolator(2f)) + .withEndAction(end) + .start() + + PopupEffect.BOUNCE -> view.animate() + .scaleX(0f) + .scaleY(0f) + .setDuration(200) + .withEndAction(end) + .start() + + PopupEffect.FLOP -> view.animate() + .rotationX(90f) + .alpha(0f) + .setDuration(200) + .withEndAction(end) + .start() + + PopupEffect.MATRIX -> { + view.animate() + .alpha(0f) + .setDuration(150) + .withEndAction(end) + .start() + + matrixOverlay?.let { overlay -> + overlay.animate().cancel() + overlay.animate().alpha(0f).setDuration(150).withEndAction { + try { + windowManager?.removeViewImmediate(overlay) + } catch (_: Exception) {} + matrixOverlay = null + }.start() + } + } + + PopupEffect.SLIDE_SNAP -> view.animate() + .translationY(200f) + .alpha(0f) + .setDuration(200) + .setInterpolator(DecelerateInterpolator(2f)) + .withEndAction(end) + .start() + + else -> end.run() + } + } + private fun add(): Boolean { try { + // If already attached, force remove and re-add + if (view.parent != null) { + windowManager?.removeViewImmediate(view) + } + view.animate().cancel() windowManager?.addView(view, layoutParams) - } catch (exc: WindowManager.BadTokenException) { + animateAppear() + } catch (exc: Exception) { + Log.e("PopupWindow", "Failed to add popup view", exc) return false } return true } - private fun remove(): Boolean { - try { - windowManager?.removeView(view) - } catch (_: IllegalArgumentException) { - } catch (_: WindowManager.BadTokenException) { - return false + private fun remove(onRemoved: (() -> Unit)? = null): Boolean { + return try { + animateDisappear { + try { + windowManager?.removeView(view) + matrixOverlay?.let { + try { + windowManager?.removeViewImmediate(it) + } catch (e: Exception) { + Log.e("PopupWindow", "Failed to remove matrix overlay", e) + } + matrixOverlay = null + } + } catch (e: Exception) { + Log.e("PopupWindow", "Failed to remove popup view", e) + } + onRemoved?.invoke() + } + true + } catch (e: Exception) { + Log.e("PopupWindow", "Exception during remove()", e) + false } - return true } fun cancel() { + Log.d("PopupWindow", "Cancel called") timer?.cancel() remove() } + + private fun applyResolvedColors(view: android.view.View) { + val attrSurface = com.google.android.material.R.attr.colorSurface + val attrOnSurface = com.google.android.material.R.attr.colorOnSurface + val attrPrimary = com.google.android.material.R.attr.colorPrimaryVariant + + try { + themedCtx.obtainStyledAttributes(intArrayOf(attrSurface, attrOnSurface, attrPrimary)).use { ta -> + val surface = ta.getColor(0, Color.LTGRAY) + val onSurface = ta.getColor(1, Color.DKGRAY) + val primary = ta.getColor(2, Color.DKGRAY) + + (view as? MaterialCardView)?.setCardBackgroundColor(surface) + view.findViewById(R.id.description)?.setTextColor(onSurface) + + view.findViewById(R.id.progress)?.let { bar -> + bar.progressTintList = ColorStateList.valueOf(primary) + bar.progressBackgroundTintList = ColorStateList.valueOf( + primary and 0x00FFFFFF or (0x40 shl 24) + ) + } + + view.invalidate() + } + } catch (e: Exception) { + Log.e("PopupTheme", "Color resolution error: ${e.message}", e) + } + } } diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt index 5909a36..753fdb5 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt @@ -4,24 +4,84 @@ import android.content.Context import androidx.core.content.edit import androidx.preference.PreferenceManager -class Preferences(ctx: Context) { +class Preferences(private val context: Context) { + companion object { - private const val ENABLED = "enabled" private const val REDIRECTION_DELAY = "redirection_delay" private const val POPUP_POSITION = "popup_position_y" + private const val POPUP_ENABLED = "popup_enabled" + private val POPUP_EFFECT = "popup_effect" + private const val BLACKLISTED_CONTACTS = "blacklisted_contacts" + private const val BLACKLIST_ENABLED = "blacklist_enabled" + private val SERVICE_ORDER_KEY = "service_order" + private const val DONATION_ACTIVATED = "donation_activated" + private const val DONATION_TOKEN = "donation_token" + + private const val REDIRECT_WIFI = "redirect_wifi" + private const val REDIRECT_DATA = "redirect_data" + private const val REDIRECT_INTERNATIONAL = "redirect_international" + private const val REDIRECT_ROAMING = "redirect_roaming" private const val DEFAULT_REDIRECTION_DELAY = 2000L private const val DEFAULT_POPUP_POSITION = 333 - - // migration private const val SERVICE_ENABLED = "service_enabled" } - private val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - var isEnabled: Boolean - get() = prefs.getBoolean(ENABLED, prefs.getBoolean(SERVICE_ENABLED, false)) - set(value) = prefs.edit { putBoolean(ENABLED, value) } + // Whether user enabled/disabled the service manually by tiggle button + var isServiceEnabledByUser: Boolean + get() = prefs.getBoolean(SERVICE_ENABLED, true) + set(value) = prefs.edit { putBoolean(SERVICE_ENABLED, value) } + + // True only if all required permissions + toggle are satisfied + val isEnabled: Boolean + get() = isServiceEnabledByUser && + hasGeneralPermissions(context) && + hasDrawOverlays(context) && + hasCallRedirectionRole(context) + + enum class PopupEffect { + NONE, FADE, SCALE, BOUNCE, FLOP, MATRIX, SLIDE_SNAP, GAMER_MODE, RANDOM + } + + var popupEffect: PopupEffect + get() { + val name = prefs.getString(POPUP_EFFECT, PopupEffect.FADE.name) ?: PopupEffect.FADE.name + return try { + PopupEffect.valueOf(name) + } catch (_: IllegalArgumentException) { + // If invalid, fallback and clear the broken value + prefs.edit().remove(POPUP_EFFECT).apply() + PopupEffect.BOUNCE + } + } + set(value) { + prefs.edit().putString(POPUP_EFFECT, value.name).apply() + } + + var isBlacklistEnabled: Boolean + get() = prefs.getBoolean(BLACKLIST_ENABLED, false) + set(value) = prefs.edit { putBoolean(BLACKLIST_ENABLED, value) } + + var isDonationActivated: Boolean + get() = prefs.getBoolean(DONATION_ACTIVATED, false) + set(value) = prefs.edit { putBoolean(DONATION_ACTIVATED, value) } + + var donationToken: String + get() { + val stored = prefs.getString(DONATION_TOKEN, null) + return if (stored != null) { + stored + } else { + generateAndStoreToken() + } + } + set(value) = prefs.edit { putString(DONATION_TOKEN, value) } + + var popupEnabled: Boolean + get() = prefs.getBoolean(POPUP_ENABLED, true) + set(value) = prefs.edit { putBoolean(POPUP_ENABLED, value) } var redirectionDelay: Long get() = prefs.getLong(REDIRECTION_DELAY, DEFAULT_REDIRECTION_DELAY) @@ -31,26 +91,87 @@ class Preferences(ctx: Context) { get() = prefs.getInt(POPUP_POSITION, DEFAULT_POPUP_POSITION) set(value) = prefs.edit { putInt(POPUP_POSITION, value) } + var redirectOnWifi: Boolean + get() = prefs.getBoolean(REDIRECT_WIFI, true) + set(value) = prefs.edit { putBoolean(REDIRECT_WIFI, value) } + + var redirectOnData: Boolean + get() = prefs.getBoolean(REDIRECT_DATA, true) + set(value) = prefs.edit { putBoolean(REDIRECT_DATA, value) } + + var redirectInternationalOnly: Boolean + get() = prefs.getBoolean(REDIRECT_INTERNATIONAL, false) + set(value) = prefs.edit { putBoolean(REDIRECT_INTERNATIONAL, value) } + + var redirectIfRoaming: Boolean + get() = prefs.getBoolean(REDIRECT_ROAMING, false) + set(value) = prefs.edit { putBoolean(REDIRECT_ROAMING, value) } + private fun makeKeyEnabled(mimetype: String) = "enabled_$mimetype" private fun makeKeyPriority(mimetype: String) = "priority_$mimetype" - /** Whether this service is enabled */ + fun getAvailablePopupEffects(): List { + val locked = listOf( + PopupEffect.FLOP, + PopupEffect.MATRIX, + PopupEffect.SLIDE_SNAP, + PopupEffect.GAMER_MODE + ) + return PopupEffect.values().filter { + isDonationActivated || it !in locked + }.filter { it != PopupEffect.RANDOM && it != PopupEffect.NONE } + } + fun isServiceEnabled(mimetype: String): Boolean { return prefs.getBoolean(makeKeyEnabled(mimetype), true) } - /** Current priority for this service (lower = higher priority) */ fun getServicePriority(mimetype: String): Int { - return prefs.getInt(makeKeyPriority(mimetype), Int.MAX_VALUE) + val order = getServiceOrder() + val index = order.indexOf(mimetype) + return if (index != -1) index else Int.MAX_VALUE + } + + fun getServiceOrder(): List { + val stored = prefs.getString(SERVICE_ORDER_KEY, null) + return stored?.split("|")?.filter { it.isNotBlank() } ?: emptyList() + } + + fun setServiceOrder(order: List) { + prefs.edit().putString(SERVICE_ORDER_KEY, order.joinToString("|")).apply() } - /** Enable or disable individual service */ fun setServiceEnabled(mimetype: String, enabled: Boolean) { prefs.edit().putBoolean(makeKeyEnabled(mimetype), enabled).apply() } - /** Change priority for an individual service */ fun setServicePriority(mimetype: String, priority: Int) { prefs.edit().putInt(makeKeyPriority(mimetype), priority).apply() } + + var blacklistedContacts: Set + get() = prefs.getStringSet(BLACKLISTED_CONTACTS, emptySet()) ?: emptySet() + set(value) = prefs.edit { putStringSet(BLACKLISTED_CONTACTS, value) } + + fun isContactWhitelisted(phoneNumber: String): Boolean { + return !blacklistedContacts.contains(phoneNumber) + } + + fun setContactWhitelisted(phoneNumber: String, allowed: Boolean) { + val current = blacklistedContacts.toMutableSet() + if (!allowed) { + current.add(phoneNumber) + } else { + current.remove(phoneNumber) + } + blacklistedContacts = current + } + + private fun generateAndStoreToken(): String { + val newToken = (1..16) + .map { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".random() } + .joinToString("") + prefs.edit().putString(DONATION_TOKEN, newToken).apply() + return newToken + } } diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/RedirectSettingsFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/RedirectSettingsFragment.kt new file mode 100644 index 0000000..8bf0f55 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/RedirectSettingsFragment.kt @@ -0,0 +1,56 @@ +package partisan.weforge.xyz.pulse + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.android.material.materialswitch.MaterialSwitch + +class RedirectSettingsFragment : Fragment() { + + private lateinit var prefs: Preferences + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + prefs = Preferences(requireContext()) + return inflater.inflate(R.layout.fragment_redirect_settings, container, false) + } + + override fun onResume() { + super.onResume() + (requireActivity() as? MainActivity)?.setAppBarTitle( + getString(R.string.settings_name), getString(R.string.redirect_name) + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val switchWifi = view.findViewById(R.id.switchRedirectWifi) + val switchData = view.findViewById(R.id.switchRedirectData) + val switchInternational = view.findViewById(R.id.switchRedirectInternational) + val switchRoaming = view.findViewById(R.id.switchRedirectRoaming) + + // Load saved state + switchWifi.isChecked = prefs.redirectOnWifi + switchData.isChecked = prefs.redirectOnData + switchInternational.isChecked = prefs.redirectInternationalOnly + switchRoaming.isChecked = prefs.redirectIfRoaming + + // Save on toggle + switchWifi.setOnCheckedChangeListener { _, isChecked -> + prefs.redirectOnWifi = isChecked + } + switchData.setOnCheckedChangeListener { _, isChecked -> + prefs.redirectOnData = isChecked + } + switchInternational.setOnCheckedChangeListener { _, isChecked -> + prefs.redirectInternationalOnly = isChecked + } + switchRoaming.setOnCheckedChangeListener { _, isChecked -> + prefs.redirectIfRoaming = isChecked + } + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt new file mode 100644 index 0000000..14ed3db --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt @@ -0,0 +1,18 @@ +package partisan.weforge.xyz.pulse + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment + +class SecretFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return SecretView(requireContext()) + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt b/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt new file mode 100644 index 0000000..64efcce --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt @@ -0,0 +1,902 @@ +package partisan.weforge.xyz.pulse + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.* +import android.util.AttributeSet +import android.view.Choreographer +import android.view.MotionEvent +import android.view.View +import kotlin.math.* +import kotlin.random.Random + +class SecretView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs), Choreographer.FrameCallback { + + private val bulletPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + strokeWidth = 6f + } + private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + alpha = 40 + style = Paint.Style.FILL + } + private val playerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + } + private val enemyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + } + private val rocketPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.RED + style = Paint.Style.STROKE + strokeWidth = 3f + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 64f + typeface = Typeface.DEFAULT_BOLD + } + private val retryRect = RectF() + private val retryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.DKGRAY + } + private val retryTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 48f + } + private var colorSecondary: Int = Color.GREEN + + private var playerX = 0f + private var viewWidth = 0f + private var viewHeight = 0f + + private var enemyClearAggression = 0f + private var adaptiveSpawnTimer = 0L + private var lastEnemyCount = 0 + private var avgEnemiesPerSecond = 0f + + private var isTouching = false + private var shieldRechargeTimer = 0L + private var shieldFlashAlpha = 0f + private var lastMissileSide = -1 + private var missileCooldown = 0L + private var bulletCooldownMs = 0L + private var gameOver = false + private var score = 0 + + // Player level + private var multiFireLevel = 1 + private var piercingLevel = 1 + private var shieldLevel = 0 + private var missileLevel = 0 + private var rapidFireLevel = 1 + + private val bullets = mutableListOf() + private val enemyBullets = mutableListOf() + private val enemies = mutableListOf() + private val rockets = mutableListOf() + private val stars = mutableListOf() + private val explosions = mutableListOf() + private val rocketTrails = mutableListOf>() + private val pickups = mutableListOf() + private val playerMissiles = mutableListOf() + + private data class Bullet(var x: Float, var y: Float, val dy: Float = -15f, var life: Int = 1) + private data class Star(var x: Float, var y: Float, val radius: Float, val speed: Float) + private data class Rocket(var x: Float, var y: Float, var angle: Float, val trail: MutableList> = mutableListOf()) + private data class Explosion(var x: Float, var y: Float, var timer: Int = 12) + private data class Pickup( + val x: Float, + var y: Float, + val type: Int, + var hue: Float = Random.nextFloat() * 360f, + ) + private data class PlayerMissile( + var x: Float, + var y: Float, + var angle: Float, + var ttl: Long = 90000L, + var target: Enemy? = null, + var recheckCooldown: Long = 0L, + var side: Int, // -1 = left, 1 = right + val trail: MutableList> = mutableListOf() + ) + + private var lastLogicTime = 0L + private val logicStepMs = 16L + + private var waveTimer = 0L + private var currentWave = 0 + private var enemiesLeftInWave = 0 + private var currentWaveType = "" + + init { + for (i in 0 until 50) { + stars.add(Star(Random.nextFloat() * 1080f, Random.nextFloat() * 1920f, Random.nextFloat() * 2f + 1f, Random.nextFloat() * 2f + 0.5f)) + } + + val colorAttrs = intArrayOf( + com.google.android.material.R.attr.colorPrimaryVariant, + com.google.android.material.R.attr.colorSecondary + ) + context.obtainStyledAttributes(colorAttrs).use { + playerPaint.color = it.getColor(0, Color.CYAN) + enemyPaint.color = it.getColor(0, Color.CYAN) + colorSecondary = it.getColor(1, Color.GREEN) + } + + Choreographer.getInstance().postFrameCallback(this) + lastLogicTime = System.nanoTime() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + viewWidth = w.toFloat() + viewHeight = h.toFloat() + // Kinda hacky way to center things, but I'd rather do this here than in update(), since it can't be in init() as the screen size isn't initialized at that point. + playerX = viewWidth / 2f + retryRect.set(viewWidth / 2f - 120f, viewHeight / 2f + 60f, viewWidth / 2f + 120f, viewHeight / 2f + 130f) + } + + override fun doFrame(frameTimeNanos: Long) { + if (!gameOver) { + val now = System.nanoTime() + while ((now - lastLogicTime) / 1_000_000 >= logicStepMs) { + update(logicStepMs) + lastLogicTime += logicStepMs * 1_000_000 + } + invalidate() + Choreographer.getInstance().postFrameCallback(this) + } + } + + private fun update(deltaMs: Long) { + stars.forEach { + it.y += it.speed * deltaMs / 16f + if (it.y > viewHeight) { + it.y = 0f + it.x = Random.nextFloat() * viewWidth + } + } + + // Update shield recharge timer and flash animation + if (shieldRechargeTimer > 0) { + shieldRechargeTimer -= deltaMs + if (shieldRechargeTimer <= 0) { + shieldRechargeTimer = 0 + shieldFlashAlpha = 1f // trigger visual flash on recharge + } + } + + if (shieldFlashAlpha > 0f) { + shieldFlashAlpha -= deltaMs / 300f + if (shieldFlashAlpha < 0f) shieldFlashAlpha = 0f + } + + rockets.forEach { rocket -> + rocket.trail.add(0, rocket.x to rocket.y) + if (rocket.trail.size > 20) { + rocket.trail.removeLast() + } + } + + explosions.forEach { it.timer-- } + explosions.removeIf { it.timer <= 0 } + + bullets.forEach { it.y += it.dy * deltaMs / 16f } + bullets.removeIf { it.y < 0 || it.life <= 0 } + + bulletCooldownMs -= deltaMs + if (isTouching && bulletCooldownMs <= 0) { + val baseCooldown = 450f + var multiplier = 1f + for (level in 2..rapidFireLevel) { + val reduction = ((36 - level * 1.2f).coerceAtLeast(4f)) / 100f + multiplier *= (1f - reduction) + } + bulletCooldownMs = max(50L, (baseCooldown * multiplier).toLong()) + + val baseY = viewHeight - 100f + val spacing = 10f + val count = multiFireLevel + + for (i in 0 until count) { + val offset = (i - (count - 1) / 2f) * spacing + bullets.add(Bullet(playerX + offset, baseY)) + } + } + + enemies.forEach { + it.update(deltaMs) + it.x = max(20f, min(it.x, viewWidth - 20f)) + if (it.y > viewHeight + 100f) it.y = -40f // respawn at top + } + + pickups.forEach { + it.y += 2.5f * deltaMs / 16f + it.hue = (it.hue + deltaMs * 0.01f) % 360f + } + pickups.removeIf { it.y > viewHeight - 40f } + + rockets.forEach { + val targetAngle = atan2(viewHeight - 100f - it.y, playerX - it.x) + it.angle += ((targetAngle - it.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f + it.x += cos(it.angle) * 6f + it.y += sin(it.angle) * 6f + } + rockets.removeIf { it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight } + + enemyBullets.forEach { it.y += it.dy * deltaMs / 16f } + enemyBullets.removeIf { it.y > viewHeight } + + updatePlayerMissiles(deltaMs) + + checkCollisions() + spawnEnemies(deltaMs) + } + + private fun checkCollisions() { + val pickupIter = pickups.iterator() + while (pickupIter.hasNext()) { + val p = pickupIter.next() + if (hypot(playerX - p.x, viewHeight - 100f - p.y) < 40f) { + when (p.type) { + 0 -> { // Multi-fire + if (multiFireLevel < 4) { + multiFireLevel++ + } else { + applyRandomPowerupExcept(0) + } + } + 1 -> shieldLevel++ + 2 -> if (missileLevel < 8) missileLevel++ + 3 -> if (rapidFireLevel < 30) rapidFireLevel++ + 4 -> if (piercingLevel < 15) piercingLevel++ + } + pickupIter.remove() + } + } + + val enemyIter = enemies.iterator() + while (enemyIter.hasNext()) { + val enemy = enemyIter.next() + if (hypot(playerX - enemy.x, viewHeight - 100f - enemy.y) < 40f) { + if (shieldLevel > 0 && shieldRechargeTimer <= 0) { + shieldRechargeTimer = calculateShieldRechargeTime() + enemyIter.remove() + explosions.add(Explosion(enemy.x, enemy.y)) + continue + } else { + explosions.add(Explosion(playerX, viewHeight - 100f)) + gameOver = true + return + } + } + for (b in bullets) { + if (hypot(b.x - enemy.x, b.y - enemy.y) < 30f) { + explosions.add(Explosion(enemy.x, enemy.y)) + enemyIter.remove() + score += 10 + b.life-- + + if (Random.nextFloat() < 0.03f) { + val type = Random.nextInt(5) // 5 pickup types + val hue = Random.nextFloat() * 360f + pickups.add(Pickup(enemy.x, enemy.y, type, hue)) + } + break + } + } + } + + val bulletIter = enemyBullets.iterator() + while (bulletIter.hasNext()) { + val b = bulletIter.next() + val dy = b.y - (viewHeight - 100f) + val dx = b.x - playerX + val dist = hypot(dx, dy) + val hitRadius = if (shieldLevel > 0 && shieldRechargeTimer <= 0) 60f else 20f + + if (dist < hitRadius) { + if (shieldLevel > 0 && shieldRechargeTimer <= 0) { + shieldRechargeTimer = calculateShieldRechargeTime() + bulletIter.remove() + explosions.add(Explosion(b.x, b.y)) + } else { + explosions.add(Explosion(playerX, viewHeight - 100f)) + gameOver = true + return + } + } + } + + val rocketIter = rockets.iterator() + while (rocketIter.hasNext()) { + val rocket = rocketIter.next() + val dy = viewHeight - 100f - rocket.y + val dx = playerX - rocket.x + val dist = hypot(dx, dy) + val hitRadius = if (shieldLevel > 0 && shieldRechargeTimer <= 0) 60f else 30f + + if (dist < hitRadius) { + if (shieldLevel > 0 && shieldRechargeTimer <= 0) { + shieldRechargeTimer = calculateShieldRechargeTime() + rocketIter.remove() + explosions.add(Explosion(rocket.x, rocket.y)) + continue + } else { + explosions.add(Explosion(playerX, viewHeight - 100f)) + gameOver = true + return + } + } + + for (b in bullets) { + if (hypot(b.x - rocket.x, b.y - rocket.y) < 20f) { + explosions.add(Explosion(rocket.x, rocket.y)) + rocketIter.remove() + break + } + } + } + + val missileIter = playerMissiles.iterator() + while (missileIter.hasNext()) { + val missile = missileIter.next() + val enemyHit = enemies.firstOrNull { enemy -> + hypot(missile.x - enemy.x, missile.y - enemy.y) < 30f + } + + if (enemyHit != null) { + explosions.add(Explosion(enemyHit.x, enemyHit.y)) + enemies.remove(enemyHit) + missileIter.remove() + score += 10 + + if (Random.nextFloat() < 0.5f) { + val type = Random.nextInt(5) + val hue = Random.nextFloat() * 360f + pickups.add(Pickup(enemyHit.x, enemyHit.y, type, hue)) + } + } + } + } + + private fun updatePlayerMissiles(deltaMs: Long) { + // Missile cooldown and launch + missileCooldown -= deltaMs + if (missileLevel > 0 && missileCooldown <= 0) { + val cooldown = when (missileLevel) { + 1 -> 20000L + 2 -> 15000L + 3 -> 12000L + 4 -> 20000L + 5 -> 18000L + 6 -> 17000L + 7 -> 16000L + else -> 15000L + } + + val baseY = viewHeight - 100f + if (missileLevel >= 4) { + // fire both sides + playerMissiles.add(PlayerMissile(playerX - 20f, baseY, -PI.toFloat() / 2f, side = -1)) + playerMissiles.add(PlayerMissile(playerX + 20f, baseY, -PI.toFloat() / 2f, side = 1)) + } else { + lastMissileSide *= -1 + val offsetX = 20f * lastMissileSide + playerMissiles.add(PlayerMissile(playerX + offsetX, baseY, -PI.toFloat() / 2f, side = lastMissileSide)) + } + + missileCooldown = cooldown + } + + // Update missiles + val lockedEnemies = playerMissiles.mapNotNull { it.target }.toSet() + val availableTargets = enemies.filter { it !in lockedEnemies } + + playerMissiles.forEach { missile -> + missile.ttl -= deltaMs + if (missile.ttl <= 0) return@forEach + + // Add current position to trail + missile.trail.add(0, missile.x to missile.y) + if (missile.trail.size > 20) { + missile.trail.removeLast() + } + + if (missile.target == null || !enemies.contains(missile.target)) { + missile.recheckCooldown -= deltaMs + if (missile.recheckCooldown <= 0) { + val newTarget = availableTargets.minByOrNull { + hypot((it.x - missile.x).toDouble(), (it.y - missile.y).toDouble()) + } + if (newTarget != null) { + missile.target = newTarget + } else { + missile.recheckCooldown = 1000L + } + } + } + + val target = missile.target + val angleTo = if (target != null) { + atan2(target.y - missile.y, target.x - missile.x) + } else missile.angle + + // steer towards target slowly + missile.angle += ((angleTo - missile.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f + missile.x += cos(missile.angle) * 6f + missile.y += sin(missile.angle) * 6f + } + + playerMissiles.removeIf { it.ttl <= 0 || it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight } + } + + private fun applyRandomPowerupExcept(excludedType: Int) { + val types = (0..4).filter { it != excludedType } + val type = types.random() + when (type) { + 1 -> shieldLevel++ + 2 -> if (missileLevel < 8) missileLevel++ + 3 -> if (rapidFireLevel < 30) rapidFireLevel++ + 4 -> if (piercingLevel < 15) piercingLevel++ + } + } + + private fun createCubeIconPath(): Path { + return Path().apply { + addRect(-10f, -10f, 10f, 10f, Path.Direction.CW) + } + } + + private fun calculateShieldRechargeTime(): Long { + val base = 60000L // 60 seconds + val min = 10000L // 10 seconds + val reduction = (1.0 - exp(-shieldLevel / 40.0)).toFloat() + return (base - (base - min) * reduction).toLong() + } + + private fun spawnEnemies(deltaMs: Long) { + waveTimer -= deltaMs + adaptiveSpawnTimer += deltaMs + + // Passive asteroids + if (Random.nextFloat() < 0.002f) { + enemies.add(EnemyAsteroid(Random.nextFloat() * viewWidth, -40f, viewWidth)) + } + + // Setup new wave + if (enemiesLeftInWave <= 0 && waveTimer <= 0) { + currentWave++ + currentWaveType = when (currentWave % 3) { + 0 -> "easy" + 1 -> "medium" + else -> "hard" + } + + val difficultyBoost = when (currentWaveType) { + "easy" -> (currentWave * 1.2f).roundToInt() + "medium" -> (currentWave * 1.5f).roundToInt() + "hard" -> (currentWave * 1.8f).roundToInt() + else -> currentWave + } + + enemiesLeftInWave = 3 + difficultyBoost + waveTimer = 3000L + adaptiveSpawnTimer = 0L + lastEnemyCount = enemies.size + avgEnemiesPerSecond = 0f + enemyClearAggression = 0f + } + + // Adjust aggression every 1s + if (adaptiveSpawnTimer >= 1000L) { + val cleared = (lastEnemyCount - enemies.size).coerceAtLeast(0) + avgEnemiesPerSecond = avgEnemiesPerSecond * 0.7f + cleared * 0.3f + lastEnemyCount = enemies.size + adaptiveSpawnTimer = 0L + + // Normalize to 0–1 range (assuming 0 to 6 cleared per second) + enemyClearAggression = (avgEnemiesPerSecond / 6f).coerceIn(0f, 1f) + } + + // Spawn enemies in group + if (enemiesLeftInWave > 0 && enemies.count { it !is EnemyAsteroid } < 10) { + val baseX = Random.nextFloat() * (viewWidth - 100f) + 50f + val baseY = -40f + val spacing = 35f + val sharedOffset = Random.nextFloat() * 1000f + val sharedFireTime = Random.nextLong(2000L, 4000L) + + // Dynamically adjust formation size + val formationBase = when (currentWaveType) { + "easy" -> 5 + "medium" -> 3 + "hard" -> 1 + else -> 3 + } + + // Scale formation by aggression (max +2) + val dynamicBonus = (enemyClearAggression * 2).roundToInt() + val formationSize = (formationBase + dynamicBonus).coerceAtMost(enemiesLeftInWave) + + for (i in 0 until formationSize) { + val offsetX = (i - (formationSize - 1) / 2f) * spacing + val x = baseX + offsetX + + val enemy = when (currentWaveType) { + "easy" -> EnemyEasy(x, baseY) + "medium" -> EnemyMedium(x, baseY, sharedOffset, sharedFireTime) { enemyBullets.add(it) } + "hard" -> { + val hardEnemiesSoFar = enemies.count { it is EnemyHard } + if (hardEnemiesSoFar >= 2) { + EnemyMedium(x, baseY, sharedOffset, sharedFireTime) { enemyBullets.add(it) } + } else { + EnemyHard(x, baseY, { rockets.add(it) }, sharedOffset, sharedFireTime) + } + } + else -> EnemyEasy(x, baseY) + } + + enemies.add(enemy) + enemiesLeftInWave-- + } + } + } + + private fun resetGame() { + bullets.clear() + enemyBullets.clear() + enemies.clear() + rockets.clear() + explosions.clear() + rocketTrails.clear() + playerMissiles.clear() + pickups.clear() + + // Reset upgrades + multiFireLevel = 1 + piercingLevel = 1 + shieldLevel = 0 + missileLevel = 0 + rapidFireLevel = 1 + + // Reset gameplay state + score = 0 + currentWave = 0 + enemiesLeftInWave = 0 + gameOver = false + shieldRechargeTimer = 0L + shieldFlashAlpha = 0f + lastMissileSide = -1 + missileCooldown = 0L + bulletCooldownMs = 0L + enemyClearAggression = 0f + adaptiveSpawnTimer = 0L + lastEnemyCount = 0 + avgEnemiesPerSecond = 0f + + playerX = viewWidth / 2f + lastLogicTime = System.nanoTime() + Choreographer.getInstance().postFrameCallback(this) + } + + override fun onDraw(canvas: Canvas) { + viewWidth = width.toFloat() + viewHeight = height.toFloat() + + canvas.drawColor(Color.parseColor("#121212")) + stars.forEach { canvas.drawCircle(it.x, it.y, it.radius, starPaint) } + + val enemyBulletPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.RED + strokeWidth = 6f // same as player bullets + } + enemyBullets.forEach { canvas.drawLine(it.x, it.y, it.x, it.y + 20f, enemyBulletPaint) } + + // Enemy rockets + rockets.forEach { rocket -> + rocket.trail.forEachIndexed { index, (x, y) -> + val alpha = ((1f - index / 20f.toFloat()) * 255).toInt() + rocketPaint.alpha = alpha + canvas.drawCircle(x, y, 2f, rocketPaint) + } + } + rocketPaint.alpha = 255 + + // Player rockets + val missilePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = colorSecondary + style = Paint.Style.STROKE + strokeWidth = 3f + } + + playerMissiles.forEach { missile -> + missile.trail.forEachIndexed { index, (x, y) -> + val alpha = ((1f - index / 20f.toFloat()) * 255).toInt() + missilePaint.alpha = alpha + canvas.drawCircle(x, y, 2f, missilePaint) + } + } + + missilePaint.alpha = 255 + playerMissiles.forEach { missile -> + canvas.drawCircle(missile.x, missile.y, 10f, missilePaint) + } + + explosions.forEach { + val radius = 40f * (1f - it.timer / 12f.toFloat()) + val alpha = (255 * (it.timer / 12f.toFloat())).toInt() + val paint = Paint().apply { + color = Color.YELLOW + this.alpha = alpha + style = Paint.Style.STROKE + strokeWidth = 3f + } + canvas.drawCircle(it.x, it.y, radius, paint) + } + + bullets.forEach { canvas.drawLine(it.x, it.y, it.x, it.y - 20f, bulletPaint) } + enemies.forEach { it.draw(canvas, enemyPaint) } + rockets.forEach { canvas.drawCircle(it.x, it.y, 10f, rocketPaint) } + + // Draw pickups + pickups.forEach { pickup -> + // Draw cube (filled rect with outline) + val size = 30f + val left = pickup.x - size / 2f + val top = pickup.y - size / 2f + val right = pickup.x + size / 2f + val bottom = pickup.y + size / 2f + + val hsv = floatArrayOf(pickup.hue, 1f, 1f) + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.HSVToColor(hsv) + style = Paint.Style.FILL + } + val outlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 2f + } + + canvas.drawRect(left, top, right, bottom, fillPaint) + canvas.drawRect(left, top, right, bottom, outlinePaint) + + // Draw icon + val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + strokeWidth = 3f + style = Paint.Style.STROKE + } + + canvas.save() + canvas.translate(pickup.x, pickup.y) + canvas.rotate(-45f) + + when (pickup.type) { + 0 -> { // Multi-fire: two parallel lines + canvas.drawLine(-8f, -10f, -8f, 10f, iconPaint) + canvas.drawLine(8f, -10f, 8f, 10f, iconPaint) + } + 1 -> { // Shield: quarter circle + val path = Path() + path.addArc(RectF(-10f, -10f, 10f, 10f), -90f, 90f) + canvas.drawPath(path, iconPaint) + } + 2 -> { // Missiles: circle + trail + canvas.drawCircle(0f, 0f, 4f, iconPaint) + canvas.drawLine(-6f, 6f, -2f, 2f, iconPaint) + } + 3 -> { // Rapid fire: two lines in succession + canvas.drawLine(-5f, -10f, -5f, 10f, iconPaint) + canvas.drawLine(5f, -10f, 5f, 10f, iconPaint) + } + 4 -> { // Piercing bullets: arrow head + val path = Path() + path.moveTo(-8f, 10f) + path.lineTo(0f, -10f) + path.lineTo(8f, 10f) + canvas.drawPath(path, iconPaint) + } + } + + canvas.restore() + } + + if (!gameOver) { + // Draw player + val baseY = viewHeight - 100f + val safeX = max(30f, min(playerX, viewWidth - 30f)) + playerX = safeX + val path = Path().apply { + moveTo(safeX, baseY - 30f) + lineTo(safeX - 30f, baseY + 30f) + lineTo(safeX + 30f, baseY + 30f) + close() + } + canvas.drawPath(path, playerPaint) + + // Draw shield + if (shieldLevel > 0 && shieldRechargeTimer <= 0) { + val baseColor = colorSecondary + val shieldPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = baseColor + style = Paint.Style.STROKE + strokeWidth = 6f + alpha = (180 + shieldFlashAlpha * 75f).toInt().coerceAtMost(255) + } + canvas.drawCircle(playerX, viewHeight - 100f, 60f, shieldPaint) + } + } else { + canvas.drawText("Game Over", viewWidth / 2f, viewHeight / 2f - 60f, textPaint) + canvas.drawText("Score: $score", viewWidth / 2f, viewHeight / 2f + 10f, textPaint) + canvas.drawText("Tap to restart", retryRect.centerX(), retryRect.centerY() + 16f, retryTextPaint) + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (gameOver && event.action == MotionEvent.ACTION_DOWN) { + resetGame() + return true + } + + when (event.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + playerX = max(30f, min(event.x, width - 30f)) + isTouching = true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> isTouching = false + } + return true + } + + private interface Enemy { + var x: Float + var y: Float + fun update(deltaMs: Long) + fun draw(canvas: Canvas, paint: Paint) + } + + private class EnemyAsteroid( + override var x: Float, + override var y: Float, + private val screenWidth: Float + ) : Enemy { + private var vx = Random.nextFloat() * 2f - 1f + private val vy = Random.nextFloat() * 2f + 2f + private var angle = Random.nextFloat() * 360f + private val rotationSpeed = Random.nextFloat() * 2f * if (Random.nextBoolean()) 1 else -1 + + override fun update(deltaMs: Long) { + x += vx * deltaMs / 16f + y += vy * deltaMs / 16f + angle = (angle + rotationSpeed * deltaMs / 16f) % 360f + + if (x < 20f || x > screenWidth - 20f) { + vx = -vx + x = max(20f, min(x, screenWidth - 20f)) + } + } + + override fun draw(canvas: Canvas, paint: Paint) { + canvas.save() + canvas.rotate(angle, x, y) + val path = Path().apply { + moveTo(x - 20f, y) + lineTo(x - 10f, y - 15f) + lineTo(x + 10f, y - 15f) + lineTo(x + 20f, y) + lineTo(x + 10f, y + 15f) + lineTo(x - 10f, y + 15f) + close() + } + canvas.drawPath(path, paint) + canvas.restore() + } + } + + private class EnemyEasy(override var x: Float, override var y: Float) : Enemy { + private val offset = Random.nextFloat() * 1000f + override fun update(deltaMs: Long) { + y += 2f * deltaMs / 16f + x += sin((y + offset) / 50f) * 2f + } + override fun draw(canvas: Canvas, paint: Paint) { + val path = Path().apply { + moveTo(x, y - 20f) + lineTo(x + 20f, y) + lineTo(x, y + 20f) + lineTo(x - 20f, y) + close() + } + canvas.drawPath(path, paint) + } + } + + private class EnemyMedium( + override var x: Float, + override var y: Float, + private val offset: Float, + private var fireTimer: Long, + val fireEnemyBullet: (Bullet) -> Unit + ) : Enemy { + override fun update(deltaMs: Long) { + y += 2.5f * deltaMs / 16f + x += sin((y + offset) / 40f) * 3f + + fireTimer -= deltaMs + if (fireTimer <= 0) { + fireEnemyBullet(Bullet(x, y + 30f, dy = 10f)) + fireTimer = Random.nextLong(3000L, 6000L) + } + } + + override fun draw(canvas: Canvas, paint: Paint) { + val path = Path().apply { + moveTo(x, y - 25f) + lineTo(x + 15f, y) + lineTo(x, y + 25f) + lineTo(x - 15f, y) + close() + } + canvas.drawPath(path, paint) + } + } + + private class EnemyHard( + override var x: Float, + override var y: Float, + val fireRocket: (Rocket) -> Unit, + private val offset: Float = Random.nextFloat() * 1000f, + private var fireTimer: Long = Random.nextLong(2000L, 5000L) + ) : Enemy { + private var cooldown = 0L + private var firing = false + + override fun update(deltaMs: Long) { + if (firing) { + cooldown += deltaMs + if (cooldown > 500 && cooldown < 1300) { + fireRocket(Rocket(x, y - 30f, -PI.toFloat() / 2)) + cooldown = 1300 + } else if (cooldown > 2000) { + cooldown = 0L + fireTimer = Random.nextLong(15000L, 25000L) + firing = false + } + return + } + + y += 3f * deltaMs / 16f + x += sin((y + offset) / 25f) * 4f + fireTimer -= deltaMs + if (fireTimer <= 0) { + firing = true + cooldown = 0L + } + } + + override fun draw(canvas: Canvas, paint: Paint) { + val path = Path().apply { + moveTo(x, y - 25f) + lineTo(x + 10f, y - 10f) + lineTo(x + 20f, y + 10f) + lineTo(x, y + 25f) + lineTo(x - 20f, y + 10f) + lineTo(x - 10f, y - 10f) + close() + } + canvas.drawPath(path, paint) + } + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt index 6565c3b..59edda9 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt @@ -66,8 +66,8 @@ class ServiceAdapter( viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { - val from = viewHolder.adapterPosition - val to = target.adapterPosition + val from = viewHolder.bindingAdapterPosition + val to = target.bindingAdapterPosition services.add(to, services.removeAt(from)) notifyItemMoved(from, to) return true diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ServicesFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ServicesFragment.kt new file mode 100644 index 0000000..add151a --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/ServicesFragment.kt @@ -0,0 +1,96 @@ +package partisan.weforge.xyz.pulse + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import partisan.weforge.xyz.pulse.databinding.FragmentServiceSettingsBinding + +class ServiceSettingsFragment : Fragment() { + + private var _binding: FragmentServiceSettingsBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentServiceSettingsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onResume() { + super.onResume() + (requireActivity() as? MainActivity)?.setAppBarTitle( + getString(R.string.settings_name), + getString(R.string.services_name) + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val available = listOf( + ServiceEntry( + "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call", + R.string.destination_signal, + requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call") + ), + ServiceEntry( + "vnd.android.cursor.item/vnd.org.telegram.messenger.android.call", + R.string.destination_telegram, + requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call") + ), + ServiceEntry( + "vnd.android.cursor.item/vnd.ch.threema.app.call", + R.string.destination_threema, + requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.ch.threema.app.call") + ), + ServiceEntry( + "vnd.android.cursor.item/vnd.com.whatsapp.voip.call", + R.string.destination_whatsapp, + requireContext().isServiceEnabled("vnd.android.cursor.item/vnd.com.whatsapp.voip.call") + ), + ) + + val storedOrder = Preferences(requireContext()).getServiceOrder() + + val ordered = storedOrder.mapNotNull { mime -> + available.find { it.mimetype == mime } + }.toMutableList() + + // Add any missing services that weren't stored (e.g., first run) + val missing = available.filterNot { s -> ordered.any { it.mimetype == s.mimetype } } + ordered += missing + + val prefs = Preferences(requireContext()) + + val adapter = ServiceAdapter( + context = requireContext(), + services = ordered, + onReordered = { updatedList -> + val newOrder = updatedList.map { it.mimetype } + prefs.setServiceOrder(newOrder) + } + ) + + binding.serviceRecycler.adapter = adapter + binding.serviceRecycler.layoutManager = LinearLayoutManager(requireContext()) + + val touchHelper = ItemTouchHelper(adapter.dragHelper) + touchHelper.attachToRecyclerView(binding.serviceRecycler) + + adapter.setDragStartListener { viewHolder -> + touchHelper.startDrag(viewHolder) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt b/app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt new file mode 100644 index 0000000..e19473b --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt @@ -0,0 +1,104 @@ +package partisan.weforge.xyz.pulse + +import android.app.role.RoleManager +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import nl.dionsegijn.konfetti.core.Party +import nl.dionsegijn.konfetti.core.Position +import nl.dionsegijn.konfetti.core.emitter.Emitter +import nl.dionsegijn.konfetti.xml.KonfettiView +import partisan.weforge.xyz.pulse.databinding.ActivityWelcomeBinding +import java.util.concurrent.TimeUnit +import partisan.weforge.xyz.pulse.hasGeneralPermissions +import partisan.weforge.xyz.pulse.hasDrawOverlays +import partisan.weforge.xyz.pulse.hasCallRedirectionRole +import partisan.weforge.xyz.pulse.REQUIRED_PERMISSIONS + +class WelcomeActivity : AppCompatActivity() { + private lateinit var binding: ActivityWelcomeBinding + private lateinit var prefs: Preferences + private var roleManager: RoleManager? = null + + private val requestPermissionsLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) {} + + private val requestOverlayLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) {} + + private val requestRoleLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (hasGeneralPermissions(this) && hasDrawOverlays(this) && hasCallRedirectionRole(this)) { + startActivity(Intent(this, MainActivity::class.java)) + finish() + return + } + + binding = ActivityWelcomeBinding.inflate(layoutInflater) + setContentView(binding.root) + + prefs = Preferences(this) + roleManager = getSystemService(RoleManager::class.java) + + binding.activateButton.setOnClickListener { + when { + !hasGeneralPermissions(this) -> { + requestPermissionsLauncher.launch(REQUIRED_PERMISSIONS) + } + !hasDrawOverlays(this) -> { + requestOverlayLauncher.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) + } + !hasCallRedirectionRole(this) -> { + requestRoleLauncher.launch(roleManager?.createRequestRoleIntent(RoleManager.ROLE_CALL_REDIRECTION)) + } + else -> { + showConfettiAndContinue() + } + } + } + } + + private fun showConfettiAndContinue() { + binding.appIcon.post { + val iconLocation = IntArray(2) + binding.appIcon.getLocationOnScreen(iconLocation) + + val iconCenterX = iconLocation[0] + binding.appIcon.width / 2f + val iconCenterY = iconLocation[1] + binding.appIcon.height / 2f + + val rootWidth = binding.root.width.toFloat() + val rootHeight = binding.root.height.toFloat() + + val relativeX = (iconCenterX / rootWidth).toDouble() + val relativeY = (iconCenterY / rootHeight).toDouble() + + binding.konfettiView.visibility = View.VISIBLE + binding.konfettiView.start( + Party( + speed = 25f, + maxSpeed = 50f, + damping = 0.9f, + spread = 360, + colors = listOf(0xfce18a, 0xff726d, 0xf4306d, 0xb48def), + position = Position.Relative(relativeX, relativeY), + emitter = Emitter(duration = 1, TimeUnit.SECONDS).perSecond(80) + ) + ) + + binding.konfettiView.postDelayed({ + startActivity(Intent(this, MainActivity::class.java)) + finish() + }, 1500) + } + } +} diff --git a/app/src/main/res/color/toggle_button_bg.xml b/app/src/main/res/color/toggle_button_bg.xml new file mode 100644 index 0000000..1c33f06 --- /dev/null +++ b/app/src/main/res/color/toggle_button_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/toggle_button_icon.xml b/app/src/main/res/color/toggle_button_icon.xml new file mode 100644 index 0000000..ae21493 --- /dev/null +++ b/app/src/main/res/color/toggle_button_icon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/call_split_24px.xml b/app/src/main/res/drawable/call_split_24px.xml new file mode 100644 index 0000000..0056d80 --- /dev/null +++ b/app/src/main/res/drawable/call_split_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/group_24px.xml b/app/src/main/res/drawable/group_24px.xml new file mode 100644 index 0000000..556499a --- /dev/null +++ b/app/src/main/res/drawable/group_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/heart_24.xml b/app/src/main/res/drawable/heart_24.xml new file mode 100644 index 0000000..3edfe1d --- /dev/null +++ b/app/src/main/res/drawable/heart_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/heart_filled_24.xml b/app/src/main/res/drawable/heart_filled_24.xml new file mode 100644 index 0000000..cee1854 --- /dev/null +++ b/app/src/main/res/drawable/heart_filled_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_power_settings_new_24.xml b/app/src/main/res/drawable/ic_power_settings_new_24.xml new file mode 100644 index 0000000..f134c90 --- /dev/null +++ b/app/src/main/res/drawable/ic_power_settings_new_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/info_24px.xml b/app/src/main/res/drawable/info_24px.xml new file mode 100644 index 0000000..3186ebf --- /dev/null +++ b/app/src/main/res/drawable/info_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml new file mode 100644 index 0000000..89c2de3 --- /dev/null +++ b/app/src/main/res/drawable/search_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/services_24.xml b/app/src/main/res/drawable/services_24.xml new file mode 100644 index 0000000..5bbe6bf --- /dev/null +++ b/app/src/main/res/drawable/services_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/toggle_button_bg.xml b/app/src/main/res/drawable/toggle_button_bg.xml new file mode 100644 index 0000000..4d684f6 --- /dev/null +++ b/app/src/main/res/drawable/toggle_button_bg.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/toggle_button_bg_outline.xml b/app/src/main/res/drawable/toggle_button_bg_outline.xml new file mode 100644 index 0000000..a459ecd --- /dev/null +++ b/app/src/main/res/drawable/toggle_button_bg_outline.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/tooltip_24px.xml b/app/src/main/res/drawable/tooltip_24px.xml new file mode 100644 index 0000000..5770d07 --- /dev/null +++ b/app/src/main/res/drawable/tooltip_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1ef4824..c0ed222 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,100 +1,45 @@ - + android:layout_height="match_parent"> - + + - + + - + + - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/app/src/main/res/layout/activity_welcome.xml b/app/src/main/res/layout/activity_welcome.xml new file mode 100644 index 0000000..77183e8 --- /dev/null +++ b/app/src/main/res/layout/activity_welcome.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + +