Compare commits
20 commits
794fe46b45
...
660bac6af8
Author | SHA1 | Date | |
---|---|---|---|
660bac6af8 | |||
|
f8aeb6da10 | ||
|
9dcfe80a26 | ||
|
b65222ddf9 | ||
|
693607de7c | ||
|
b2b839cf72 | ||
|
e8549c8841 | ||
|
4ce065425b | ||
|
6af51d8fc8 | ||
|
6d9024a580 | ||
|
e810208a14 | ||
|
27cd0a829e | ||
|
2939fb55d1 | ||
|
72d4a797ea | ||
|
1850641fdb | ||
|
64efa8c15e | ||
|
fafe7e2cd5 | ||
|
1691891a4d | ||
|
663463cd38 | ||
|
cba93c6069 |
|
@ -1,9 +1,6 @@
|
|||
name: Android Release Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
|
1
.gitignore
vendored
|
@ -15,3 +15,4 @@
|
|||
local.properties
|
||||
release-key.jks
|
||||
check.py
|
||||
round.sh
|
46
README.md
|
@ -7,7 +7,7 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.
|
||||
Redirecting outgoing calls to E2EE apps.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
@ -30,14 +30,41 @@ Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp.
|
|||
|
||||
<p align="center">
|
||||
<span>
|
||||
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/data/screenshot.png" alt="Main screen" height="500" style="border-radius: 8px;">
|
||||
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" alt="Main screen" height="500" style="border-radius: 8px;">
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/data/screenshot-redirecting.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
|
||||
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<img src="https://weforge.xyz/partisan/Pulse/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" alt="Redirecting popup" height="500" style="border-radius: 8px;">
|
||||
</span>
|
||||
</p>
|
||||
|
||||
# Features
|
||||
|
||||
- Material You design
|
||||
- Popup with cancel option
|
||||
- Extensive settings panel:
|
||||
- Toggle per-service support
|
||||
- Redirection only on Wi-Fi/Data
|
||||
- Allowlist specific contacts
|
||||
- Change per-service priority
|
||||
- Customize popup position, animation, and duration
|
||||
- ...
|
||||
|
||||
# Supports
|
||||
|
||||
- Signal
|
||||
- Telegram
|
||||
- Threema
|
||||
- WhatsApp
|
||||
|
||||
# How to Install
|
||||
|
||||
## Using Droid-ify (or other F-Droid client)
|
||||
|
@ -68,13 +95,14 @@ Install it, and 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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.hardware.telephony" android:required="true" />
|
||||
|
||||
<application
|
||||
|
@ -16,13 +18,14 @@
|
|||
android:theme="@style/Theme.Pulse">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".WelcomeActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".MainActivity" />
|
||||
|
||||
<service
|
||||
android:name=".CallRedirectionService"
|
||||
|
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 56 KiB |
|
@ -0,0 +1,71 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import partisan.weforge.xyz.pulse.databinding.FragmentAboutBinding
|
||||
|
||||
class AboutFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentAboutBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private var tapCount = 0
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentAboutBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
(requireActivity() as? MainActivity)?.setAppBarTitle(
|
||||
getString(R.string.about_name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.sourceButton.setOnClickListener {
|
||||
openUrl("https://weforge.xyz/partisan/Pulse")
|
||||
}
|
||||
|
||||
binding.licenseButton.setOnClickListener {
|
||||
openUrl("https://www.gnu.org/licenses/gpl-3.0.en.html")
|
||||
}
|
||||
|
||||
binding.appIcon.setOnClickListener {
|
||||
tapCount++
|
||||
if (tapCount >= 5) {
|
||||
binding.secretButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
binding.secretButton.setOnClickListener {
|
||||
requireActivity().supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragmentContainer, SecretFragment())
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
|
@ -4,10 +4,13 @@ import android.Manifest
|
|||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.provider.ContactsContract
|
||||
import android.telecom.CallRedirectionService
|
||||
import android.telecom.PhoneAccountHandle
|
||||
import android.telephony.TelephonyManager
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class CallRedirectionService : CallRedirectionService() {
|
||||
|
@ -58,31 +61,97 @@ class CallRedirectionService : CallRedirectionService() {
|
|||
initialPhoneAccount: PhoneAccountHandle,
|
||||
allowInteractiveResponse: Boolean,
|
||||
) {
|
||||
if (!prefs.isEnabled || !hasInternet() || !allowInteractiveResponse) {
|
||||
Log.d("Redirection", "onPlaceCall triggered: uri=$handle, interactive=$allowInteractiveResponse")
|
||||
|
||||
val capabilities = connectivityManager
|
||||
?.getNetworkCapabilities(connectivityManager?.activeNetwork)
|
||||
|
||||
val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
val isCellular = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
||||
Log.d("Redirection", "isWifi=$isWifi, isCellular=$isCellular")
|
||||
|
||||
val shouldRedirect = when {
|
||||
isWifi && !prefs.redirectOnWifi -> false
|
||||
isCellular && !prefs.redirectOnData -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
if (!prefs.isEnabled) {
|
||||
Log.d("Redirection", "Aborting: redirection disabled in prefs")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldRedirect) {
|
||||
Log.d("Redirection", "Aborting: redirection blocked by current network preference")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasInternet()) {
|
||||
Log.d("Redirection", "Aborting: no internet connection detected")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowInteractiveResponse) {
|
||||
Log.d("Redirection", "Aborting: interactive response not allowed by system")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
if (prefs.redirectIfRoaming && !isOutsideHomeCountry()) {
|
||||
Log.d("Redirection", "Aborting: redirect only while roaming, but we're inside home country")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
val phoneNumber = handle.schemeSpecificPart
|
||||
Log.d("Redirection", "Resolved phone number: $phoneNumber")
|
||||
|
||||
if (prefs.redirectInternationalOnly && !isInternationalNumber(phoneNumber)) {
|
||||
Log.d("Redirection", "Aborting: number is not international and pref requires it")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
if (prefs.isBlacklistEnabled && !prefs.isContactWhitelisted(phoneNumber)) {
|
||||
Log.d("Redirection", "Aborting: number is not in whitelist while blacklist is enabled")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
val records: Array<Record>
|
||||
try {
|
||||
records = getRecordsFromPhoneNumber(handle.schemeSpecificPart)
|
||||
records = getRecordsFromPhoneNumber(phoneNumber)
|
||||
Log.d("Redirection", "Found ${records.size} raw records for contact")
|
||||
} catch (exc: SecurityException) {
|
||||
Log.w("Redirection", "SecurityException during record fetch", exc)
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to enabled services only
|
||||
val enabledRecords = records
|
||||
.filter { prefs.isServiceEnabled(it.mimetype) }
|
||||
.sortedBy { prefs.getServicePriority(it.mimetype) }
|
||||
|
||||
Log.d("Redirection", "Filtered to ${enabledRecords.size} enabled records")
|
||||
|
||||
val record = enabledRecords.firstOrNull()
|
||||
if (record == null) {
|
||||
Log.d("Redirection", "Aborting: no suitable record found for redirection")
|
||||
placeCallUnmodified()
|
||||
return
|
||||
}
|
||||
|
||||
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) &&
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class ContactAdapter(
|
||||
private val prefs: Preferences,
|
||||
private val fullList: List<ContactEntry>
|
||||
) : RecyclerView.Adapter<ContactAdapter.ViewHolder>() {
|
||||
|
||||
private var filteredList = fullList.toMutableList()
|
||||
|
||||
inner class ViewHolder(inflater: LayoutInflater, parent: ViewGroup) :
|
||||
RecyclerView.ViewHolder(inflater.inflate(R.layout.item_contact, parent, false)) {
|
||||
val contactName: TextView = itemView.findViewById(R.id.contactName)
|
||||
val contactAllowed: CheckBox = itemView.findViewById(R.id.contactAllowed)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(LayoutInflater.from(parent.context), parent)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val contact = filteredList[position]
|
||||
holder.contactName.text = contact.name
|
||||
holder.contactAllowed.setOnCheckedChangeListener(null)
|
||||
holder.contactAllowed.isChecked = prefs.isContactWhitelisted(contact.phoneNumber)
|
||||
|
||||
holder.contactAllowed.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.setContactWhitelisted(contact.phoneNumber, isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = filteredList.size
|
||||
|
||||
fun filter(query: String) {
|
||||
filteredList = if (query.isBlank()) {
|
||||
fullList.toMutableList()
|
||||
} else {
|
||||
fullList.filter {
|
||||
it.name.contains(query, ignoreCase = true)
|
||||
}.toMutableList()
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
data class ContactEntry(
|
||||
val name: String,
|
||||
val phoneNumber: String
|
||||
)
|
126
app/src/main/java/partisan/weforge/xyz/pulse/ContactsFragment.kt
Normal file
|
@ -0,0 +1,126 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.graphics.Color
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import partisan.weforge.xyz.pulse.databinding.FragmentContactsBinding
|
||||
|
||||
class ContactsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentContactsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var prefs: Preferences
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentContactsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
(requireActivity() as? MainActivity)?.apply {
|
||||
setAppBarTitle(getString(R.string.settings_name), getString(R.string.whitelist_name))
|
||||
setupPopupToggle(true, prefs.isBlacklistEnabled) { isChecked ->
|
||||
prefs.isBlacklistEnabled = isChecked
|
||||
binding.contactRecycler.isEnabled = isChecked
|
||||
binding.contactRecycler.alpha = if (isChecked) 1f else 0.4f
|
||||
}
|
||||
}
|
||||
|
||||
// Initial state
|
||||
binding.contactRecycler.isEnabled = prefs.isBlacklistEnabled
|
||||
binding.contactRecycler.alpha = if (prefs.isBlacklistEnabled) 1f else 0.4f
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(requireActivity() as? MainActivity)?.setupPopupToggle(false)
|
||||
}
|
||||
|
||||
private lateinit var adapter: ContactAdapter
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
prefs = Preferences(requireContext())
|
||||
|
||||
val contacts = getContacts()
|
||||
adapter = ContactAdapter(prefs, contacts)
|
||||
|
||||
binding.contactRecycler.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.contactRecycler.adapter = adapter
|
||||
|
||||
binding.contactSearch.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
adapter.filter(newText ?: "")
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
val searchView = binding.contactSearch
|
||||
|
||||
searchView.setIconifiedByDefault(false)
|
||||
searchView.isIconified = false
|
||||
searchView.isSubmitButtonEnabled = false
|
||||
searchView.clearFocus()
|
||||
|
||||
val editText = searchView.findViewById<androidx.appcompat.widget.SearchView.SearchAutoComplete>(
|
||||
androidx.appcompat.R.id.search_src_text
|
||||
)
|
||||
editText.isFocusable = true
|
||||
editText.isFocusableInTouchMode = true
|
||||
editText.setTextColor(Color.WHITE)
|
||||
editText.setHintTextColor(Color.LTGRAY)
|
||||
|
||||
val searchPlate = searchView.findViewById<View>(androidx.appcompat.R.id.search_plate)
|
||||
searchPlate.setBackgroundColor(Color.TRANSPARENT)
|
||||
searchPlate.setPadding(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
private fun getContacts(): List<ContactEntry> {
|
||||
val resolver: ContentResolver = requireContext().contentResolver
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER
|
||||
)
|
||||
|
||||
val cursor = resolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC"
|
||||
)
|
||||
|
||||
val results = mutableListOf<ContactEntry>()
|
||||
|
||||
cursor?.use {
|
||||
val nameIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
|
||||
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
|
||||
while (it.moveToNext()) {
|
||||
val name = it.getString(nameIndex) ?: continue
|
||||
val number = it.getString(numberIndex) ?: continue
|
||||
results.add(ContactEntry(name, number))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
213
app/src/main/java/partisan/weforge/xyz/pulse/DonateFragment.kt
Normal file
|
@ -0,0 +1,213 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.fragment.app.Fragment
|
||||
import okhttp3.*
|
||||
import partisan.weforge.xyz.pulse.databinding.FragmentDonateBinding
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class DonateFragment : Fragment() {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val apiBase = "https://api.weforge.xyz/api"
|
||||
private var _binding: FragmentDonateBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private lateinit var prefs: Preferences
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentDonateBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
(requireActivity() as? MainActivity)?.setAppBarTitle(
|
||||
getString(R.string.about_name), getString(R.string.donate_name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
prefs = Preferences(requireContext())
|
||||
|
||||
// Pre-fill token field
|
||||
binding.tokenInput.setText(prefs.donationToken)
|
||||
|
||||
// Show toast and open Ko-fi
|
||||
binding.kofiButton.setOnClickListener {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.donate_toast_reminder),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
val customTab = CustomTabsIntent.Builder().build()
|
||||
customTab.launchUrl(requireContext(), Uri.parse("https://ko-fi.com/internetaddict"))
|
||||
}
|
||||
|
||||
binding.tokenDisplay.text = "token:${prefs.donationToken}"
|
||||
binding.tokenDisplay.setOnClickListener {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = android.content.ClipData.newPlainText("Ko-fi token", "token:${prefs.donationToken}")
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, getString(R.string.donate_token_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// If already donated, update UI to show activation message and hide token entry controls
|
||||
if (prefs.isDonationActivated) {
|
||||
binding.postDonatePrompt.text = getString(R.string.donate_token_activated)
|
||||
binding.openTokenSection.visibility = View.GONE
|
||||
binding.tokenSection.visibility = View.GONE
|
||||
} else {
|
||||
// Show token entry section button if not activated
|
||||
binding.openTokenSection.setOnClickListener {
|
||||
binding.tokenSection.visibility = View.VISIBLE
|
||||
binding.openTokenSection.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
binding.verifyButton.setOnClickListener {
|
||||
var token = binding.tokenInput.text.toString().trim()
|
||||
|
||||
// Strip optional "token:" prefix
|
||||
if (token.startsWith("token:")) {
|
||||
token = token.removePrefix("token:")
|
||||
}
|
||||
|
||||
// Validate token format
|
||||
if (token.length != 16) {
|
||||
Toast.makeText(context, getString(R.string.donate_token_invalid_format), Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
prefs.donationToken = token
|
||||
|
||||
if (prefs.isDonationActivated) {
|
||||
binding.resultText.text = getString(R.string.donate_token_already_activated)
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
// Step 0: Check INTERNET permission
|
||||
if (!hasInternetPermission(requireContext())) {
|
||||
binding.resultText.text = getString(R.string.donate_missing_permission)
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
// Step 1: Try activation server first
|
||||
val aliveRequest = Request.Builder().url("$apiBase/alive").build()
|
||||
client.newCall(aliveRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// If server unreachable, fallback to internet check
|
||||
val internetCheck = Request.Builder()
|
||||
.url("https://deb.debian.org/")
|
||||
.build()
|
||||
|
||||
client.newCall(internetCheck).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
activity?.runOnUiThread {
|
||||
binding.resultText.text = getString(R.string.donate_no_internet)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
activity?.runOnUiThread {
|
||||
if (!response.isSuccessful || response.body?.string().isNullOrBlank()) {
|
||||
binding.resultText.text = getString(R.string.donate_no_internet)
|
||||
} else {
|
||||
binding.resultText.text = getString(R.string.donate_server_unreachable)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (response.body?.string()?.trim() != "true") {
|
||||
activity?.runOnUiThread {
|
||||
binding.resultText.text = getString(R.string.donate_server_not_responding)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Check token
|
||||
val checkRequest = Request.Builder()
|
||||
.url("$apiBase/check?token=$token")
|
||||
.build()
|
||||
|
||||
client.newCall(checkRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
activity?.runOnUiThread {
|
||||
binding.resultText.text = getString(R.string.donate_token_check_failed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val result = response.body?.string()?.trim()
|
||||
if (result == "0") {
|
||||
activity?.runOnUiThread {
|
||||
binding.resultText.text = getString(R.string.donate_token_invalid)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Activate
|
||||
val activateRequest = Request.Builder()
|
||||
.url("$apiBase/activate?token=$token")
|
||||
.build()
|
||||
|
||||
client.newCall(activateRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
activity?.runOnUiThread {
|
||||
binding.resultText.text = getString(R.string.donate_activation_failed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val activateResult = response.body?.string()?.trim()
|
||||
if (activateResult == "success") {
|
||||
prefs.isDonationActivated = true
|
||||
activity?.runOnUiThread {
|
||||
val remaining = (result?.toIntOrNull() ?: 1) - 1
|
||||
binding.resultText.text =
|
||||
getString(R.string.donate_token_activated) + "\n" +
|
||||
getString(R.string.donate_token_left, remaining.toString())
|
||||
}
|
||||
} else {
|
||||
activity?.runOnUiThread {
|
||||
binding.resultText.text = getString(R.string.donate_activation_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun hasInternetPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.INTERNET
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
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
|
||||
}
|
||||
}
|
||||
updateDonationIcon()
|
||||
setContentView(binding.root)
|
||||
prefs = Preferences(this)
|
||||
setSupportActionBar(binding.topAppBar)
|
||||
|
||||
private fun setup() {
|
||||
binding.apply {
|
||||
redirectionDelay.setLabelFormatter {
|
||||
String.format("%.1f", it)
|
||||
}
|
||||
redirectionDelay.addOnChangeListener { _, value, _ ->
|
||||
prefs.redirectionDelay = (value * 1000).toLong()
|
||||
}
|
||||
popupPosition.setEndIconOnClickListener {
|
||||
window.preview()
|
||||
}
|
||||
popupPosition.editText?.doAfterTextChanged {
|
||||
try {
|
||||
prefs.popupPosition = it?.toString()?.toInt() ?: return@doAfterTextChanged
|
||||
} catch (exc: NumberFormatException) {}
|
||||
}
|
||||
toggle.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked && !hasPermissions()) {
|
||||
toggle.isChecked = false
|
||||
requestPermissions()
|
||||
return@setOnCheckedChangeListener
|
||||
}
|
||||
prefs.isEnabled = isChecked
|
||||
}
|
||||
val services = listOf(
|
||||
ServiceEntry("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call", R.string.destination_signal, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call")),
|
||||
ServiceEntry("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call", R.string.destination_telegram, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call")),
|
||||
ServiceEntry("vnd.android.cursor.item/vnd.ch.threema.app.call", R.string.destination_threema, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.ch.threema.app.call")),
|
||||
ServiceEntry("vnd.android.cursor.item/vnd.com.whatsapp.voip.call", R.string.destination_whatsapp, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.com.whatsapp.voip.call")),
|
||||
val 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()
|
||||
|
||||
val adapter = ServiceAdapter(
|
||||
context = this@MainActivity,
|
||||
services = services.toMutableList(),
|
||||
onReordered = { updatedList ->
|
||||
updatedList.forEachIndexed { index, entry ->
|
||||
setServicePriority(entry.mimetype, index)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.topbar_toggle, menu)
|
||||
popupMenuItem = menu.findItem(R.id.globalPopupToggle)
|
||||
popupSwitch = popupMenuItem?.actionView?.findViewById(R.id.globalPopupToggle)
|
||||
popupMenuItem?.isVisible = false // hide by default
|
||||
return true
|
||||
}
|
||||
|
||||
fun setupPopupToggle(
|
||||
visible: Boolean,
|
||||
initialState: Boolean = false,
|
||||
onToggle: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
popupMenuItem?.isVisible = visible
|
||||
popupSwitch?.apply {
|
||||
setOnCheckedChangeListener(null)
|
||||
isChecked = initialState
|
||||
setOnCheckedChangeListener { _, isChecked ->
|
||||
onToggle?.invoke(isChecked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun 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
|
||||
)
|
||||
binding.serviceRecycler.adapter = adapter
|
||||
binding.serviceRecycler.layoutManager = LinearLayoutManager(this@MainActivity)
|
||||
|
||||
val touchHelper = ItemTouchHelper(adapter.dragHelper)
|
||||
touchHelper.attachToRecyclerView(binding.serviceRecycler)
|
||||
|
||||
adapter.setDragStartListener { viewHolder ->
|
||||
touchHelper.startDrag(viewHolder)
|
||||
}
|
||||
|
||||
// binding.serviceConfigList.removeAllViews()
|
||||
// for ((mimetype, labelRes) in mimetypes) {
|
||||
// val checkbox = CheckBox(this@MainActivity).apply {
|
||||
// text = getString(labelRes)
|
||||
// isChecked = this@MainActivity.isServiceEnabled(mimetype)
|
||||
// setOnCheckedChangeListener { _, checked ->
|
||||
// this@MainActivity.setServiceEnabled(mimetype, checked)
|
||||
// }
|
||||
// }
|
||||
|
||||
// val priorityInput = EditText(this@MainActivity).apply {
|
||||
// inputType = InputType.TYPE_CLASS_NUMBER
|
||||
// setEms(4)
|
||||
// hint = "Priority"
|
||||
// setText(this@MainActivity.getServicePriority(mimetype).toString())
|
||||
// setOnFocusChangeListener { _, hasFocus ->
|
||||
// if (!hasFocus) {
|
||||
// val value = text.toString().toIntOrNull()
|
||||
// if (value != null) this@MainActivity.setServicePriority(mimetype, value)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// val row = LinearLayout(this@MainActivity).apply {
|
||||
// orientation = LinearLayout.HORIZONTAL
|
||||
// setPadding(0, 16, 0, 16)
|
||||
// addView(checkbox, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
|
||||
// addView(priorityInput, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
|
||||
// }
|
||||
|
||||
// binding.serviceConfigList.addView(row)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
when {
|
||||
!hasGeneralPermissions() -> requestGeneralPermissions()
|
||||
!hasDrawOverlays() -> requestDrawOverlays()
|
||||
!hasCallRedirectionRole() -> requestCallRedirectionRole()
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
67
app/src/main/java/partisan/weforge/xyz/pulse/MainFragment.kt
Normal file
|
@ -0,0 +1,67 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import nl.dionsegijn.konfetti.core.Party
|
||||
import nl.dionsegijn.konfetti.core.Position
|
||||
import nl.dionsegijn.konfetti.core.emitter.Emitter
|
||||
import nl.dionsegijn.konfetti.xml.KonfettiView
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainFragment : Fragment() {
|
||||
|
||||
private lateinit var prefs: Preferences
|
||||
private var lastConfettiTime = 0L
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
(requireActivity() as? MainActivity)?.setAppBarTitle(getString(R.string.app_name))
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = inflater.inflate(R.layout.fragment_main, container, false)
|
||||
prefs = Preferences(requireContext())
|
||||
|
||||
val toggle = view.findViewById<MaterialButton>(R.id.toggle)
|
||||
val konfetti = view.findViewById<KonfettiView>(R.id.confettiView)
|
||||
|
||||
toggle.isCheckable = true
|
||||
toggle.isChecked = prefs.isServiceEnabledByUser
|
||||
|
||||
toggle.setOnClickListener {
|
||||
// the button toggles itself internally since it's checkable
|
||||
val isNowChecked = toggle.isChecked
|
||||
prefs.isServiceEnabledByUser = isNowChecked
|
||||
|
||||
if (isNowChecked && SystemClock.elapsedRealtime() - lastConfettiTime > 500) {
|
||||
konfetti.start(
|
||||
Party(
|
||||
emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).perSecond(100),
|
||||
speed = 30f,
|
||||
maxSpeed = 40f,
|
||||
damping = 0.85f,
|
||||
spread = 360,
|
||||
position = Position.Relative(0.5, 0.5)
|
||||
)
|
||||
)
|
||||
lastConfettiTime = SystemClock.elapsedRealtime()
|
||||
}
|
||||
|
||||
toggle.post {
|
||||
toggle.jumpDrawablesToCurrentState()
|
||||
toggle.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
27
app/src/main/java/partisan/weforge/xyz/pulse/Permissions.kt
Normal file
|
@ -0,0 +1,27 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.Settings
|
||||
import android.app.role.RoleManager
|
||||
|
||||
val REQUIRED_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.CALL_PHONE,
|
||||
)
|
||||
|
||||
fun hasGeneralPermissions(context: Context): Boolean {
|
||||
return REQUIRED_PERMISSIONS.all {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
fun hasDrawOverlays(context: Context): Boolean {
|
||||
return Settings.canDrawOverlays(context)
|
||||
}
|
||||
|
||||
fun hasCallRedirectionRole(context: Context): Boolean {
|
||||
val roleManager = context.getSystemService(RoleManager::class.java)
|
||||
return roleManager?.isRoleHeld(RoleManager.ROLE_CALL_REDIRECTION) ?: false
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AdapterView
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.Spinner
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.core.content.getSystemService
|
||||
import partisan.weforge.xyz.pulse.databinding.FragmentPopupSettingsBinding
|
||||
|
||||
class PopupSettingsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPopupSettingsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var prefs: Preferences
|
||||
private lateinit var window: PopupWindow
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentPopupSettingsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateSpinner()
|
||||
(requireActivity() as? MainActivity)?.apply {
|
||||
setAppBarTitle(getString(R.string.settings_name), getString(R.string.popup_name))
|
||||
setupPopupToggle(true, prefs.popupEnabled) { isChecked ->
|
||||
prefs.popupEnabled = isChecked
|
||||
updateControls(isChecked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(requireActivity() as? MainActivity)?.setupPopupToggle(false)
|
||||
}
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
prefs = Preferences(requireContext())
|
||||
window = PopupWindow(requireContext(), null)
|
||||
|
||||
binding.popupPreview.setOnClickListener {
|
||||
window.preview(false)
|
||||
}
|
||||
binding.popupPreview.setOnLongClickListener {
|
||||
window.preview(true)
|
||||
true
|
||||
}
|
||||
|
||||
binding.redirectionDelay.value = (prefs.redirectionDelay / 1000).toFloat()
|
||||
binding.redirectionDelay.setLabelFormatter {
|
||||
String.format("%.1f", it)
|
||||
}
|
||||
binding.redirectionDelay.addOnChangeListener { _, value, _ ->
|
||||
prefs.redirectionDelay = (value * 1000).toLong()
|
||||
}
|
||||
|
||||
updateSpinner()
|
||||
|
||||
val screenHeight = getScreenHeightPx()
|
||||
binding.popupHeightSlider.valueFrom = 0f
|
||||
binding.popupHeightSlider.valueTo = screenHeight.toFloat()
|
||||
binding.popupHeightSlider.value = prefs.popupPosition.toFloat()
|
||||
binding.popupHeightSlider.addOnChangeListener { _, value, _ ->
|
||||
prefs.popupPosition = value.toInt().coerceIn(0, screenHeight)
|
||||
}
|
||||
|
||||
updateControls(prefs.popupEnabled)
|
||||
}
|
||||
|
||||
private fun updateControls(enabled: Boolean) {
|
||||
binding.redirectionDelay.isEnabled = enabled
|
||||
binding.popupHeightSlider.isEnabled = enabled
|
||||
binding.popupPreview.isEnabled = enabled
|
||||
binding.popupEffectSpinner.isEnabled = enabled
|
||||
binding.popupEffectLabel.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun getScreenHeightPx(): Int {
|
||||
val wm = requireContext().getSystemService<android.view.WindowManager>()!!
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val bounds: Rect = wm.currentWindowMetrics.bounds
|
||||
bounds.height()
|
||||
} else {
|
||||
val metrics = DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
requireActivity().windowManager.defaultDisplay.getMetrics(metrics)
|
||||
metrics.heightPixels
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSpinner() {
|
||||
val allEffects = Preferences.PopupEffect.values()
|
||||
val effectLabels = resources.getStringArray(R.array.popup_effects)
|
||||
|
||||
val availableEffects = prefs.getAvailablePopupEffects() + listOf(
|
||||
Preferences.PopupEffect.NONE,
|
||||
Preferences.PopupEffect.RANDOM
|
||||
)
|
||||
|
||||
val displayNames = allEffects.mapIndexed { index, effect ->
|
||||
val baseName = effectLabels.getOrElse(index) { effect.name }
|
||||
if (!prefs.isDonationActivated && effect !in availableEffects)
|
||||
"$baseName 🔒"
|
||||
else
|
||||
baseName
|
||||
}
|
||||
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, displayNames)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.popupEffectSpinner.adapter = adapter
|
||||
|
||||
binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal)
|
||||
|
||||
binding.popupEffectSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
val selectedEffect = allEffects[position]
|
||||
if (!prefs.isDonationActivated && selectedEffect !in prefs.getAvailablePopupEffects()) {
|
||||
Toast.makeText(requireContext(), getString(R.string.donate_lock), Toast.LENGTH_SHORT).show()
|
||||
binding.popupEffectSpinner.setSelection(prefs.popupEffect.ordinal)
|
||||
} else {
|
||||
prefs.popupEffect = selectedEffect
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>) {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
|
@ -3,30 +3,49 @@ package partisan.weforge.xyz.pulse
|
|||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Rect
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.widget.TextView
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.ProgressBar
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.res.use
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import kotlin.concurrent.timerTask
|
||||
import android.util.Log
|
||||
import android.content.res.ColorStateList
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import partisan.weforge.xyz.pulse.Preferences.PopupEffect
|
||||
import partisan.weforge.xyz.pulse.MatrixRainView
|
||||
|
||||
class PopupWindow(
|
||||
ctx: Context,
|
||||
private val service: WeakReference<CallRedirectionService>?,
|
||||
) {
|
||||
private val themedCtx = ContextThemeWrapper(ctx, R.style.Theme_Pulse)
|
||||
private val themedCtx = DynamicColors.wrapContextIfAvailable(
|
||||
ContextThemeWrapper(ctx, R.style.Theme_Pulse)
|
||||
)
|
||||
private val prefs = Preferences(themedCtx)
|
||||
private val windowManager = themedCtx.getSystemService(WindowManager::class.java)
|
||||
private val audioManager = themedCtx.getSystemService(AudioManager::class.java)
|
||||
private val view = LayoutInflater.from(themedCtx).inflate(R.layout.popup, null)
|
||||
private val inflater = LayoutInflater.from(themedCtx)
|
||||
private val view = inflater.inflate(R.layout.popup, null)
|
||||
private val layoutParams = WindowManager.LayoutParams().apply {
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
||||
|
@ -36,6 +55,9 @@ class PopupWindow(
|
|||
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
y = prefs.popupPosition
|
||||
}
|
||||
private var currentEffect: PopupEffect = PopupEffect.NONE
|
||||
private var matrixOverlay: View? = null
|
||||
private var gamerAnimator: ValueAnimator? = null
|
||||
private var timer: Timer? = null
|
||||
|
||||
init {
|
||||
|
@ -43,50 +65,65 @@ class PopupWindow(
|
|||
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 {
|
||||
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()) {
|
||||
service.placeCallUnmodified()
|
||||
return@timerTask
|
||||
svc.placeCallUnmodified()
|
||||
return@post
|
||||
}
|
||||
if (audioManager?.mode != AudioManager.MODE_IN_CALL) {
|
||||
service.placeCallUnmodified()
|
||||
return@timerTask
|
||||
svc.placeCallUnmodified()
|
||||
return@post
|
||||
}
|
||||
try {
|
||||
call(uri)
|
||||
} catch (exc: SecurityException) {
|
||||
service.placeCallUnmodified()
|
||||
return@timerTask
|
||||
svc.placeCallUnmodified()
|
||||
return@post
|
||||
}
|
||||
svc.cancelCall()
|
||||
}
|
||||
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 {
|
||||
private fun remove(onRemoved: (() -> Unit)? = null): Boolean {
|
||||
return try {
|
||||
animateDisappear {
|
||||
try {
|
||||
windowManager?.removeView(view)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
} catch (_: WindowManager.BadTokenException) {
|
||||
return false
|
||||
matrixOverlay?.let {
|
||||
try {
|
||||
windowManager?.removeViewImmediate(it)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Failed to remove matrix overlay", e)
|
||||
}
|
||||
matrixOverlay = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Failed to remove popup view", e)
|
||||
}
|
||||
onRemoved?.invoke()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Exception during remove()", e)
|
||||
false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
Log.d("PopupWindow", "Cancel called")
|
||||
timer?.cancel()
|
||||
remove()
|
||||
}
|
||||
|
||||
private fun applyResolvedColors(view: android.view.View) {
|
||||
val attrSurface = com.google.android.material.R.attr.colorSurface
|
||||
val attrOnSurface = com.google.android.material.R.attr.colorOnSurface
|
||||
val attrPrimary = com.google.android.material.R.attr.colorPrimaryVariant
|
||||
|
||||
try {
|
||||
themedCtx.obtainStyledAttributes(intArrayOf(attrSurface, attrOnSurface, attrPrimary)).use { ta ->
|
||||
val surface = ta.getColor(0, Color.LTGRAY)
|
||||
val onSurface = ta.getColor(1, Color.DKGRAY)
|
||||
val primary = ta.getColor(2, Color.DKGRAY)
|
||||
|
||||
(view as? MaterialCardView)?.setCardBackgroundColor(surface)
|
||||
view.findViewById<TextView>(R.id.description)?.setTextColor(onSurface)
|
||||
|
||||
view.findViewById<ProgressBar>(R.id.progress)?.let { bar ->
|
||||
bar.progressTintList = ColorStateList.valueOf(primary)
|
||||
bar.progressBackgroundTintList = ColorStateList.valueOf(
|
||||
primary and 0x00FFFFFF or (0x40 shl 24)
|
||||
)
|
||||
}
|
||||
|
||||
view.invalidate()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupTheme", "Color resolution error: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,24 +4,84 @@ import android.content.Context
|
|||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
|
||||
class Preferences(ctx: Context) {
|
||||
class Preferences(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val ENABLED = "enabled"
|
||||
private const val REDIRECTION_DELAY = "redirection_delay"
|
||||
private const val POPUP_POSITION = "popup_position_y"
|
||||
private const val POPUP_ENABLED = "popup_enabled"
|
||||
private val POPUP_EFFECT = "popup_effect"
|
||||
private const val BLACKLISTED_CONTACTS = "blacklisted_contacts"
|
||||
private const val BLACKLIST_ENABLED = "blacklist_enabled"
|
||||
private val SERVICE_ORDER_KEY = "service_order"
|
||||
private const val DONATION_ACTIVATED = "donation_activated"
|
||||
private const val DONATION_TOKEN = "donation_token"
|
||||
|
||||
private const val REDIRECT_WIFI = "redirect_wifi"
|
||||
private const val REDIRECT_DATA = "redirect_data"
|
||||
private const val REDIRECT_INTERNATIONAL = "redirect_international"
|
||||
private const val REDIRECT_ROAMING = "redirect_roaming"
|
||||
|
||||
private const val DEFAULT_REDIRECTION_DELAY = 2000L
|
||||
private const val DEFAULT_POPUP_POSITION = 333
|
||||
|
||||
// migration
|
||||
private const val SERVICE_ENABLED = "service_enabled"
|
||||
}
|
||||
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
var isEnabled: Boolean
|
||||
get() = prefs.getBoolean(ENABLED, prefs.getBoolean(SERVICE_ENABLED, false))
|
||||
set(value) = prefs.edit { putBoolean(ENABLED, value) }
|
||||
// Whether user enabled/disabled the service manually by tiggle button
|
||||
var isServiceEnabledByUser: Boolean
|
||||
get() = prefs.getBoolean(SERVICE_ENABLED, true)
|
||||
set(value) = prefs.edit { putBoolean(SERVICE_ENABLED, value) }
|
||||
|
||||
// True only if all required permissions + toggle are satisfied
|
||||
val isEnabled: Boolean
|
||||
get() = isServiceEnabledByUser &&
|
||||
hasGeneralPermissions(context) &&
|
||||
hasDrawOverlays(context) &&
|
||||
hasCallRedirectionRole(context)
|
||||
|
||||
enum class PopupEffect {
|
||||
NONE, FADE, SCALE, BOUNCE, FLOP, MATRIX, SLIDE_SNAP, GAMER_MODE, RANDOM
|
||||
}
|
||||
|
||||
var popupEffect: PopupEffect
|
||||
get() {
|
||||
val name = prefs.getString(POPUP_EFFECT, PopupEffect.FADE.name) ?: PopupEffect.FADE.name
|
||||
return try {
|
||||
PopupEffect.valueOf(name)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// If invalid, fallback and clear the broken value
|
||||
prefs.edit().remove(POPUP_EFFECT).apply()
|
||||
PopupEffect.BOUNCE
|
||||
}
|
||||
}
|
||||
set(value) {
|
||||
prefs.edit().putString(POPUP_EFFECT, value.name).apply()
|
||||
}
|
||||
|
||||
var isBlacklistEnabled: Boolean
|
||||
get() = prefs.getBoolean(BLACKLIST_ENABLED, false)
|
||||
set(value) = prefs.edit { putBoolean(BLACKLIST_ENABLED, value) }
|
||||
|
||||
var isDonationActivated: Boolean
|
||||
get() = prefs.getBoolean(DONATION_ACTIVATED, false)
|
||||
set(value) = prefs.edit { putBoolean(DONATION_ACTIVATED, value) }
|
||||
|
||||
var donationToken: String
|
||||
get() {
|
||||
val stored = prefs.getString(DONATION_TOKEN, null)
|
||||
return if (stored != null) {
|
||||
stored
|
||||
} else {
|
||||
generateAndStoreToken()
|
||||
}
|
||||
}
|
||||
set(value) = prefs.edit { putString(DONATION_TOKEN, value) }
|
||||
|
||||
var popupEnabled: Boolean
|
||||
get() = prefs.getBoolean(POPUP_ENABLED, true)
|
||||
set(value) = prefs.edit { putBoolean(POPUP_ENABLED, value) }
|
||||
|
||||
var redirectionDelay: Long
|
||||
get() = prefs.getLong(REDIRECTION_DELAY, DEFAULT_REDIRECTION_DELAY)
|
||||
|
@ -31,26 +91,87 @@ class Preferences(ctx: Context) {
|
|||
get() = prefs.getInt(POPUP_POSITION, DEFAULT_POPUP_POSITION)
|
||||
set(value) = prefs.edit { putInt(POPUP_POSITION, value) }
|
||||
|
||||
var redirectOnWifi: Boolean
|
||||
get() = prefs.getBoolean(REDIRECT_WIFI, true)
|
||||
set(value) = prefs.edit { putBoolean(REDIRECT_WIFI, value) }
|
||||
|
||||
var redirectOnData: Boolean
|
||||
get() = prefs.getBoolean(REDIRECT_DATA, true)
|
||||
set(value) = prefs.edit { putBoolean(REDIRECT_DATA, value) }
|
||||
|
||||
var redirectInternationalOnly: Boolean
|
||||
get() = prefs.getBoolean(REDIRECT_INTERNATIONAL, false)
|
||||
set(value) = prefs.edit { putBoolean(REDIRECT_INTERNATIONAL, value) }
|
||||
|
||||
var redirectIfRoaming: Boolean
|
||||
get() = prefs.getBoolean(REDIRECT_ROAMING, false)
|
||||
set(value) = prefs.edit { putBoolean(REDIRECT_ROAMING, value) }
|
||||
|
||||
private fun makeKeyEnabled(mimetype: String) = "enabled_$mimetype"
|
||||
private fun makeKeyPriority(mimetype: String) = "priority_$mimetype"
|
||||
|
||||
/** Whether this service is enabled */
|
||||
fun getAvailablePopupEffects(): List<PopupEffect> {
|
||||
val locked = listOf(
|
||||
PopupEffect.FLOP,
|
||||
PopupEffect.MATRIX,
|
||||
PopupEffect.SLIDE_SNAP,
|
||||
PopupEffect.GAMER_MODE
|
||||
)
|
||||
return PopupEffect.values().filter {
|
||||
isDonationActivated || it !in locked
|
||||
}.filter { it != PopupEffect.RANDOM && it != PopupEffect.NONE }
|
||||
}
|
||||
|
||||
fun isServiceEnabled(mimetype: String): Boolean {
|
||||
return prefs.getBoolean(makeKeyEnabled(mimetype), true)
|
||||
}
|
||||
|
||||
/** Current priority for this service (lower = higher priority) */
|
||||
fun getServicePriority(mimetype: String): Int {
|
||||
return prefs.getInt(makeKeyPriority(mimetype), Int.MAX_VALUE)
|
||||
val order = getServiceOrder()
|
||||
val index = order.indexOf(mimetype)
|
||||
return if (index != -1) index else Int.MAX_VALUE
|
||||
}
|
||||
|
||||
fun getServiceOrder(): List<String> {
|
||||
val stored = prefs.getString(SERVICE_ORDER_KEY, null)
|
||||
return stored?.split("|")?.filter { it.isNotBlank() } ?: emptyList()
|
||||
}
|
||||
|
||||
fun setServiceOrder(order: List<String>) {
|
||||
prefs.edit().putString(SERVICE_ORDER_KEY, order.joinToString("|")).apply()
|
||||
}
|
||||
|
||||
/** Enable or disable individual service */
|
||||
fun setServiceEnabled(mimetype: String, enabled: Boolean) {
|
||||
prefs.edit().putBoolean(makeKeyEnabled(mimetype), enabled).apply()
|
||||
}
|
||||
|
||||
/** Change priority for an individual service */
|
||||
fun setServicePriority(mimetype: String, priority: Int) {
|
||||
prefs.edit().putInt(makeKeyPriority(mimetype), priority).apply()
|
||||
}
|
||||
|
||||
var blacklistedContacts: Set<String>
|
||||
get() = prefs.getStringSet(BLACKLISTED_CONTACTS, emptySet()) ?: emptySet()
|
||||
set(value) = prefs.edit { putStringSet(BLACKLISTED_CONTACTS, value) }
|
||||
|
||||
fun isContactWhitelisted(phoneNumber: String): Boolean {
|
||||
return !blacklistedContacts.contains(phoneNumber)
|
||||
}
|
||||
|
||||
fun setContactWhitelisted(phoneNumber: String, allowed: Boolean) {
|
||||
val current = blacklistedContacts.toMutableSet()
|
||||
if (!allowed) {
|
||||
current.add(phoneNumber)
|
||||
} else {
|
||||
current.remove(phoneNumber)
|
||||
}
|
||||
blacklistedContacts = current
|
||||
}
|
||||
|
||||
private fun generateAndStoreToken(): String {
|
||||
val newToken = (1..16)
|
||||
.map { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".random() }
|
||||
.joinToString("")
|
||||
prefs.edit().putString(DONATION_TOKEN, newToken).apply()
|
||||
return newToken
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
|
||||
class RedirectSettingsFragment : Fragment() {
|
||||
|
||||
private lateinit var prefs: Preferences
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
prefs = Preferences(requireContext())
|
||||
return inflater.inflate(R.layout.fragment_redirect_settings, container, false)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
(requireActivity() as? MainActivity)?.setAppBarTitle(
|
||||
getString(R.string.settings_name), getString(R.string.redirect_name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val switchWifi = view.findViewById<MaterialSwitch>(R.id.switchRedirectWifi)
|
||||
val switchData = view.findViewById<MaterialSwitch>(R.id.switchRedirectData)
|
||||
val switchInternational = view.findViewById<MaterialSwitch>(R.id.switchRedirectInternational)
|
||||
val switchRoaming = view.findViewById<MaterialSwitch>(R.id.switchRedirectRoaming)
|
||||
|
||||
// Load saved state
|
||||
switchWifi.isChecked = prefs.redirectOnWifi
|
||||
switchData.isChecked = prefs.redirectOnData
|
||||
switchInternational.isChecked = prefs.redirectInternationalOnly
|
||||
switchRoaming.isChecked = prefs.redirectIfRoaming
|
||||
|
||||
// Save on toggle
|
||||
switchWifi.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.redirectOnWifi = isChecked
|
||||
}
|
||||
switchData.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.redirectOnData = isChecked
|
||||
}
|
||||
switchInternational.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.redirectInternationalOnly = isChecked
|
||||
}
|
||||
switchRoaming.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.redirectIfRoaming = isChecked
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
902
app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt
Normal file
|
@ -0,0 +1,902 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.view.Choreographer
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import kotlin.math.*
|
||||
import kotlin.random.Random
|
||||
|
||||
class SecretView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs), Choreographer.FrameCallback {
|
||||
|
||||
private val bulletPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
strokeWidth = 6f
|
||||
}
|
||||
private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
alpha = 40
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val playerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 4f
|
||||
}
|
||||
private val enemyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 4f
|
||||
}
|
||||
private val rocketPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.RED
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 3f
|
||||
}
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = 64f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
private val retryRect = RectF()
|
||||
private val retryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.DKGRAY
|
||||
}
|
||||
private val retryTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = 48f
|
||||
}
|
||||
private var colorSecondary: Int = Color.GREEN
|
||||
|
||||
private var playerX = 0f
|
||||
private var viewWidth = 0f
|
||||
private var viewHeight = 0f
|
||||
|
||||
private var enemyClearAggression = 0f
|
||||
private var adaptiveSpawnTimer = 0L
|
||||
private var lastEnemyCount = 0
|
||||
private var avgEnemiesPerSecond = 0f
|
||||
|
||||
private var isTouching = false
|
||||
private var shieldRechargeTimer = 0L
|
||||
private var shieldFlashAlpha = 0f
|
||||
private var lastMissileSide = -1
|
||||
private var missileCooldown = 0L
|
||||
private var bulletCooldownMs = 0L
|
||||
private var gameOver = false
|
||||
private var score = 0
|
||||
|
||||
// Player level
|
||||
private var multiFireLevel = 1
|
||||
private var piercingLevel = 1
|
||||
private var shieldLevel = 0
|
||||
private var missileLevel = 0
|
||||
private var rapidFireLevel = 1
|
||||
|
||||
private val bullets = mutableListOf<Bullet>()
|
||||
private val enemyBullets = mutableListOf<Bullet>()
|
||||
private val enemies = mutableListOf<Enemy>()
|
||||
private val rockets = mutableListOf<Rocket>()
|
||||
private val stars = mutableListOf<Star>()
|
||||
private val explosions = mutableListOf<Explosion>()
|
||||
private val rocketTrails = mutableListOf<Pair<Float, Float>>()
|
||||
private val pickups = mutableListOf<Pickup>()
|
||||
private val playerMissiles = mutableListOf<PlayerMissile>()
|
||||
|
||||
private data class Bullet(var x: Float, var y: Float, val dy: Float = -15f, var life: Int = 1)
|
||||
private data class Star(var x: Float, var y: Float, val radius: Float, val speed: Float)
|
||||
private data class Rocket(var x: Float, var y: Float, var angle: Float, val trail: MutableList<Pair<Float, Float>> = mutableListOf())
|
||||
private data class Explosion(var x: Float, var y: Float, var timer: Int = 12)
|
||||
private data class Pickup(
|
||||
val x: Float,
|
||||
var y: Float,
|
||||
val type: Int,
|
||||
var hue: Float = Random.nextFloat() * 360f,
|
||||
)
|
||||
private data class PlayerMissile(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
var angle: Float,
|
||||
var ttl: Long = 90000L,
|
||||
var target: Enemy? = null,
|
||||
var recheckCooldown: Long = 0L,
|
||||
var side: Int, // -1 = left, 1 = right
|
||||
val trail: MutableList<Pair<Float, Float>> = mutableListOf()
|
||||
)
|
||||
|
||||
private var lastLogicTime = 0L
|
||||
private val logicStepMs = 16L
|
||||
|
||||
private var waveTimer = 0L
|
||||
private var currentWave = 0
|
||||
private var enemiesLeftInWave = 0
|
||||
private var currentWaveType = ""
|
||||
|
||||
init {
|
||||
for (i in 0 until 50) {
|
||||
stars.add(Star(Random.nextFloat() * 1080f, Random.nextFloat() * 1920f, Random.nextFloat() * 2f + 1f, Random.nextFloat() * 2f + 0.5f))
|
||||
}
|
||||
|
||||
val colorAttrs = intArrayOf(
|
||||
com.google.android.material.R.attr.colorPrimaryVariant,
|
||||
com.google.android.material.R.attr.colorSecondary
|
||||
)
|
||||
context.obtainStyledAttributes(colorAttrs).use {
|
||||
playerPaint.color = it.getColor(0, Color.CYAN)
|
||||
enemyPaint.color = it.getColor(0, Color.CYAN)
|
||||
colorSecondary = it.getColor(1, Color.GREEN)
|
||||
}
|
||||
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
lastLogicTime = System.nanoTime()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
viewWidth = w.toFloat()
|
||||
viewHeight = h.toFloat()
|
||||
// Kinda hacky way to center things, but I'd rather do this here than in update(), since it can't be in init() as the screen size isn't initialized at that point.
|
||||
playerX = viewWidth / 2f
|
||||
retryRect.set(viewWidth / 2f - 120f, viewHeight / 2f + 60f, viewWidth / 2f + 120f, viewHeight / 2f + 130f)
|
||||
}
|
||||
|
||||
override fun doFrame(frameTimeNanos: Long) {
|
||||
if (!gameOver) {
|
||||
val now = System.nanoTime()
|
||||
while ((now - lastLogicTime) / 1_000_000 >= logicStepMs) {
|
||||
update(logicStepMs)
|
||||
lastLogicTime += logicStepMs * 1_000_000
|
||||
}
|
||||
invalidate()
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(deltaMs: Long) {
|
||||
stars.forEach {
|
||||
it.y += it.speed * deltaMs / 16f
|
||||
if (it.y > viewHeight) {
|
||||
it.y = 0f
|
||||
it.x = Random.nextFloat() * viewWidth
|
||||
}
|
||||
}
|
||||
|
||||
// Update shield recharge timer and flash animation
|
||||
if (shieldRechargeTimer > 0) {
|
||||
shieldRechargeTimer -= deltaMs
|
||||
if (shieldRechargeTimer <= 0) {
|
||||
shieldRechargeTimer = 0
|
||||
shieldFlashAlpha = 1f // trigger visual flash on recharge
|
||||
}
|
||||
}
|
||||
|
||||
if (shieldFlashAlpha > 0f) {
|
||||
shieldFlashAlpha -= deltaMs / 300f
|
||||
if (shieldFlashAlpha < 0f) shieldFlashAlpha = 0f
|
||||
}
|
||||
|
||||
rockets.forEach { rocket ->
|
||||
rocket.trail.add(0, rocket.x to rocket.y)
|
||||
if (rocket.trail.size > 20) {
|
||||
rocket.trail.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
explosions.forEach { it.timer-- }
|
||||
explosions.removeIf { it.timer <= 0 }
|
||||
|
||||
bullets.forEach { it.y += it.dy * deltaMs / 16f }
|
||||
bullets.removeIf { it.y < 0 || it.life <= 0 }
|
||||
|
||||
bulletCooldownMs -= deltaMs
|
||||
if (isTouching && bulletCooldownMs <= 0) {
|
||||
val baseCooldown = 450f
|
||||
var multiplier = 1f
|
||||
for (level in 2..rapidFireLevel) {
|
||||
val reduction = ((36 - level * 1.2f).coerceAtLeast(4f)) / 100f
|
||||
multiplier *= (1f - reduction)
|
||||
}
|
||||
bulletCooldownMs = max(50L, (baseCooldown * multiplier).toLong())
|
||||
|
||||
val baseY = viewHeight - 100f
|
||||
val spacing = 10f
|
||||
val count = multiFireLevel
|
||||
|
||||
for (i in 0 until count) {
|
||||
val offset = (i - (count - 1) / 2f) * spacing
|
||||
bullets.add(Bullet(playerX + offset, baseY))
|
||||
}
|
||||
}
|
||||
|
||||
enemies.forEach {
|
||||
it.update(deltaMs)
|
||||
it.x = max(20f, min(it.x, viewWidth - 20f))
|
||||
if (it.y > viewHeight + 100f) it.y = -40f // respawn at top
|
||||
}
|
||||
|
||||
pickups.forEach {
|
||||
it.y += 2.5f * deltaMs / 16f
|
||||
it.hue = (it.hue + deltaMs * 0.01f) % 360f
|
||||
}
|
||||
pickups.removeIf { it.y > viewHeight - 40f }
|
||||
|
||||
rockets.forEach {
|
||||
val targetAngle = atan2(viewHeight - 100f - it.y, playerX - it.x)
|
||||
it.angle += ((targetAngle - it.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f
|
||||
it.x += cos(it.angle) * 6f
|
||||
it.y += sin(it.angle) * 6f
|
||||
}
|
||||
rockets.removeIf { it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight }
|
||||
|
||||
enemyBullets.forEach { it.y += it.dy * deltaMs / 16f }
|
||||
enemyBullets.removeIf { it.y > viewHeight }
|
||||
|
||||
updatePlayerMissiles(deltaMs)
|
||||
|
||||
checkCollisions()
|
||||
spawnEnemies(deltaMs)
|
||||
}
|
||||
|
||||
private fun checkCollisions() {
|
||||
val pickupIter = pickups.iterator()
|
||||
while (pickupIter.hasNext()) {
|
||||
val p = pickupIter.next()
|
||||
if (hypot(playerX - p.x, viewHeight - 100f - p.y) < 40f) {
|
||||
when (p.type) {
|
||||
0 -> { // Multi-fire
|
||||
if (multiFireLevel < 4) {
|
||||
multiFireLevel++
|
||||
} else {
|
||||
applyRandomPowerupExcept(0)
|
||||
}
|
||||
}
|
||||
1 -> shieldLevel++
|
||||
2 -> if (missileLevel < 8) missileLevel++
|
||||
3 -> if (rapidFireLevel < 30) rapidFireLevel++
|
||||
4 -> if (piercingLevel < 15) piercingLevel++
|
||||
}
|
||||
pickupIter.remove()
|
||||
}
|
||||
}
|
||||
|
||||
val enemyIter = enemies.iterator()
|
||||
while (enemyIter.hasNext()) {
|
||||
val enemy = enemyIter.next()
|
||||
if (hypot(playerX - enemy.x, viewHeight - 100f - enemy.y) < 40f) {
|
||||
if (shieldLevel > 0 && shieldRechargeTimer <= 0) {
|
||||
shieldRechargeTimer = calculateShieldRechargeTime()
|
||||
enemyIter.remove()
|
||||
explosions.add(Explosion(enemy.x, enemy.y))
|
||||
continue
|
||||
} else {
|
||||
explosions.add(Explosion(playerX, viewHeight - 100f))
|
||||
gameOver = true
|
||||
return
|
||||
}
|
||||
}
|
||||
for (b in bullets) {
|
||||
if (hypot(b.x - enemy.x, b.y - enemy.y) < 30f) {
|
||||
explosions.add(Explosion(enemy.x, enemy.y))
|
||||
enemyIter.remove()
|
||||
score += 10
|
||||
b.life--
|
||||
|
||||
if (Random.nextFloat() < 0.03f) {
|
||||
val type = Random.nextInt(5) // 5 pickup types
|
||||
val hue = Random.nextFloat() * 360f
|
||||
pickups.add(Pickup(enemy.x, enemy.y, type, hue))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val bulletIter = enemyBullets.iterator()
|
||||
while (bulletIter.hasNext()) {
|
||||
val b = bulletIter.next()
|
||||
val dy = b.y - (viewHeight - 100f)
|
||||
val dx = b.x - playerX
|
||||
val dist = hypot(dx, dy)
|
||||
val hitRadius = if (shieldLevel > 0 && shieldRechargeTimer <= 0) 60f else 20f
|
||||
|
||||
if (dist < hitRadius) {
|
||||
if (shieldLevel > 0 && shieldRechargeTimer <= 0) {
|
||||
shieldRechargeTimer = calculateShieldRechargeTime()
|
||||
bulletIter.remove()
|
||||
explosions.add(Explosion(b.x, b.y))
|
||||
} else {
|
||||
explosions.add(Explosion(playerX, viewHeight - 100f))
|
||||
gameOver = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val rocketIter = rockets.iterator()
|
||||
while (rocketIter.hasNext()) {
|
||||
val rocket = rocketIter.next()
|
||||
val dy = viewHeight - 100f - rocket.y
|
||||
val dx = playerX - rocket.x
|
||||
val dist = hypot(dx, dy)
|
||||
val hitRadius = if (shieldLevel > 0 && shieldRechargeTimer <= 0) 60f else 30f
|
||||
|
||||
if (dist < hitRadius) {
|
||||
if (shieldLevel > 0 && shieldRechargeTimer <= 0) {
|
||||
shieldRechargeTimer = calculateShieldRechargeTime()
|
||||
rocketIter.remove()
|
||||
explosions.add(Explosion(rocket.x, rocket.y))
|
||||
continue
|
||||
} else {
|
||||
explosions.add(Explosion(playerX, viewHeight - 100f))
|
||||
gameOver = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (b in bullets) {
|
||||
if (hypot(b.x - rocket.x, b.y - rocket.y) < 20f) {
|
||||
explosions.add(Explosion(rocket.x, rocket.y))
|
||||
rocketIter.remove()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val missileIter = playerMissiles.iterator()
|
||||
while (missileIter.hasNext()) {
|
||||
val missile = missileIter.next()
|
||||
val enemyHit = enemies.firstOrNull { enemy ->
|
||||
hypot(missile.x - enemy.x, missile.y - enemy.y) < 30f
|
||||
}
|
||||
|
||||
if (enemyHit != null) {
|
||||
explosions.add(Explosion(enemyHit.x, enemyHit.y))
|
||||
enemies.remove(enemyHit)
|
||||
missileIter.remove()
|
||||
score += 10
|
||||
|
||||
if (Random.nextFloat() < 0.5f) {
|
||||
val type = Random.nextInt(5)
|
||||
val hue = Random.nextFloat() * 360f
|
||||
pickups.add(Pickup(enemyHit.x, enemyHit.y, type, hue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlayerMissiles(deltaMs: Long) {
|
||||
// Missile cooldown and launch
|
||||
missileCooldown -= deltaMs
|
||||
if (missileLevel > 0 && missileCooldown <= 0) {
|
||||
val cooldown = when (missileLevel) {
|
||||
1 -> 20000L
|
||||
2 -> 15000L
|
||||
3 -> 12000L
|
||||
4 -> 20000L
|
||||
5 -> 18000L
|
||||
6 -> 17000L
|
||||
7 -> 16000L
|
||||
else -> 15000L
|
||||
}
|
||||
|
||||
val baseY = viewHeight - 100f
|
||||
if (missileLevel >= 4) {
|
||||
// fire both sides
|
||||
playerMissiles.add(PlayerMissile(playerX - 20f, baseY, -PI.toFloat() / 2f, side = -1))
|
||||
playerMissiles.add(PlayerMissile(playerX + 20f, baseY, -PI.toFloat() / 2f, side = 1))
|
||||
} else {
|
||||
lastMissileSide *= -1
|
||||
val offsetX = 20f * lastMissileSide
|
||||
playerMissiles.add(PlayerMissile(playerX + offsetX, baseY, -PI.toFloat() / 2f, side = lastMissileSide))
|
||||
}
|
||||
|
||||
missileCooldown = cooldown
|
||||
}
|
||||
|
||||
// Update missiles
|
||||
val lockedEnemies = playerMissiles.mapNotNull { it.target }.toSet()
|
||||
val availableTargets = enemies.filter { it !in lockedEnemies }
|
||||
|
||||
playerMissiles.forEach { missile ->
|
||||
missile.ttl -= deltaMs
|
||||
if (missile.ttl <= 0) return@forEach
|
||||
|
||||
// Add current position to trail
|
||||
missile.trail.add(0, missile.x to missile.y)
|
||||
if (missile.trail.size > 20) {
|
||||
missile.trail.removeLast()
|
||||
}
|
||||
|
||||
if (missile.target == null || !enemies.contains(missile.target)) {
|
||||
missile.recheckCooldown -= deltaMs
|
||||
if (missile.recheckCooldown <= 0) {
|
||||
val newTarget = availableTargets.minByOrNull {
|
||||
hypot((it.x - missile.x).toDouble(), (it.y - missile.y).toDouble())
|
||||
}
|
||||
if (newTarget != null) {
|
||||
missile.target = newTarget
|
||||
} else {
|
||||
missile.recheckCooldown = 1000L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val target = missile.target
|
||||
val angleTo = if (target != null) {
|
||||
atan2(target.y - missile.y, target.x - missile.x)
|
||||
} else missile.angle
|
||||
|
||||
// steer towards target slowly
|
||||
missile.angle += ((angleTo - missile.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f
|
||||
missile.x += cos(missile.angle) * 6f
|
||||
missile.y += sin(missile.angle) * 6f
|
||||
}
|
||||
|
||||
playerMissiles.removeIf { it.ttl <= 0 || it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight }
|
||||
}
|
||||
|
||||
private fun applyRandomPowerupExcept(excludedType: Int) {
|
||||
val types = (0..4).filter { it != excludedType }
|
||||
val type = types.random()
|
||||
when (type) {
|
||||
1 -> shieldLevel++
|
||||
2 -> if (missileLevel < 8) missileLevel++
|
||||
3 -> if (rapidFireLevel < 30) rapidFireLevel++
|
||||
4 -> if (piercingLevel < 15) piercingLevel++
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCubeIconPath(): Path {
|
||||
return Path().apply {
|
||||
addRect(-10f, -10f, 10f, 10f, Path.Direction.CW)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateShieldRechargeTime(): Long {
|
||||
val base = 60000L // 60 seconds
|
||||
val min = 10000L // 10 seconds
|
||||
val reduction = (1.0 - exp(-shieldLevel / 40.0)).toFloat()
|
||||
return (base - (base - min) * reduction).toLong()
|
||||
}
|
||||
|
||||
private fun spawnEnemies(deltaMs: Long) {
|
||||
waveTimer -= deltaMs
|
||||
adaptiveSpawnTimer += deltaMs
|
||||
|
||||
// Passive asteroids
|
||||
if (Random.nextFloat() < 0.002f) {
|
||||
enemies.add(EnemyAsteroid(Random.nextFloat() * viewWidth, -40f, viewWidth))
|
||||
}
|
||||
|
||||
// Setup new wave
|
||||
if (enemiesLeftInWave <= 0 && waveTimer <= 0) {
|
||||
currentWave++
|
||||
currentWaveType = when (currentWave % 3) {
|
||||
0 -> "easy"
|
||||
1 -> "medium"
|
||||
else -> "hard"
|
||||
}
|
||||
|
||||
val difficultyBoost = when (currentWaveType) {
|
||||
"easy" -> (currentWave * 1.2f).roundToInt()
|
||||
"medium" -> (currentWave * 1.5f).roundToInt()
|
||||
"hard" -> (currentWave * 1.8f).roundToInt()
|
||||
else -> currentWave
|
||||
}
|
||||
|
||||
enemiesLeftInWave = 3 + difficultyBoost
|
||||
waveTimer = 3000L
|
||||
adaptiveSpawnTimer = 0L
|
||||
lastEnemyCount = enemies.size
|
||||
avgEnemiesPerSecond = 0f
|
||||
enemyClearAggression = 0f
|
||||
}
|
||||
|
||||
// Adjust aggression every 1s
|
||||
if (adaptiveSpawnTimer >= 1000L) {
|
||||
val cleared = (lastEnemyCount - enemies.size).coerceAtLeast(0)
|
||||
avgEnemiesPerSecond = avgEnemiesPerSecond * 0.7f + cleared * 0.3f
|
||||
lastEnemyCount = enemies.size
|
||||
adaptiveSpawnTimer = 0L
|
||||
|
||||
// Normalize to 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
104
app/src/main/java/partisan/weforge/xyz/pulse/WelcomeActivity.kt
Normal file
|
@ -0,0 +1,104 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import android.app.role.RoleManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import nl.dionsegijn.konfetti.core.Party
|
||||
import nl.dionsegijn.konfetti.core.Position
|
||||
import nl.dionsegijn.konfetti.core.emitter.Emitter
|
||||
import nl.dionsegijn.konfetti.xml.KonfettiView
|
||||
import partisan.weforge.xyz.pulse.databinding.ActivityWelcomeBinding
|
||||
import java.util.concurrent.TimeUnit
|
||||
import partisan.weforge.xyz.pulse.hasGeneralPermissions
|
||||
import partisan.weforge.xyz.pulse.hasDrawOverlays
|
||||
import partisan.weforge.xyz.pulse.hasCallRedirectionRole
|
||||
import partisan.weforge.xyz.pulse.REQUIRED_PERMISSIONS
|
||||
|
||||
class WelcomeActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityWelcomeBinding
|
||||
private lateinit var prefs: Preferences
|
||||
private var roleManager: RoleManager? = null
|
||||
|
||||
private val requestPermissionsLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) {}
|
||||
|
||||
private val requestOverlayLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {}
|
||||
|
||||
private val requestRoleLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (hasGeneralPermissions(this) && hasDrawOverlays(this) && hasCallRedirectionRole(this)) {
|
||||
startActivity(Intent(this, MainActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
binding = ActivityWelcomeBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
prefs = Preferences(this)
|
||||
roleManager = getSystemService(RoleManager::class.java)
|
||||
|
||||
binding.activateButton.setOnClickListener {
|
||||
when {
|
||||
!hasGeneralPermissions(this) -> {
|
||||
requestPermissionsLauncher.launch(REQUIRED_PERMISSIONS)
|
||||
}
|
||||
!hasDrawOverlays(this) -> {
|
||||
requestOverlayLauncher.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION))
|
||||
}
|
||||
!hasCallRedirectionRole(this) -> {
|
||||
requestRoleLauncher.launch(roleManager?.createRequestRoleIntent(RoleManager.ROLE_CALL_REDIRECTION))
|
||||
}
|
||||
else -> {
|
||||
showConfettiAndContinue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showConfettiAndContinue() {
|
||||
binding.appIcon.post {
|
||||
val iconLocation = IntArray(2)
|
||||
binding.appIcon.getLocationOnScreen(iconLocation)
|
||||
|
||||
val iconCenterX = iconLocation[0] + binding.appIcon.width / 2f
|
||||
val iconCenterY = iconLocation[1] + binding.appIcon.height / 2f
|
||||
|
||||
val rootWidth = binding.root.width.toFloat()
|
||||
val rootHeight = binding.root.height.toFloat()
|
||||
|
||||
val relativeX = (iconCenterX / rootWidth).toDouble()
|
||||
val relativeY = (iconCenterY / rootHeight).toDouble()
|
||||
|
||||
binding.konfettiView.visibility = View.VISIBLE
|
||||
binding.konfettiView.start(
|
||||
Party(
|
||||
speed = 25f,
|
||||
maxSpeed = 50f,
|
||||
damping = 0.9f,
|
||||
spread = 360,
|
||||
colors = listOf(0xfce18a, 0xff726d, 0xf4306d, 0xb48def),
|
||||
position = Position.Relative(relativeX, relativeY),
|
||||
emitter = Emitter(duration = 1, TimeUnit.SECONDS).perSecond(80)
|
||||
)
|
||||
)
|
||||
|
||||
binding.konfettiView.postDelayed({
|
||||
startActivity(Intent(this, MainActivity::class.java))
|
||||
finish()
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}
|
5
app/src/main/res/color/toggle_button_bg.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true" android:color="?attr/colorPrimary"/>
|
||||
<item android:color="?attr/colorSurfaceContainer"/>
|
||||
</selector>
|
5
app/src/main/res/color/toggle_button_icon.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true" android:color="?attr/colorOnPrimaryContainer"/>
|
||||
<item android:color="?attr/colorOnSurfaceVariant"/>
|
||||
</selector>
|
11
app/src/main/res/drawable/call_split_24px.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,800L440,496L240,296L240,400L160,400L160,160L400,160L400,240L296,240L520,464L520,800L440,800ZM594,424L536,366L664,240L560,240L560,160L800,160L800,400L720,400L720,296L594,424Z"/>
|
||||
</vector>
|
10
app/src/main/res/drawable/group_24px.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM760,800L760,680Q760,636 735.5,595.5Q711,555 666,526Q717,532 762,546.5Q807,561 846,582Q882,602 901,626.5Q920,651 920,680L920,800L760,800ZM360,480Q294,480 247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480ZM760,320Q760,386 713,433Q666,480 600,480Q589,480 572,477.5Q555,475 544,472Q571,440 585.5,401Q600,362 600,320Q600,278 585.5,239Q571,200 544,168Q558,163 572,161.5Q586,160 600,160Q666,160 713,207Q760,254 760,320ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM360,400Q393,400 416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400ZM360,720L360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Z"/>
|
||||
</vector>
|
10
app/src/main/res/drawable/heart_24.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
|
||||
</vector>
|
11
app/src/main/res/drawable/heart_filled_24.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840Z"/>
|
||||
</vector>
|
||||
|
9
app/src/main/res/drawable/ic_power_settings_new_24.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.42L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9c0,-2.74 -1.23,-5.18 -3.17,-6.83z"/>
|
||||
</vector>
|
10
app/src/main/res/drawable/info_24px.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||
</vector>
|
5
app/src/main/res/drawable/search_background.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="?attr/colorSurfaceVariant" />
|
||||
<corners android:radius="50dp" />
|
||||
</shape>
|
13
app/src/main/res/drawable/services_24.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M14.17,13.71l1.4,-2.42c0.09,-0.15 0.05,-0.34 -0.08,-0.45l-1.48,-1.16c0.03,-0.22 0.05,-0.45 0.05,-0.68s-0.02,-0.46 -0.05,-0.69l1.48,-1.16c0.13,-0.11 0.17,-0.3 0.08,-0.45l-1.4,-2.42c-0.09,-0.15 -0.27,-0.21 -0.43,-0.15L12,4.83c-0.36,-0.28 -0.75,-0.51 -1.18,-0.69l-0.26,-1.85C10.53,2.13 10.38,2 10.21,2h-2.8C7.24,2 7.09,2.13 7.06,2.3L6.8,4.15C6.38,4.33 5.98,4.56 5.62,4.84l-1.74,-0.7c-0.16,-0.06 -0.34,0 -0.43,0.15l-1.4,2.42C1.96,6.86 2,7.05 2.13,7.16l1.48,1.16C3.58,8.54 3.56,8.77 3.56,9s0.02,0.46 0.05,0.69l-1.48,1.16C2,10.96 1.96,11.15 2.05,11.3l1.4,2.42c0.09,0.15 0.27,0.21 0.43,0.15l1.74,-0.7c0.36,0.28 0.75,0.51 1.18,0.69l0.26,1.85C7.09,15.87 7.24,16 7.41,16h2.8c0.17,0 0.32,-0.13 0.35,-0.3l0.26,-1.85c0.42,-0.18 0.82,-0.41 1.18,-0.69l1.74,0.7C13.9,13.92 14.08,13.86 14.17,13.71zM8.81,11c-1.1,0 -2,-0.9 -2,-2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2C10.81,10.1 9.91,11 8.81,11z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M21.92,18.67l-0.96,-0.74c0.02,-0.14 0.04,-0.29 0.04,-0.44c0,-0.15 -0.01,-0.3 -0.04,-0.44l0.95,-0.74c0.08,-0.07 0.11,-0.19 0.05,-0.29l-0.9,-1.55c-0.05,-0.1 -0.17,-0.13 -0.28,-0.1l-1.11,0.45c-0.23,-0.18 -0.48,-0.33 -0.76,-0.44l-0.17,-1.18C18.73,13.08 18.63,13 18.53,13h-1.79c-0.11,0 -0.21,0.08 -0.22,0.19l-0.17,1.18c-0.27,0.12 -0.53,0.26 -0.76,0.44l-1.11,-0.45c-0.1,-0.04 -0.22,0 -0.28,0.1l-0.9,1.55c-0.05,0.1 -0.04,0.22 0.05,0.29l0.95,0.74c-0.02,0.14 -0.03,0.29 -0.03,0.44c0,0.15 0.01,0.3 0.03,0.44l-0.95,0.74c-0.08,0.07 -0.11,0.19 -0.05,0.29l0.9,1.55c0.05,0.1 0.17,0.13 0.28,0.1l1.11,-0.45c0.23,0.18 0.48,0.33 0.76,0.44l0.17,1.18c0.02,0.11 0.11,0.19 0.22,0.19h1.79c0.11,0 0.21,-0.08 0.22,-0.19l0.17,-1.18c0.27,-0.12 0.53,-0.26 0.75,-0.44l1.12,0.45c0.1,0.04 0.22,0 0.28,-0.1l0.9,-1.55C22.03,18.86 22,18.74 21.92,18.67zM17.63,18.83c-0.74,0 -1.35,-0.6 -1.35,-1.35s0.6,-1.35 1.35,-1.35s1.35,0.6 1.35,1.35S18.37,18.83 17.63,18.83z"/>
|
||||
</vector>
|
13
app/src/main/res/drawable/toggle_button_bg.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="?attr/colorPrimary" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="?attr/colorSurfaceContainerLowest" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
8
app/src/main/res/drawable/toggle_button_bg_outline.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="?attr/colorSurfaceContainerLowest" />
|
||||
<stroke
|
||||
android:width="2dp"
|
||||
android:color="?attr/colorOutline"/>
|
||||
</shape>
|
10
app/src/main/res/drawable/tooltip_24px.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,880L373,720L160,720Q127,720 103.5,696.5Q80,673 80,640L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L587,720L480,880ZM480,736L544,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640L416,640L480,736ZM480,400L480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Q480,400 480,400Q480,400 480,400L480,400Z"/>
|
||||
</vector>
|
|
@ -1,100 +1,45 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
<androidx.drawerlayout.widget.DrawerLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/drawerLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="32dp"
|
||||
tools:context=".MainActivity">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
<!-- Main content -->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/topAppBar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/description"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorSurface"
|
||||
app:titleTextColor="?attr/colorOnSurface"
|
||||
app:navigationIconTint="?attr/colorOnSurface"
|
||||
app:title="@string/app_name"
|
||||
app:titleTextAppearance="@style/Toolbar.Title.Small"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
</com.google.android.material.appbar.MaterialToolbar>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollView"
|
||||
<FrameLayout
|
||||
android:id="@+id/fragmentContainer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:padding="16dp"
|
||||
android:layout_marginVertical="16dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/description"
|
||||
app:layout_constraintBottom_toTopOf="@id/toggle"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/redirectionDelay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stepSize="0.5"
|
||||
android:valueFrom="2"
|
||||
android:valueTo="4"
|
||||
android:contentDescription="@string/redirection_delay_description" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/redirection_delay_description"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="8dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/popupPosition"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:endIconMode="custom"
|
||||
app:endIconDrawable="@drawable/ic_baseline_check_circle_24"
|
||||
android:hint="@string/popup_position">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="8dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:background="?android:attr/listDivider" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/serviceRecycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:scrollbars="vertical" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<ToggleButton
|
||||
android:id="@+id/toggle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingVertical="12dp"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintTop_toBottomOf="@id/topAppBar"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<!-- Sliding menu -->
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
android:id="@+id/navigationView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
app:menu="@menu/main_menu" />
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
|
|
74
app/src/main/res/layout/activity_welcome.xml
Normal file
|
@ -0,0 +1,74 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/rootLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="32dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/contentWrapper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/appIcon"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:contentDescription="@string/app_name"
|
||||
android:src="@mipmap/ic_launcher" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appDescription"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/description"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/activateButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/activate"
|
||||
android:layout_marginTop="32dp"
|
||||
app:cornerRadius="24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/activateDescription"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/activate_description"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="16dp" />
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<nl.dionsegijn.konfetti.xml.KonfettiView
|
||||
android:id="@+id/konfettiView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
77
app/src/main/res/layout/fragment_about.xml
Normal file
|
@ -0,0 +1,77 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/rootLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="32dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/contentWrapper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/appIcon"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:contentDescription="@string/app_name"
|
||||
android:src="@mipmap/ic_launcher" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appDescription"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/description"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/sourceButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/source_code"
|
||||
android:layout_marginTop="24dp"
|
||||
app:cornerRadius="24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/licenseButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/license"
|
||||
android:layout_marginTop="8dp"
|
||||
app:cornerRadius="24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/secretButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Secret"
|
||||
android:layout_marginTop="16dp"
|
||||
android:visibility="gone"
|
||||
app:cornerRadius="24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</FrameLayout>
|
33
app/src/main/res/layout/fragment_contacts.xml
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/contactSearch"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="@drawable/search_background"
|
||||
android:iconifiedByDefault="false"
|
||||
android:queryHint="@string/contact_search"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/contactRecycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/contactSearch"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="8dp" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
114
app/src/main/res/layout/fragment_donate.xml
Normal file
|
@ -0,0 +1,114 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/donate_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Intro section -->
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_title"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/descText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_description"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tokenDisplay"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/reminderText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_toast_reminder"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/kofiButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_button"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/postDonatePrompt"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_post_prompt"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/openTokenSection"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_have_token"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Token activation section (initially hidden) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/tokenSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/instruction"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_token_instruction"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/tokenInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/donate_token_hint"
|
||||
android:inputType="text"
|
||||
android:maxLength="32"
|
||||
android:layout_marginBottom="12dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/verifyButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/donate_token_activate" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/resultText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:layout_marginTop="16dp"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
37
app/src/main/res/layout/fragment_main.xml
Normal file
|
@ -0,0 +1,37 @@
|
|||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center">
|
||||
|
||||
<nl.dionsegijn.konfetti.xml.KonfettiView
|
||||
android:id="@+id/confettiView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<View
|
||||
android:layout_width="104dp"
|
||||
android:layout_height="104dp"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/toggle_button_bg_outline" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/toggle"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_gravity="center"
|
||||
android:checkable="true"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:insetLeft="0dp"
|
||||
android:insetRight="0dp"
|
||||
android:text=""
|
||||
app:icon="@drawable/ic_power_settings_new_24"
|
||||
app:iconTint="@color/toggle_button_icon"
|
||||
app:backgroundTint="@color/toggle_button_bg"
|
||||
app:iconSize="48dp"
|
||||
app:iconGravity="textTop"
|
||||
app:iconPadding="0dp"
|
||||
app:cornerRadius="48dp" />
|
||||
</FrameLayout>
|
99
app/src/main/res/layout/fragment_popup_settings.xml
Normal file
|
@ -0,0 +1,99 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="32dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<!-- Delay label -->
|
||||
<TextView
|
||||
android:id="@+id/delayDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/redirection_delay_description"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<!-- Delay slider -->
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/redirectionDelay"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:stepSize="0.5"
|
||||
android:valueFrom="2"
|
||||
android:valueTo="4"
|
||||
android:contentDescription="@string/redirection_delay_description"
|
||||
app:layout_constraintTop_toBottomOf="@id/delayDescription"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Position label -->
|
||||
<TextView
|
||||
android:id="@+id/heightDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/popup_position"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintTop_toBottomOf="@id/redirectionDelay"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<!-- Position slider -->
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/popupHeightSlider"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="100"
|
||||
android:stepSize="1"
|
||||
android:contentDescription="@string/popup_position"
|
||||
app:layout_constraintTop_toBottomOf="@id/heightDescription"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Animation label -->
|
||||
<TextView
|
||||
android:id="@+id/popupEffectLabel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/popup_effect_label"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintTop_toBottomOf="@id/popupHeightSlider"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<!-- Animation dropdown -->
|
||||
<Spinner
|
||||
android:id="@+id/popupEffectSpinner"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/popupEffectLabel"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Test button -->
|
||||
<Button
|
||||
android:id="@+id/popupPreview"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/test"
|
||||
app:layout_constraintTop_toBottomOf="@id/popupEffectSpinner"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
57
app/src/main/res/layout/fragment_redirect_settings.xml
Normal file
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="32dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<!-- Redirect on WiFi -->
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchRedirectWifi"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/redirect_wifi"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<!-- Redirect on Data -->
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchRedirectData"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/redirect_data"
|
||||
app:layout_constraintTop_toBottomOf="@id/switchRedirectWifi"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<!-- Redirect only international numbers -->
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchRedirectInternational"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/redirect_international"
|
||||
app:layout_constraintTop_toBottomOf="@id/switchRedirectData"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<!-- Redirect only on roaming -->
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchRedirectRoaming"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/redirect_roaming"
|
||||
app:layout_constraintTop_toBottomOf="@id/switchRedirectInternational"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
32
app/src/main/res/layout/fragment_service_settings.xml
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/serviceRecycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scrollbars="vertical"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/serviceHeader"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/serviceHeader"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/services_desc"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/serviceRecycler"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
20
app/src/main/res/layout/item_contact.xml
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/contactName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Name" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/contactAllowed"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
/>
|
||||
</LinearLayout>
|
|
@ -5,10 +5,12 @@
|
|||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
app:cardCornerRadius="24dp"
|
||||
app:cardElevation="4dp"
|
||||
app:cardBackgroundColor="?attr/colorSurface"
|
||||
android:background="@android:color/white">
|
||||
android:padding="24dp"
|
||||
app:cardBackgroundColor="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -20,7 +22,6 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textAlignment="center"
|
||||
|
@ -35,7 +36,6 @@
|
|||
android:layout_marginBottom="8dp"
|
||||
android:indeterminate="false"
|
||||
android:max="100"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
android:progressDrawable="@drawable/progress_drawable"/>
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"/>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
|
12
app/src/main/res/layout/secret_overlay.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/secretRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#CC000000">
|
||||
|
||||
<partisan.weforge.xyz.pulse.SecretView
|
||||
android:id="@+id/secretView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
7
app/src/main/res/layout/switch_item.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/globalPopupToggle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:checked="true" />
|
58
app/src/main/res/menu/main_menu.xml
Normal file
|
@ -0,0 +1,58 @@
|
|||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<!-- Home (main screen toggle) -->
|
||||
<group android:checkableBehavior="none">
|
||||
<item
|
||||
android:id="@+id/action_home"
|
||||
android:title="@string/home_name"
|
||||
android:icon="@drawable/ic_power_settings_new_24"
|
||||
app:showAsAction="never" />
|
||||
</group>
|
||||
|
||||
<!-- Settings section -->
|
||||
<!-- <item
|
||||
android:id="@+id/section_settings"
|
||||
android:title="@string/settings_name"
|
||||
android:enabled="false" /> -->
|
||||
<group android:checkableBehavior="none">
|
||||
<item
|
||||
android:id="@+id/action_contacts"
|
||||
android:title="@string/whitelist_name"
|
||||
android:icon="@drawable/group_24px"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_popup_settings"
|
||||
android:title="@string/popup_name"
|
||||
android:icon="@drawable/tooltip_24px"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_services"
|
||||
android:title="@string/services_name"
|
||||
android:icon="@drawable/services_24"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_redirect_settings"
|
||||
android:title="@string/redirect_name"
|
||||
android:icon="@drawable/call_split_24px"
|
||||
app:showAsAction="never" />
|
||||
</group>
|
||||
|
||||
<!-- About section -->
|
||||
<!-- <item
|
||||
android:id="@+id/section_about"
|
||||
android:title="@string/about_name"
|
||||
android:enabled="false" /> -->
|
||||
<group android:checkableBehavior="none">
|
||||
<item
|
||||
android:id="@+id/action_donate"
|
||||
android:title="Donate"
|
||||
android:icon="@drawable/heart_24"
|
||||
app:showAsAction="never" />
|
||||
<item
|
||||
android:id="@+id/action_about"
|
||||
android:title="@string/about_name"
|
||||
android:icon="@drawable/info_24px"
|
||||
app:showAsAction="never" />
|
||||
</group>
|
||||
</menu>
|
7
app/src/main/res/menu/topbar_toggle.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/globalPopupToggle"
|
||||
app:actionLayout="@layout/switch_item"
|
||||
app:showAsAction="always" />
|
||||
</menu>
|
|
@ -9,5 +9,4 @@
|
|||
<string name="destination_whatsapp">WhatsApp</string>
|
||||
<string name="redirection_delay_description">Задержка до того, как звонок будет перенаправлен.</string>
|
||||
<string name="popup_position">Позиция всплывающего окна</string>
|
||||
<string name="fallback">Обратная совместимость</string>
|
||||
</resources>
|
|
@ -14,4 +14,6 @@
|
|||
<color name="colorOnSecondary">#000000</color>
|
||||
|
||||
<color name="launcher_background">@color/colorPrimary</color>
|
||||
|
||||
<color name="colorSurfaceVariant">#2B3542</color>
|
||||
</resources>
|
||||
|
|
|
@ -1,13 +1,75 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Pulse</string>
|
||||
<string name="description">The app will try to redirect outgoing calls to Signal/Telegram/Threema/WhatsApp if available. For work it requires many permissions. Click on the toggle and grant permissions until it turns ON.</string>
|
||||
<string name="description">Redirecting outgoing calls to E2EE apps.</string>
|
||||
<string name="popup">Redirecting to %1$s</string>
|
||||
<string name="home_name">Home</string>
|
||||
<string name="settings_name">Settings</string>
|
||||
<string name="popup_name">Popup</string>
|
||||
<string name="services_name">Services</string>
|
||||
<string name="whitelist_name">Allowlist</string>
|
||||
<string name="redirect_name">Redirect</string>
|
||||
<string name="tools_name">Tools</string>
|
||||
<string name="about_name">About</string>
|
||||
<string name="donate_name">Donate</string>
|
||||
<string name="destination_signal">Signal</string>
|
||||
<string name="destination_telegram">Telegram</string>
|
||||
<string name="destination_threema">Threema</string>
|
||||
<string name="destination_whatsapp">WhatsApp</string>
|
||||
<string name="redirection_delay_description">The delay before a call will be redirected.</string>
|
||||
<string name="services_desc">Here you can enable or disable redirection to individual services and change their priority by dragging them. Redirection will be handled in order from top to bottom.</string>
|
||||
<string name="contact_search">Filter contacts</string>
|
||||
<string name="popup_position">Popup position</string>
|
||||
<string name="fallback">Fallback</string>
|
||||
<string name="activate_description">To start, grant the required permissions by tapping the Activate button.</string>
|
||||
<string name="activate">Activate</string>
|
||||
<string name="navigation_drawer_open">Open menu</string>
|
||||
<string name="test">Test</string>
|
||||
<string name="source_code">Source Code</string>
|
||||
<string name="license">License</string>
|
||||
|
||||
<!-- Redirect Settings -->
|
||||
<string name="redirect_wifi">Redirect while using Wi-Fi</string>
|
||||
<string name="redirect_data">Redirect while on mobile data</string>
|
||||
<string name="redirect_international">Redirect only international numbers</string>
|
||||
<string name="redirect_roaming">Redirect only if roaming</string>
|
||||
|
||||
<!-- Popup Animations -->
|
||||
<string name="popup_effect_label">Popup Animation</string>
|
||||
<string-array name="popup_effects">
|
||||
<item>None</item>
|
||||
<item>Fade</item>
|
||||
<item>Slide</item>
|
||||
<item>Bounce</item>
|
||||
<item>Flop</item>
|
||||
<item>Matrix</item>
|
||||
<item>Slide Snap</item>
|
||||
<item>Gamer Mode</item>
|
||||
<item>Random</item>
|
||||
</string-array>
|
||||
|
||||
<!-- Donate screen -->
|
||||
<string name="donate_title">Support Pulse Development 💖</string>
|
||||
<string name="donate_description">Pulse is free and open-source. You can support future development through Ko-fi. As a thank-you, donors get special popup animation effects!</string>
|
||||
<string name="donate_button">Donate via Ko-fi 💙</string>
|
||||
<string name="donate_post_prompt">Already donated? Tap below to activate your token.</string>
|
||||
<string name="donate_have_token">I have a token</string>
|
||||
<string name="donate_token_instruction">Enter your Ko-fi token:</string>
|
||||
<string name="donate_token_hint">token: abcd1234efgh5678</string>
|
||||
<string name="donate_token_activate">Activate Token</string>
|
||||
<string name="donate_lock">Donate to unlock this effect</string>
|
||||
|
||||
<!-- DonateFragment -->
|
||||
<string name="donate_token_copied">Token copied to clipboard</string>
|
||||
<string name="donate_token_invalid_format">Invalid token format</string>
|
||||
<string name="donate_missing_permission">❌ Missing INTERNET permission</string>
|
||||
<string name="donate_no_internet">❌ No internet access</string>
|
||||
<string name="donate_server_unreachable">❌ Activation server is unreachable</string>
|
||||
<string name="donate_server_not_responding">❌ Server not responding</string>
|
||||
<string name="donate_token_check_failed">❌ Could not check token</string>
|
||||
<string name="donate_token_invalid">❌ Invalid or expired token</string>
|
||||
<string name="donate_activation_failed">❌ Activation failed</string>
|
||||
<string name="donate_token_activated">✅ Token activated!</string>
|
||||
<string name="donate_token_left">You have %1$s activations left.</string>
|
||||
<string name="donate_token_already_activated">✅ Already activated</string>
|
||||
<string name="donate_toast_reminder">Make sure to include your token in the donation message to get rewarded 😊</string>
|
||||
</resources>
|
10
app/src/main/res/values/styles.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="RoundIconShape" parent="">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">100%</item>
|
||||
</style>
|
||||
<style name="Toolbar.Title.Small" parent="TextAppearance.Widget.AppCompat.Toolbar.Title">
|
||||
<item name="android:textSize">16sp</item>
|
||||
</style>
|
||||
</resources>
|
|
@ -11,5 +11,7 @@
|
|||
<item name="colorSurface">@color/white</item>
|
||||
<item name="colorOnSurface">@color/black</item>
|
||||
<item name="textAppearanceBodyMedium">@style/TextAppearance.Material3.BodyMedium</item>
|
||||
<item name="colorSurfaceContainerLowest">?attr/colorSurfaceContainerLowest</item>
|
||||
<item name="colorSurfaceVariant">@color/colorSurfaceVariant</item>
|
||||
</style>
|
||||
</resources>
|
|
@ -1,17 +0,0 @@
|
|||
package partisan.weforge.xyz.pulse
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 127 KiB |
|
@ -1,2 +1,5 @@
|
|||
Forked from Red and renamed to Pulse.
|
||||
Changed Icons and graphic.
|
||||
v1.3.0
|
||||
- Forked from Red and renamed to Pulse.
|
||||
- Changed Icons and graphic.
|
||||
- Added material you icon.
|
||||
- Added options to toggle and change priority to individual redirect services.
|
|
@ -1 +1,2 @@
|
|||
Added material you icon.
|
||||
v1.3.1
|
||||
- Updated metadata and removed some background Google BLOB to improve compliance with IzzyOnDroid repo.
|
|
@ -1 +1,2 @@
|
|||
Added options to toggle and change priority to individual redirect services.
|
||||
v1.3.2
|
||||
- Fixed crash related to redirect popup
|
|
@ -1 +1,3 @@
|
|||
Updated metadata and removed some background Google BLOB to improve compliance with IzzyOnDroid repo.
|
||||
v1.4.0
|
||||
- Added progress bar to popup, to better indicate loading
|
||||
- Updated appstore icon
|
|
@ -1 +1,2 @@
|
|||
Fixed crash related to redirect popup
|
||||
v1.4.1
|
||||
- Dependency update
|
22
fastlane/metadata/android/en-US/changelogs/15.txt
Normal file
|
@ -0,0 +1,22 @@
|
|||
v2.0.0
|
||||
- Reworked the entire UI
|
||||
- Added welcome screen to check for required permissions
|
||||
- New landing page with a single toggle, moved settings to separate menus
|
||||
- Added side menu with:
|
||||
- Allowlist
|
||||
- Redirection: toggle on Wi-Fi, etc.
|
||||
- Services: toggle individual redirect services and set their priority
|
||||
- Popup: change position, animation, and duration
|
||||
- Added About section with:
|
||||
- Donate
|
||||
- About the app
|
||||
|
||||
Fixes:
|
||||
- Popup now uses system Material colors
|
||||
- Fixed issue where service priority changes didn't work
|
||||
|
||||
Misc:
|
||||
- Updated Gradle to v8.14.1
|
||||
- Updated screenshots to include new looks of the app.
|
||||
- Updated description.
|
||||
- Added temporary "v2.0" to store icon to indicate new version.
|
|
@ -1,15 +1,33 @@
|
|||
Tiny app to redirect outgoing calls to Signal/Telegram/Threema/Whatsapp if available.
|
||||
Redirect calls to Signal, Telegram, Threema, or WhatsApp.
|
||||
|
||||
You can cancel redirection by clicking on "Redirecting to.." popup.
|
||||
---
|
||||
|
||||
Permissions:
|
||||
* `ACCESS_NETWORK_STATE` - check internet is available
|
||||
* `CALL_PHONE` - make a call via messenger
|
||||
* `READ_CONTACTS` - check contact has a messenger record
|
||||
* `SYSTEM_ALERT_WINDOW` - show redirecting popup and launch an activity from background
|
||||
* `CALL_REDIRECTION` - process outgoing call
|
||||
**Features:**
|
||||
- Material You design
|
||||
- Popup with cancel option
|
||||
- Extensive settings panel:
|
||||
- Toggle per-service support
|
||||
- Redirection only on Wi-Fi/Data
|
||||
- Allowlist specific contacts
|
||||
- Change per-service priority
|
||||
- Customize popup position, animation, and duration
|
||||
...
|
||||
|
||||
All permissions are mandatory.
|
||||
**Supports:**
|
||||
- Signal
|
||||
- Telegram
|
||||
- Threema
|
||||
- WhatsApp
|
||||
|
||||
It is Free Open Source Software.
|
||||
License: GPL-3
|
||||
**Permissions required:**
|
||||
- `CALL_PHONE` - initiate calls via messenger
|
||||
- `READ_CONTACTS` - check contact compatibility
|
||||
- `READ_PHONE_NUMBERS` - detect outgoing call
|
||||
- `SYSTEM_ALERT_WINDOW` - show popup overlay
|
||||
- `ACCESS_NETWORK_STATE` - check connectivity
|
||||
- `INTERNET` - check connectivity and verify donates
|
||||
|
||||
Currently all of the permissions are required.
|
||||
|
||||
**License:** GPL-3.0
|
||||
Free and open source
|
||||
|
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 142 KiB |
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 163 KiB |
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 70 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 95 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 108 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 121 KiB |
|
@ -1 +1 @@
|
|||
Redirect outgoing calls to Signal/Telegram/Threema/Whatsapp
|
||||
Redirecting outgoing calls to E2EE apps.
|
|
@ -1,17 +0,0 @@
|
|||
Petite application redirigereant les appels sortants vers Signal/Telegram/Threema/Whatsapp si ils sont
|
||||
disponibles.
|
||||
|
||||
Vous pouvez annuler la redirection en cliquant sur la fenêtre contextuelle "Redirection vers...".
|
||||
|
||||
Autorisations:
|
||||
* `ACCESS_NETWORK_STATE` - Vérifié la disponibilité d\'accès à internet
|
||||
* `CALL_PHONE` - Passer un appel via messenger
|
||||
* `READ_CONTACTS` - Vérifier que le contact a un enregistreur de message
|
||||
* `SYSTEM_ALERT_WINDOW` - Afficher une fenêtre contextuelle de redirection et lancer une activité en
|
||||
arrière-plan
|
||||
* `CALL_REDIRECTION` - Traiter les appels sortants
|
||||
|
||||
Toutes les autorisations sont obligatoires.
|
||||
|
||||
C'est un logiciel libre et gratuit.
|
||||
Licence : GPL-3
|
|
@ -1 +0,0 @@
|
|||
Rediriger les appels sortants vers Signal/Telegram/Threema/Whatsapp
|
|
@ -1 +0,0 @@
|
|||
Pulse
|
|
@ -1,16 +0,0 @@
|
|||
Мини приложение для перенаправления исходящих вызовов в Signal/Telegram/Threema/Whatsapp.
|
||||
|
||||
Вы можете отменить перенаправление, кликнув на всплывающее сообщение "Перенаправление в..".
|
||||
|
||||
Разрешения:
|
||||
* `ACCESS_NETWORK_STATE` - проверить наличие интернета
|
||||
* `CALL_PHONE` - позвонить через мессенджер
|
||||
* `READ_CONTACTS - проверить контакт на наличие записи из мессенджера
|
||||
* `SYSTEM_ALERT_WINDOW` - показать всплывающее сообщение о перенаправлении и запустить активити из
|
||||
фона
|
||||
* `CALL_REDIRECTION` - обработать исходящий вызов
|
||||
|
||||
Все разрешения обязательны для работы приложения.
|
||||
|
||||
Это свободное программное обеспечение с открытым исходным кодом.
|
||||
Лицензия: GPL-3
|
|
@ -1 +0,0 @@
|
|||
Перенаправление исходящих вызовов в Signal/Telegram/Threema/Whatsapp
|
|
@ -1 +0,0 @@
|
|||
Pulse
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
6
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,5 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
49
gradlew
vendored
|
@ -1,7 +1,7 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
|
@ -15,6 +15,8 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
|
@ -55,7 +57,7 @@
|
|||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
@ -80,13 +82,11 @@ do
|
|||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
@ -114,7 +114,7 @@ case "$( uname )" in #(
|
|||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
|
@ -133,22 +133,29 @@ location of your Java installation."
|
|||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
|
@ -193,18 +200,28 @@ if "$cygwin" || "$msys" ; then
|
|||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
|
|
41
gradlew.bat
vendored
|
@ -13,8 +13,10 @@
|
|||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
|
@ -25,7 +27,8 @@
|
|||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
|
@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
|||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
|
@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
|