diff --git a/app/build.gradle b/app/build.gradle index 7e6f781..7f6f5a7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,29 +4,39 @@ plugins { } android { - namespace 'partisan.weforge.xyz.pulse' - compileSdk 34 + namespace = 'partisan.weforge.xyz.pulse' + compileSdk = 34 defaultConfig { - applicationId "partisan.weforge.xyz.pulse" - minSdk 29 - targetSdk 34 - versionCode 9 - versionName "1.2.0" + applicationId = "partisan.weforge.xyz.pulse" + minSdk = 29 + targetSdk = 34 + versionCode = 9 + versionName = "1.3.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + release { + storeFile file("release-key.jks") + storePassword RELEASE_STORE_PASSWORD + keyAlias "release-key" + keyPassword RELEASE_STORE_PASSWORD + } } buildTypes { release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + minifyEnabled = false + signingConfig signingConfigs.release + proguardFiles(getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro') } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { @@ -34,11 +44,11 @@ android { } buildFeatures { - viewBinding true + viewBinding = true } lint { - disable 'MissingTranslation' + disable += 'MissingTranslation' } } diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt index 3fccc46..f9d46b4 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/CallRedirectionService.kt @@ -17,20 +17,19 @@ class CallRedirectionService : CallRedirectionService() { private const val TELEGRAM_MIMETYPE = "$PREFIX/vnd.org.telegram.messenger.android.call" private const val THREEMA_MIMETYPE = "$PREFIX/vnd.ch.threema.app.call" private const val WHATSAPP_MIMETYPE = "$PREFIX/vnd.com.whatsapp.voip.call" - private val MIMETYPE_TO_WEIGHT = mapOf( - SIGNAL_MIMETYPE to 0, - TELEGRAM_MIMETYPE to 1, - THREEMA_MIMETYPE to 2, - WHATSAPP_MIMETYPE to 48, - ) - private val FALLBACK_MIMETYPES = arrayOf( + + val ALL_MIMETYPES = arrayOf( + SIGNAL_MIMETYPE, + TELEGRAM_MIMETYPE, + THREEMA_MIMETYPE, WHATSAPP_MIMETYPE, ) + private val MIMETYPE_TO_DST_NAME = mapOf( SIGNAL_MIMETYPE to R.string.destination_signal, TELEGRAM_MIMETYPE to R.string.destination_telegram, THREEMA_MIMETYPE to R.string.destination_threema, - WHATSAPP_MIMETYPE to R.string.fallback_destination_whatsapp, + WHATSAPP_MIMETYPE to R.string.destination_whatsapp, ) } @@ -63,6 +62,7 @@ class CallRedirectionService : CallRedirectionService() { placeCallUnmodified() return } + val records: Array try { records = getRecordsFromPhoneNumber(handle.schemeSpecificPart) @@ -70,11 +70,18 @@ class CallRedirectionService : CallRedirectionService() { placeCallUnmodified() return } - val record = records.minByOrNull { MIMETYPE_TO_WEIGHT[it.mimetype] ?: 0 } - if (record == null || (record.mimetype in FALLBACK_MIMETYPES && !prefs.isFallbackChecked)) { + + // Filter to enabled services only + val enabledRecords = records + .filter { prefs.isServiceEnabled(it.mimetype) } + .sortedBy { prefs.getServicePriority(it.mimetype) } + + val record = enabledRecords.firstOrNull() + if (record == null) { placeCallUnmodified() return } + window.show(record.uri, MIMETYPE_TO_DST_NAME[record.mimetype] ?: return) } @@ -109,9 +116,8 @@ class CallRedirectionService : CallRedirectionService() { ContactsContract.Data.CONTENT_URI, arrayOf(ContactsContract.Data._ID, ContactsContract.Data.MIMETYPE), "${ContactsContract.Data.CONTACT_ID} = ? AND " + - "${ContactsContract.Data.MIMETYPE} IN " + - "(${MIMETYPE_TO_WEIGHT.keys.joinToString(",") { "?" }})", - arrayOf(contactId, *MIMETYPE_TO_WEIGHT.keys.toTypedArray()), + "${ContactsContract.Data.MIMETYPE} IN (${ALL_MIMETYPES.joinToString(",") { "?" }})", + arrayOf(contactId, *ALL_MIMETYPES), null, ) cursor?.apply { diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt index b8abf4b..2658d4c 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt @@ -10,7 +10,17 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.widget.doAfterTextChanged import java.lang.NumberFormatException - +import android.text.InputType +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper +import partisan.weforge.xyz.pulse.getServicePriority +import partisan.weforge.xyz.pulse.setServicePriority +import partisan.weforge.xyz.pulse.isServiceEnabled +import partisan.weforge.xyz.pulse.setServiceEnabled import partisan.weforge.xyz.pulse.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { @@ -55,7 +65,6 @@ class MainActivity : AppCompatActivity() { binding.apply { redirectionDelay.value = (prefs.redirectionDelay / 1000).toFloat() popupPosition.editText?.setText(prefs.popupPosition.toString()) - fallback.isChecked = prefs.isFallbackChecked toggle.isChecked = prefs.isEnabled } } @@ -76,9 +85,6 @@ class MainActivity : AppCompatActivity() { prefs.popupPosition = it?.toString()?.toInt() ?: return@doAfterTextChanged } catch (exc: NumberFormatException) {} } - fallback.setOnCheckedChangeListener { _, isChecked -> - prefs.isFallbackChecked = isChecked - } toggle.setOnCheckedChangeListener { _, isChecked -> if (isChecked && !hasPermissions()) { toggle.isChecked = false @@ -87,6 +93,64 @@ class MainActivity : AppCompatActivity() { } prefs.isEnabled = isChecked } + val services = listOf( + ServiceEntry("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call", R.string.destination_signal, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call")), + ServiceEntry("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call", R.string.destination_telegram, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.org.telegram.messenger.android.call")), + ServiceEntry("vnd.android.cursor.item/vnd.ch.threema.app.call", R.string.destination_threema, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.ch.threema.app.call")), + ServiceEntry("vnd.android.cursor.item/vnd.com.whatsapp.voip.call", R.string.destination_whatsapp, this@MainActivity.isServiceEnabled("vnd.android.cursor.item/vnd.com.whatsapp.voip.call")), + ) + + val adapter = ServiceAdapter( + context = this@MainActivity, + services = services.toMutableList(), + onReordered = { updatedList -> + updatedList.forEachIndexed { index, entry -> + setServicePriority(entry.mimetype, index) + } + } + ) + binding.serviceRecycler.adapter = adapter + binding.serviceRecycler.layoutManager = LinearLayoutManager(this@MainActivity) + + val touchHelper = ItemTouchHelper(adapter.dragHelper) + touchHelper.attachToRecyclerView(binding.serviceRecycler) + + adapter.setDragStartListener { viewHolder -> + touchHelper.startDrag(viewHolder) + } + + // binding.serviceConfigList.removeAllViews() + // for ((mimetype, labelRes) in mimetypes) { + // val checkbox = CheckBox(this@MainActivity).apply { + // text = getString(labelRes) + // isChecked = this@MainActivity.isServiceEnabled(mimetype) + // setOnCheckedChangeListener { _, checked -> + // this@MainActivity.setServiceEnabled(mimetype, checked) + // } + // } + + // val priorityInput = EditText(this@MainActivity).apply { + // inputType = InputType.TYPE_CLASS_NUMBER + // setEms(4) + // hint = "Priority" + // setText(this@MainActivity.getServicePriority(mimetype).toString()) + // setOnFocusChangeListener { _, hasFocus -> + // if (!hasFocus) { + // val value = text.toString().toIntOrNull() + // if (value != null) this@MainActivity.setServicePriority(mimetype, value) + // } + // } + // } + + // val row = LinearLayout(this@MainActivity).apply { + // orientation = LinearLayout.HORIZONTAL + // setPadding(0, 16, 0, 16) + // addView(checkbox, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)) + // addView(priorityInput, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)) + // } + + // binding.serviceConfigList.addView(row) + // } } } diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt index 75d13fd..835f2ab 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/PopupWindow.kt @@ -50,7 +50,6 @@ class PopupWindow( R.string.destination_telegram, R.string.destination_threema, ) - if (prefs.isFallbackChecked) destinations.add(R.string.fallback_destination_whatsapp) setDescription(destinations.random()) add() } diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt index 09fc8c3..5909a36 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/Preferences.kt @@ -9,7 +9,6 @@ class Preferences(ctx: Context) { private const val ENABLED = "enabled" private const val REDIRECTION_DELAY = "redirection_delay" private const val POPUP_POSITION = "popup_position_y" - private const val FALLBACK_CHECKED = "fallback_checked" private const val DEFAULT_REDIRECTION_DELAY = 2000L private const val DEFAULT_POPUP_POSITION = 333 @@ -32,7 +31,26 @@ class Preferences(ctx: Context) { get() = prefs.getInt(POPUP_POSITION, DEFAULT_POPUP_POSITION) set(value) = prefs.edit { putInt(POPUP_POSITION, value) } - var isFallbackChecked: Boolean - get() = prefs.getBoolean(FALLBACK_CHECKED, false) - set(value) = prefs.edit { putBoolean(FALLBACK_CHECKED, value) } + private fun makeKeyEnabled(mimetype: String) = "enabled_$mimetype" + private fun makeKeyPriority(mimetype: String) = "priority_$mimetype" + + /** Whether this service is enabled */ + fun isServiceEnabled(mimetype: String): Boolean { + return prefs.getBoolean(makeKeyEnabled(mimetype), true) + } + + /** Current priority for this service (lower = higher priority) */ + fun getServicePriority(mimetype: String): Int { + return prefs.getInt(makeKeyPriority(mimetype), Int.MAX_VALUE) + } + + /** Enable or disable individual service */ + fun setServiceEnabled(mimetype: String, enabled: Boolean) { + prefs.edit().putBoolean(makeKeyEnabled(mimetype), enabled).apply() + } + + /** Change priority for an individual service */ + fun setServicePriority(mimetype: String, priority: Int) { + prefs.edit().putInt(makeKeyPriority(mimetype), priority).apply() + } } diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt new file mode 100644 index 0000000..6565c3b --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/ServiceAdapter.kt @@ -0,0 +1,89 @@ +package partisan.weforge.xyz.pulse + +import android.content.Context +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView + +class ServiceAdapter( + private val context: Context, + private val services: MutableList, + private val onReordered: (List) -> Unit +) : RecyclerView.Adapter() { + + inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val label: TextView = view.findViewById(R.id.label) + val checkbox: CheckBox = view.findViewById(R.id.checkbox) + val handle: ImageView = view.findViewById(R.id.handle) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_service, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val entry = services[position] + + holder.label.setText(entry.labelRes) + holder.checkbox.isChecked = entry.enabled + + holder.checkbox.setOnCheckedChangeListener { _, isChecked -> + entry.enabled = isChecked + context.setServiceEnabled(entry.mimetype, isChecked) + } + + holder.handle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + dragStartListener?.invoke(holder) + } + false + } + } + + override fun getItemCount(): Int = services.size + + private var dragStartListener: ((RecyclerView.ViewHolder) -> Unit)? = null + + val dragHelper = object : ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN + return makeMovementFlags(dragFlags, 0) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val from = viewHolder.adapterPosition + val to = target.adapterPosition + services.add(to, services.removeAt(from)) + notifyItemMoved(from, to) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + // No swipe actions + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + onReordered(services) + } + } + + fun setDragStartListener(listener: (RecyclerView.ViewHolder) -> Unit) { + dragStartListener = listener + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/ServicePrefs.kt b/app/src/main/java/partisan/weforge/xyz/pulse/ServicePrefs.kt new file mode 100644 index 0000000..9a477cb --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/ServicePrefs.kt @@ -0,0 +1,33 @@ +package partisan.weforge.xyz.pulse + +import android.content.Context +import androidx.preference.PreferenceManager + +private fun makeKeyEnabled(mimetype: String) = "enabled_$mimetype" +private fun makeKeyPriority(mimetype: String) = "priority_$mimetype" + +data class ServiceEntry( + val mimetype: String, + val labelRes: Int, + var enabled: Boolean +) + +fun Context.isServiceEnabled(mimetype: String): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return prefs.getBoolean(makeKeyEnabled(mimetype), true) +} + +fun Context.setServiceEnabled(mimetype: String, enabled: Boolean) { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + prefs.edit().putBoolean(makeKeyEnabled(mimetype), enabled).apply() +} + +fun Context.getServicePriority(mimetype: String): Int { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return prefs.getInt(makeKeyPriority(mimetype), Int.MAX_VALUE) +} + +fun Context.setServicePriority(mimetype: String, priority: Int) { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + prefs.edit().putInt(makeKeyPriority(mimetype), priority).apply() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 0000000..ab0c094 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 97ae04d..1ef4824 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,6 @@ - + app:layout_constraintEnd_toEndOf="parent" /> + app:layout_constraintEnd_toEndOf="parent"> + android:valueTo="4" + android:contentDescription="@string/redirection_delay_description" /> + android:text="@string/redirection_delay_description" + android:textSize="12sp" /> + android:layout_height="wrap_content" + android:layout_marginVertical="8dp" /> + android:hint="@string/popup_position"> - + android:layout_height="wrap_content" + android:inputType="number" /> + android:layout_height="wrap_content" + android:layout_marginVertical="8dp" /> - - - - + android:layout_marginTop="16dp" + android:scrollbars="vertical" /> @@ -105,7 +95,6 @@ android:paddingVertical="12dp" android:textSize="16sp" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" /> - - \ No newline at end of file + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + diff --git a/app/src/main/res/layout/item_service.xml b/app/src/main/res/layout/item_service.xml new file mode 100644 index 0000000..40a6a64 --- /dev/null +++ b/app/src/main/res/layout/item_service.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fcc44c3..351afc4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,10 +1,10 @@ Pulse - L\'application essaiera de rediriger les appels sortants vers Signal/Telegram/Threema s\'ils sont disponibles. Pour fonctionner, l\'application nécessite de nombreuses permissions. Cliquez sur le bouton et accordez les autorisations nécéssaires jusqu\'à ce qu\'il soit activé. + L\'application essaiera de rediriger les appels sortants vers Signal/Telegram/Threema/WhatsApp s\'ils sont disponibles. Pour fonctionner, l\'application nécessite de nombreuses permissions. Cliquez sur le bouton et accordez les autorisations nécéssaires jusqu\'à ce qu\'il soit activé. Redirection vers %1$s Signal Telegram Threema - Délai avant qu\'un appel ne soit redirigé. + WhatsApp diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3c8c70d..4caa5d3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,14 +1,13 @@ Pulse - Приложение будет пытаться перенаправить исходящие вызовы в Signal/Telegram/Threema. Для работы ему нужно много разрешений. Кликайте на переключатель и выдавайте разрешения пока он не включится. + Приложение будет пытаться перенаправить исходящие вызовы в Signal/Telegram/Threema/WhatsApp. Для работы ему нужно много разрешений. Кликайте на переключатель и выдавайте разрешения пока он не включится. Перенаправление в %1$s Signal Telegram Threema + WhatsApp Задержка до того, как звонок будет перенаправлен. Позиция всплывающего окна Обратная совместимость - Перенаправлять в WhatsApp, если другие недоступны. - WhatsApp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5954b91..fe77fda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,14 +1,13 @@ Pulse - The app will try to redirect outgoing calls to Signal/Telegram/Threema if available. For work it requires many permissions. Click on the toggle and grant permissions until it turns ON. + The app will try to redirect outgoing calls to Signal/Telegram/Threema/WhatsApp if available. For work it requires many permissions. Click on the toggle and grant permissions until it turns ON. Redirecting to %1$s Signal Telegram Threema + WhatsApp The delay before a call will be redirected. Popup position Fallback - Redirect to WhatsApp if no other available. - WhatsApp \ No newline at end of file