diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..a0f12ba41 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,127 @@ +name: Build & Release + +on: + workflow_dispatch: + inputs: + tag: + description: Git tag for the release (leave empty to auto-detect) + required: false + type: string + push: + branches: [main] + paths: + - ".fossify/release-marker.txt" + +# Two runs publishing to the same release would race on asset upload +concurrency: + group: build-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # tags are needed by the "Determine tag" step below; the default shallow + # fetch has none, which silently forces the build- fallback + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + +# - name: Set up Android SDK +# uses: android-actions/setup-android@v3 +# +# - name: Validate Gradle wrapper +# uses: gradle/actions/wrapper-validation@v4 +# +# - name: Setup Gradle +# uses: gradle/actions/setup-gradle@v4 + + - name: Decode keystore (if provided) + id: keystore + env: + KEYSTORE: ${{ secrets.KEYSTORE }} + run: | + if [ -n "$KEYSTORE" ]; then + echo "$KEYSTORE" | base64 -d > "$RUNNER_TEMP/keystore.jks" + echo "created=true" >> "$GITHUB_OUTPUT" + else + echo "created=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build release APKs + env: + SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + # must be absolute: app/build.gradle.kts resolves file() relative to the app module + SIGNING_STORE_FILE: ${{ steps.keystore.outputs.created == 'true' && format('{0}/keystore.jks', runner.temp) || '' }} + SIGNING_STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + run: | + if [ -z "$SIGNING_STORE_FILE" ]; then + unset SIGNING_KEY_ALIAS SIGNING_KEY_PASSWORD SIGNING_STORE_FILE SIGNING_STORE_PASSWORD + fi + # `assemble` would also build the three debug variants, which are never published + ./gradlew assembleRelease + + - name: Collect APKs + id: apks + run: | + mkdir -p apks + find app/build/outputs/apk -name '*.apk' -type f -exec cp {} apks/ \; + ls -lh apks/ + echo "count=$(ls apks/*.apk 2>/dev/null | wc -l)" >> "$GITHUB_OUTPUT" + + - name: Determine tag + id: tag + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + if [ -n "$INPUT_TAG" ]; then + echo "value=$INPUT_TAG" >> "$GITHUB_OUTPUT" + exit 0 + fi + DESCRIBE=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$DESCRIBE" ]; then + echo "value=build-$(date +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT" + else + echo "value=$DESCRIBE" >> "$GITHUB_OUTPUT" + fi + + - name: Upload APKs as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: phone-apks-${{ steps.tag.outputs.value }} + path: apks/*.apk + if-no-files-found: error + + # Unsigned APKs cannot be installed as an update over a signed install, so they stay + # as a workflow artifact only and never reach a release. + - name: Create or update GitHub release + if: steps.apks.outputs.count > 0 && steps.keystore.outputs.created == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.value }} + run: | + echo "Tag: $TAG" + + if gh release view "$TAG" --repo "${{ github.repository }}" &>/dev/null; then + echo "Release $TAG exists — uploading assets" + gh release upload "$TAG" apks/*.apk --clobber --repo "${{ github.repository }}" + else + echo "Release $TAG does not exist — creating" + gh release create "$TAG" apks/*.apk \ + --title "$TAG" \ + --notes "Automated build for $TAG" \ + --repo "${{ github.repository }}" + fi diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..4db131136 --- /dev/null +++ b/Makefile @@ -0,0 +1,128 @@ +SHELL := /bin/sh +.DEFAULT_GOAL := help + +# Which product flavor to build. The project declares core/foss/gplay; the bare +# assemble/install tasks either build all three flavors at once +# (assembleDebug) or do not exist at all (installDebug), so every target below is +# flavor-qualified. Override with e.g. `make FLAVOR=core debug`. +FLAVOR ?= foss + +# Gradle wrapper +GRADLE := ./gradlew + +# JDK discovery, in order of preference: +# 1. JAVA_HOME from the environment, if it already points at a usable JDK +# 2. macOS: java_home, which knows about /Library/Java/JavaVirtualMachines +# 3. Linux/WSL: probe /usr/lib/jvm, picking the first JDK new enough +# 4. whatever javac is on PATH +# AGP needs JDK 17 or newer; 17, 21 and 25 all build this project. Source and target +# compatibility stay pinned in gradle/libs.versions.toml, so the bytecode does not +# depend on which of them you use. +MIN_JDK := 17 + +# $(call jdk_major,) -> major version, or empty if that is not a JDK at all. +# The -x test keeps the shell from printing "not found" straight to the terminal. +jdk_major = $(shell [ -x "$(1)/bin/javac" ] && "$(1)/bin/javac" -version 2>&1 | sed -e 's/^javac //' -e 's/[.+_-].*//') +jdk_ok = $(shell [ "$(call jdk_major,$(1))" -ge $(MIN_JDK) ] 2>/dev/null && echo ok) + +# `make help` has to work before a JDK is installed, so skip discovery entirely for it. +JDK_NEEDED := $(filter-out help,$(or $(strip $(MAKECMDGOALS)),$(.DEFAULT_GOAL))) + +ifneq ($(strip $(JDK_NEEDED)),) + +# An inherited JAVA_HOME that is too old is common (distro default, old shell profile). +# Warn and keep looking rather than failing, since a newer JDK is usually installed too. +ifneq ($(strip $(JAVA_HOME)),) + ifneq ($(call jdk_ok,$(JAVA_HOME)),ok) + $(warning ignoring JAVA_HOME=$(JAVA_HOME): not a JDK $(MIN_JDK)+) + JAVA_HOME := + endif +endif + +# Darwin / Linux / MINGW*_NT / CYGWIN*_NT. Everything non-Darwin tries the Linux layout +# first and then falls back to PATH, which is what MSYS and Cygwin need anyway. +UNAME_S := $(shell uname -s) + +ifeq ($(strip $(JAVA_HOME)),) + ifeq ($(UNAME_S),Darwin) + JAVA_HOME := $(shell /usr/libexec/java_home -v $(MIN_JDK)+ 2>/dev/null) + else + # do not sort these lexically: java-8-openjdk sorts after java-21-openjdk, so ask + # each candidate for its version instead of guessing from the directory name + JAVA_HOME := $(shell \ + for d in /usr/lib/jvm/*/; do \ + [ -x "$$d/bin/javac" ] || continue; \ + v=$$("$$d/bin/javac" -version 2>&1 | sed -e 's/^javac //' -e 's/[.+_-].*//'); \ + if [ "$$v" -ge $(MIN_JDK) ] 2>/dev/null; then printf '%s\n' "$${d%/}"; fi; \ + done | head -1) + endif +endif + +ifeq ($(strip $(JAVA_HOME)),) + JAVA_HOME := $(patsubst %/bin/javac,%,$(realpath $(shell command -v javac 2>/dev/null))) +endif + +ifeq ($(strip $(JAVA_HOME)),) + $(error No JDK $(MIN_JDK)+ found. Install one (macOS: brew install --cask temurin@$(MIN_JDK), \ +Debian/Ubuntu: apt install openjdk-$(MIN_JDK)-jdk, Fedora: dnf install java-$(MIN_JDK)-openjdk-devel) \ +or set JAVA_HOME yourself) +endif + +JDK_MAJOR := $(call jdk_major,$(JAVA_HOME)) +ifneq ($(call jdk_ok,$(JAVA_HOME)),ok) + $(error Found JDK "$(JDK_MAJOR)" at $(JAVA_HOME), but $(MIN_JDK)+ is required. \ +Set JAVA_HOME to a newer JDK) +endif + +export JAVA_HOME + +# so gradlew and anything it spawns pick up the same java +PATH := $(JAVA_HOME)/bin:$(PATH) +export PATH + +endif + +.PHONY: help all debug release clean check install install-debug install-release java-info + +# Lists every target carrying a `## description` comment. Deliberately plain POSIX awk: +# no lazy quantifiers (BSD and GNU disagree) and no sed \t (BSD sed does not expand it). +help: ## Show this help + @echo 'Targets:' + @awk 'BEGIN {FS = ":.*?## "} { \ + if (/^[0-9a-zA-Z_-]+:.*?##.*$$/) {printf " \033[1;37m%-20s\033[1;32m%s\033[0m\n", $$1, $$2} \ + else if (/^## .*$$/) {printf " \033[1;36m%s\033[0m\n", substr($$1,4)} \ + }' $(MAKEFILE_LIST) + @echo "" + @echo " FLAVOR=core|foss|gplay selects the product flavor (currently $(FLAVOR))." + @echo " Example: make FLAVOR=core install-debug" + +all: release install-release ## release + install-release + +debug: ## Assemble the debug APK + @$(GRADLE) assemble$(FLAVOR)Debug + +release: ## Assemble the release APK + @$(GRADLE) assemble$(FLAVOR)Release + +install: install-debug ## Alias for install-debug + +# install already depends on assemble, no need to chain it ourselves +install-debug: ## Build and install the debug APK on the connected device + @$(GRADLE) install$(FLAVOR)Debug + +install-release: ## Build and install the release APK on the connected device + @$(GRADLE) install$(FLAVOR)Release + +clean: ## Delete build outputs + @$(GRADLE) clean + +# Static analysis. The project has no formatter configured (no spotless/ktlint plugin), +# only detekt and the Android linter. +check: ## Run detekt and the Android linter + @$(GRADLE) detekt lint$(FLAVOR)Debug + +# handy when a build picks up an unexpected toolchain +java-info: ## Print the detected JDK and flavor + @echo "JAVA_HOME = $(JAVA_HOME)" + @echo "JDK major = $(JDK_MAJOR)" + @echo "flavor = $(FLAVOR)" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5bb585835..2356673ce 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + @@ -99,6 +100,13 @@ android:label="@string/conference" android:parentActivityName="org.fossify.phone.activities.CallActivity" /> + + + android:showOnLockScreen="true" + android:turnScreenOn="true" /> () override fun onCreate(savedInstanceState: Bundle?) { @@ -69,33 +71,15 @@ class MainActivity : SimpleActivity() { EventBus.getDefault().register(this) launchedDialer = savedInstanceState?.getBoolean(OPEN_DIAL_PAD_AT_LAUNCH) ?: false + hadContactsPermission = hasPermission(PERMISSION_READ_CONTACTS) - if (isDefaultDialer()) { - checkContactPermissions() - - if (!config.wasOverlaySnackbarConfirmed && !Settings.canDrawOverlays(this)) { - val snackbar = Snackbar.make( - binding.mainHolder, - R.string.allow_displaying_over_other_apps, - Snackbar.LENGTH_INDEFINITE - ).setAction(R.string.ok) { - config.wasOverlaySnackbarConfirmed = true - startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) - } - - snackbar.setBackgroundTint(getProperBackgroundColor().darkenColor()) - snackbar.setTextColor(getProperTextColor()) - snackbar.setActionTextColor(getProperTextColor()) - snackbar.show() - } - - handleFullScreenNotificationsPermission { granted -> - if (!granted) { - toast(org.fossify.commons.R.string.notifications_disabled) - } - } + if (config.wasPermissionsSetupShown) { + checkPermissionsOnLaunch() } else { - launchSetDefaultDialerIntent() + // first launch: ask for everything up front in one place instead of drip-feeding + // prompts, and never show it again even if the user skips through it + config.wasPermissionsSetupShown = true + startActivity(Intent(this, PermissionsActivity::class.java)) } if (isQPlus() && (config.blockUnknownNumbers || config.blockHiddenNumbers)) { @@ -103,9 +87,55 @@ class MainActivity : SimpleActivity() { } setupTabs() + // only wires up the view pager, so it must not be gated on a permission result + initFragments() Contact.sorting = config.sorting } + private fun checkPermissionsOnLaunch() { + if (isDefaultDialer()) { + onDefaultDialerReady() + } else { + launchSetDefaultDialerIntent() + } + } + + /** + * Everything here used to run in onCreate only. A user who granted the default dialer role from + * the prompt started by this very launch was therefore never asked for full screen notification + * access until the next cold start, which on Android 14+ left incoming calls with no full screen + * UI at all. onActivityResult calls this too now. + */ + private fun onDefaultDialerReady() { + handlePermission(PERMISSION_READ_CONTACTS) { granted -> + if (granted) { + refreshItems() + } + } + + if (!config.wasOverlaySnackbarConfirmed && !Settings.canDrawOverlays(this)) { + val snackbar = Snackbar.make( + binding.mainHolder, + R.string.allow_displaying_over_other_apps, + Snackbar.LENGTH_INDEFINITE + ).setAction(R.string.ok) { + config.wasOverlaySnackbarConfirmed = true + startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) + } + + snackbar.setBackgroundTint(getProperBackgroundColor().darkenColor()) + snackbar.setTextColor(getProperTextColor()) + snackbar.setActionTextColor(getProperTextColor()) + snackbar.show() + } + + handleFullScreenNotificationsPermission { granted -> + if (!granted) { + toast(org.fossify.commons.R.string.notifications_disabled) + } + } + } + override fun onResume() { super.onResume() if (storedShowTabs != config.showTabs) { @@ -133,6 +163,15 @@ class MainActivity : SimpleActivity() { storedStartNameWithSurname = config.startNameWithSurname } + // the fragments read the contacts permission once, in setupFragment, so they keep showing + // the "could not access contacts" placeholder until they are recreated. Dropping the + // adapter makes refreshItems below rebuild them. + val hasContactsPermission = hasPermission(PERMISSION_READ_CONTACTS) + if (hasContactsPermission != hadContactsPermission) { + hadContactsPermission = hasContactsPermission + binding.viewPager.adapter = null + } + if (!binding.mainMenu.isSearchOpen) { refreshItems(true) } @@ -145,7 +184,7 @@ class MainActivity : SimpleActivity() { } checkShortcuts() - Handler().postDelayed({ + Handler(Looper.getMainLooper()).postDelayed({ getRecentsFragment()?.refreshItems() }, 2000) } @@ -161,7 +200,9 @@ class MainActivity : SimpleActivity() { super.onActivityResult(requestCode, resultCode, resultData) // we don't really care about the result, the app can work without being the default Dialer too if (requestCode == REQUEST_CODE_SET_DEFAULT_DIALER) { - checkContactPermissions() + if (isDefaultDialer()) { + onDefaultDialerReady() + } } else if (requestCode == REQUEST_CODE_SET_DEFAULT_CALLER_ID && resultCode != Activity.RESULT_OK) { toast(R.string.must_make_default_caller_id_app, length = Toast.LENGTH_LONG) baseConfig.blockUnknownNumbers = false @@ -267,12 +308,6 @@ class MainActivity : SimpleActivity() { binding.mainMenu.updateColors() } - private fun checkContactPermissions() { - handlePermission(PERMISSION_READ_CONTACTS) { - initFragments() - } - } - private fun clearCallHistory() { val confirmationText = "${getString(R.string.clear_history_confirmation)}\n\n${getString(R.string.cannot_be_undone)}" ConfirmationDialog(this, confirmationText) { @@ -301,7 +336,7 @@ class MainActivity : SimpleActivity() { @SuppressLint("NewApi") private fun getLaunchDialpadShortcut(appIconColor: Int): ShortcutInfo { val newEvent = getString(R.string.dialpad) - val drawable = resources.getDrawable(R.drawable.shortcut_dialpad) + val drawable = resources.getDrawable(R.drawable.shortcut_dialpad, theme) (drawable as LayerDrawable).findDrawableByLayerId(R.id.shortcut_dialpad_background).applyColorFilter(appIconColor) val bmp = drawable.convertToBitmap() @@ -386,7 +421,7 @@ class MainActivity : SimpleActivity() { // selecting the proper tab sometimes glitches, add an extra selector to make sure we have it right binding.mainTabsHolder.onGlobalLayout { - Handler().postDelayed({ + Handler(Looper.getMainLooper()).postDelayed({ var wantedTab = getDefaultTab() // open the Recents tab if we got here by clicking a missed call notification @@ -566,6 +601,7 @@ class MainActivity : SimpleActivity() { FAQItem(R.string.faq_1_title, R.string.faq_1_text), FAQItem(R.string.faq_2_title, R.string.faq_2_text), FAQItem(R.string.faq_3_title, R.string.faq_3_text), + FAQItem(R.string.faq_4_title, R.string.faq_4_text), FAQItem(R.string.faq_9_title_commons, R.string.faq_9_text_commons) ) diff --git a/app/src/main/kotlin/org/fossify/phone/activities/PermissionsActivity.kt b/app/src/main/kotlin/org/fossify/phone/activities/PermissionsActivity.kt new file mode 100644 index 000000000..78b38252f --- /dev/null +++ b/app/src/main/kotlin/org/fossify/phone/activities/PermissionsActivity.kt @@ -0,0 +1,221 @@ +package org.fossify.phone.activities + +import android.annotation.SuppressLint +import android.app.role.RoleManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import androidx.activity.result.contract.ActivityResultContracts +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.isVisible +import org.fossify.commons.extensions.launchViewIntent +import org.fossify.commons.extensions.openFullScreenIntentSettings +import org.fossify.commons.extensions.toast +import org.fossify.commons.extensions.updateTextColors +import org.fossify.commons.extensions.viewBinding +import org.fossify.commons.helpers.NavigationIcon +import org.fossify.commons.helpers.isQPlus +import org.fossify.phone.BuildConfig +import org.fossify.phone.R +import org.fossify.phone.databinding.ActivityPermissionsBinding +import org.fossify.phone.databinding.ItemPermissionBinding +import org.fossify.phone.helpers.AppPermission +import org.fossify.phone.helpers.appPermissions +import org.fossify.phone.helpers.isPermissionGranted +import org.fossify.phone.helpers.mayNeedRestrictedSettingsUnlock + +/** + * Shows every permission and special access the dialer can use, each with its current state and a + * button starting the matching grant flow. States are re-read in onResume because most of the + * special accesses are granted in a system screen that gives us no usable result. + */ +class PermissionsActivity : SimpleActivity() { + companion object { + private const val RESTRICTED_SETTINGS_HELP_URL = + "https://support.google.com/android/answer/12623953" + } + + private val binding by viewBinding(ActivityPermissionsBinding::inflate) + private var permissionRows: Map = emptyMap() + + private val requestRuntimePermissions = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + refreshPermissionStates() + } + + private val requestRole = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + refreshPermissionStates() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setupEdgeToEdge(padBottomSystem = listOf(binding.permissionsNestedScrollview)) + setupMaterialScrollListener(binding.permissionsNestedScrollview, binding.permissionsAppbar) + + binding.permissionsDone.setOnClickListener { finish() } + binding.permissionsRestrictedHeader.setOnClickListener { toggleRestrictedDetails() } + binding.permissionsRestrictedAppInfo.setOnClickListener { openAppDetailsSettings() } + binding.permissionsRestrictedLearnMore.setOnClickListener { + launchViewIntent(RESTRICTED_SETTINGS_HELP_URL) + } + + buildPermissionRows() + } + + override fun onResume() { + super.onResume() + setupTopAppBar(binding.permissionsAppbar, NavigationIcon.Arrow) + updateTextColors(binding.permissionsHolder) + binding.permissionsRequiredLabel.setTextColor(getProperPrimaryColor()) + binding.permissionsOptionalLabel.setTextColor(getProperPrimaryColor()) + refreshPermissionStates() + } + + /** + * Rows are built once and only their state is refreshed later, so a row never moves or + * disappears under the user's finger while they work through the list. + */ + private fun buildPermissionRows() { + permissionRows = appPermissions().associateWith { permission -> + val parent = if (permission.isRequired) { + binding.permissionsRequiredHolder + } else { + binding.permissionsOptionalHolder + } + + ItemPermissionBinding.inflate(layoutInflater, parent, true).apply { + permissionIcon.setImageResource(permission.iconRes) + permissionIcon.applyColorFilter(getProperTextColor()) + permissionTitle.setText(permission.titleRes) + permissionSummary.setText(permission.summaryRes) + root.setOnClickListener { requestPermission(permission) } + permissionAction.setOnClickListener { requestPermission(permission) } + } + } + + binding.permissionsRequiredLabel + .beVisibleIf(binding.permissionsRequiredHolder.childCount > 0) + binding.permissionsOptionalLabel + .beVisibleIf(binding.permissionsOptionalHolder.childCount > 0) + } + + private fun refreshPermissionStates() { + val grantedColor = getColor(R.color.md_green_400) + val missingColor = getColor(R.color.md_red_400) + + permissionRows.forEach { (permission, row) -> + val granted = isPermissionGranted(permission) + val stateColor = if (granted) grantedColor else missingColor + + row.permissionStatusIcon.setImageResource( + if (granted) R.drawable.ic_check_circle_vector else R.drawable.ic_cross_vector + ) + row.permissionStatusIcon.applyColorFilter(stateColor) + row.permissionStatusLabel.setText( + if (granted) R.string.permission_granted else R.string.permission_not_granted + ) + row.permissionStatusLabel.setTextColor(stateColor) + + row.permissionAction.beVisibleIf(!granted) + row.permissionAction.setText( + if (permission.opensSystemScreen) { + R.string.permission_open_settings + } else { + org.fossify.commons.R.string.grant_permission + } + ) + row.root.isClickable = !granted + } + + // only worth the screen space while a restricted entry is actually still blocked + binding.permissionsRestrictedNotice.beVisibleIf(mayNeedRestrictedSettingsUnlock()) + binding.permissionsRestrictedIcon.applyColorFilter(getColor(R.color.md_amber_700)) + binding.permissionsRestrictedTitle.setTextColor(getColor(R.color.md_amber_700)) + binding.permissionsRestrictedChevron.applyColorFilter(getProperTextColor()) + } + + private fun toggleRestrictedDetails() { + val expanded = !binding.permissionsRestrictedDetails.isVisible() + binding.permissionsRestrictedDetails.beVisibleIf(expanded) + binding.permissionsRestrictedTeaser.beVisibleIf(!expanded) + binding.permissionsRestrictedChevron.setImageResource( + if (expanded) R.drawable.ic_chevron_up_vector else R.drawable.ic_chevron_down_vector + ) + binding.permissionsRestrictedChevron.applyColorFilter(getProperTextColor()) + } + + private fun requestPermission(permission: AppPermission) { + if (isPermissionGranted(permission)) { + return + } + + when (permission) { + AppPermission.DEFAULT_DIALER -> launchSetDefaultDialerIntent() + AppPermission.FULL_SCREEN_INTENT -> openFullScreenIntentSettingsSafely() + AppPermission.DRAW_OVER_OTHER_APPS -> openOverlaySettings() + AppPermission.CALLER_ID -> requestCallScreeningRole() + else -> requestRuntimePermissions.launch(permission.runtimePermissions.toTypedArray()) + } + } + + @SuppressLint("NewApi") + private fun openFullScreenIntentSettingsSafely() { + try { + openFullScreenIntentSettings(BuildConfig.APPLICATION_ID) + } catch (_: Exception) { + openAppDetailsSettings() + } + } + + private fun openOverlaySettings() { + val packageUri = Uri.fromParts("package", BuildConfig.APPLICATION_ID, null) + try { + startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, packageUri)) + } catch (_: Exception) { + try { + // some ROMs reject the per-package variant and only accept the global screen + startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) + } catch (_: Exception) { + openAppDetailsSettings() + } + } + } + + @SuppressLint("NewApi") + private fun requestCallScreeningRole() { + if (!isQPlus()) { + return + } + + try { + val roleManager = getSystemService(RoleManager::class.java) + if (roleManager?.isRoleAvailable(RoleManager.ROLE_CALL_SCREENING) != true) { + openAppDetailsSettings() + return + } + + requestRole.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_CALL_SCREENING)) + } catch (_: Exception) { + openAppDetailsSettings() + } + } + + private fun openAppDetailsSettings() { + try { + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", BuildConfig.APPLICATION_ID, null) + ) + ) + } catch (_: Exception) { + toast(org.fossify.commons.R.string.unknown_error_occurred) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/phone/activities/SettingsActivity.kt b/app/src/main/kotlin/org/fossify/phone/activities/SettingsActivity.kt index a2c0bdb3d..added3c06 100644 --- a/app/src/main/kotlin/org/fossify/phone/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/phone/activities/SettingsActivity.kt @@ -44,6 +44,7 @@ import org.fossify.phone.extensions.canLaunchAccountsConfiguration import org.fossify.phone.extensions.config import org.fossify.phone.extensions.launchAccountsConfiguration import org.fossify.phone.helpers.RecentsHelper +import org.fossify.phone.helpers.getMissingRequiredPermissions import org.fossify.phone.models.RecentCall import java.util.Locale import kotlin.system.exitProcess @@ -118,6 +119,8 @@ class SettingsActivity : SimpleActivity() { setupCallsExport() setupCallsImport() updateTextColors(binding.settingsHolder) + // after updateTextColors, it repaints every child and would drop the warning colour + setupPermissions() binding.apply { arrayOf( @@ -182,6 +185,18 @@ class SettingsActivity : SimpleActivity() { } } + private fun setupPermissions() { + binding.apply { + val missing = getMissingRequiredPermissions() + settingsPermissionsStatus.beVisibleIf(missing.isNotEmpty()) + settingsPermissionsStatus.text = getString(R.string.permissions_missing_required) + settingsPermissionsStatus.setTextColor(getColor(R.color.md_red_400)) + settingsPermissionsHolder.setOnClickListener { + startActivity(Intent(this@SettingsActivity, PermissionsActivity::class.java)) + } + } + } + private fun setupManageBlockedNumbers() { binding.apply { settingsManageBlockedNumbersLabel.text = addLockedLabelIfNeeded(R.string.manage_blocked_numbers) diff --git a/app/src/main/kotlin/org/fossify/phone/adapters/RecentCallsAdapter.kt b/app/src/main/kotlin/org/fossify/phone/adapters/RecentCallsAdapter.kt index 67c57460d..f94c2c4ae 100644 --- a/app/src/main/kotlin/org/fossify/phone/adapters/RecentCallsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/phone/adapters/RecentCallsAdapter.kt @@ -83,7 +83,7 @@ class RecentCallsAdapter( private lateinit var incomingMissedCallIcon: Drawable var fontSize: Float = activity.getTextSize() private val areMultipleSIMsAvailable = activity.areMultipleSIMsAvailable() - private var missedCallColor = resources.getColor(R.color.color_missed_call) + private var missedCallColor = resources.getColor(R.color.color_missed_call, activity.theme) private var secondaryTextColor = textColor.adjustAlpha(0.6f) private var textToHighlight = "" private var durationPadding = resources.getDimension(R.dimen.normal_margin).toInt() diff --git a/app/src/main/kotlin/org/fossify/phone/helpers/AppPermission.kt b/app/src/main/kotlin/org/fossify/phone/helpers/AppPermission.kt new file mode 100644 index 000000000..2c3c33e7f --- /dev/null +++ b/app/src/main/kotlin/org/fossify/phone/helpers/AppPermission.kt @@ -0,0 +1,209 @@ +package org.fossify.phone.helpers + +import android.Manifest +import android.app.role.RoleManager +import android.content.Context +import android.content.pm.PackageManager +import android.provider.Settings +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import org.fossify.commons.extensions.canUseFullScreenIntent +import org.fossify.commons.extensions.isDefaultDialer +import org.fossify.commons.helpers.isQPlus +import org.fossify.commons.helpers.isRPlus +import org.fossify.commons.helpers.isTiramisuPlus +import org.fossify.commons.helpers.isUpsideDownCakePlus +import org.fossify.phone.R + +/** + * Everything the dialer needs the user to hand over, in the order it is presented on the setup + * screen. [runtimePermissions] is empty for the entries that can only be granted by walking the + * user into a system settings screen or a role request. + */ +enum class AppPermission( + @StringRes val titleRes: Int, + @StringRes val summaryRes: Int, + @DrawableRes val iconRes: Int, + val isRequired: Boolean, + val runtimePermissions: List = emptyList(), +) { + DEFAULT_DIALER( + titleRes = R.string.permission_default_dialer, + summaryRes = R.string.permission_default_dialer_summary, + iconRes = R.drawable.ic_simple_phone_vector, + isRequired = true, + ), + + CONTACTS( + titleRes = R.string.permission_contacts, + summaryRes = R.string.permission_contacts_summary, + iconRes = R.drawable.ic_person_vector, + isRequired = true, + runtimePermissions = listOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS, + ), + ), + + PHONE( + titleRes = R.string.permission_phone, + summaryRes = R.string.permission_phone_summary, + iconRes = R.drawable.ic_phone_vector, + isRequired = true, + runtimePermissions = listOf( + Manifest.permission.CALL_PHONE, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.ANSWER_PHONE_CALLS, + ), + ), + + CALL_LOG( + titleRes = R.string.permission_call_log, + summaryRes = R.string.permission_call_log_summary, + iconRes = R.drawable.ic_clock_vector, + isRequired = true, + runtimePermissions = listOf( + Manifest.permission.READ_CALL_LOG, + Manifest.permission.WRITE_CALL_LOG, + ), + ), + + NOTIFICATIONS( + titleRes = R.string.permission_notifications, + summaryRes = R.string.permission_notifications_summary, + iconRes = R.drawable.ic_bell_vector, + isRequired = true, + runtimePermissions = listOf(Manifest.permission.POST_NOTIFICATIONS), + ), + + FULL_SCREEN_INTENT( + titleRes = R.string.permission_full_screen_intent, + summaryRes = R.string.permission_full_screen_intent_summary, + iconRes = R.drawable.ic_lock_vector, + isRequired = true, + ), + + DRAW_OVER_OTHER_APPS( + titleRes = R.string.permission_draw_over_other_apps, + summaryRes = R.string.permission_draw_over_other_apps_summary, + iconRes = R.drawable.ic_info_outline_vector, + isRequired = false, + ), + + CALLER_ID( + titleRes = R.string.permission_caller_id, + summaryRes = R.string.permission_caller_id_summary, + iconRes = R.drawable.ic_block_vector, + isRequired = false, + ); + + /** true when granting means leaving the app for a system screen, so we cannot show a dialog */ + val opensSystemScreen: Boolean get() = runtimePermissions.isEmpty() +} + +/** + * Hides the entries that do not exist on the running OS version, so the setup screen never shows a + * row the user has no way to act on. + */ +fun appPermissions(): List = AppPermission.entries.filter { + when (it) { + AppPermission.NOTIFICATIONS -> isTiramisuPlus() + AppPermission.FULL_SCREEN_INTENT -> isUpsideDownCakePlus() + AppPermission.CALLER_ID -> isQPlus() + else -> true + } +} + +fun Context.isPermissionGranted(permission: AppPermission): Boolean { + return when (permission) { + AppPermission.DEFAULT_DIALER -> isDefaultDialer() + AppPermission.FULL_SCREEN_INTENT -> canUseFullScreenIntent() + AppPermission.DRAW_OVER_OTHER_APPS -> Settings.canDrawOverlays(this) + AppPermission.CALLER_ID -> isCallScreeningRoleHeld() + else -> permission.runtimePermissions.all { hasRuntimePermission(it) } + } +} + +fun Context.hasRuntimePermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +} + +fun Context.isCallScreeningRoleHeld(): Boolean { + if (!isQPlus()) { + return false + } + + return try { + val roleManager = getSystemService(RoleManager::class.java) + roleManager?.isRoleAvailable(RoleManager.ROLE_CALL_SCREENING) == true && + roleManager.isRoleHeld(RoleManager.ROLE_CALL_SCREENING) + } catch (_: Exception) { + false + } +} + +/** Required entries that are still missing, i.e. the ones worth nagging the user about. */ +fun Context.getMissingRequiredPermissions(): List { + return appPermissions().filter { it.isRequired && !isPermissionGranted(it) } +} + +/** + * Installers whose apps the platform exempts from Restricted Settings. The real allowlist is a + * per-device XML under /system/etc/sysconfig that we cannot read, so this covers Play plus the + * major OEM stores. Anything else - F-Droid, Obtainium, a file manager, adb - is treated as + * sideloaded, which is the safe direction to be wrong in: an unnecessary hint costs the user a few + * seconds, a missing one leaves them with a dialer that cannot answer calls. + */ +private val EXEMPT_INSTALLERS = setOf( + "com.android.vending", // Google Play + "com.sec.android.app.samsungapps", // Galaxy Store + "com.heytap.market", // OPPO/OnePlus/realme App Market + "com.oppo.market", + "com.xiaomi.market", // Xiaomi GetApps + "com.huawei.appmarket", // Huawei AppGallery + "com.vivo.appstore", +) + +/** + * The Restricted Settings entries, as mandated by the Android 15 CDD (9.8/H-0-1): the Dialer role + * and "Display over other apps" are both on that list, and both are things this app cannot work + * without. Call screening is not listed but OEMs may extend the set. + */ +private val RESTRICTED_SETTING_PERMISSIONS = setOf( + AppPermission.DEFAULT_DIALER, + AppPermission.DRAW_OVER_OTHER_APPS, + AppPermission.CALLER_ID, +) + +/** + * True when the user is likely to find the default-dialer and overlay toggles greyed out, with a + * warning that the app is unsafe, and has to unblock them from App info first. Restricted Settings + * arrived in Android 13 and was extended to cover roles in Android 15; there is no public API to + * query the state (EnhancedConfirmationManager is a system API), so this is inferred from the + * install source. + */ +fun Context.mayNeedRestrictedSettingsUnlock(): Boolean { + if (!isTiramisuPlus()) { + return false + } + + if (RESTRICTED_SETTING_PERMISSIONS.none { it in appPermissions() && !isPermissionGranted(it) }) { + return false + } + + return getInstallerPackageName() !in EXEMPT_INSTALLERS +} + +private fun Context.getInstallerPackageName(): String? { + return try { + if (isRPlus()) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/kotlin/org/fossify/phone/helpers/CallContactHelper.kt b/app/src/main/kotlin/org/fossify/phone/helpers/CallContactHelper.kt index cdd52d437..036fafaf0 100644 --- a/app/src/main/kotlin/org/fossify/phone/helpers/CallContactHelper.kt +++ b/app/src/main/kotlin/org/fossify/phone/helpers/CallContactHelper.kt @@ -13,6 +13,21 @@ import org.fossify.phone.R import org.fossify.phone.extensions.config import org.fossify.phone.extensions.isConference import org.fossify.phone.models.CallContact +import java.util.Collections + +// Resolving a caller loads the whole contacts database, and the notification is rebuilt on every +// single call state change, so the result is cached per handle. Bounded at MAX_CACHE_SIZE because +// Telecom never hands us more than a couple of concurrent calls. +private const val MAX_CACHE_SIZE = 4 +private const val CACHE_LOAD_FACTOR = 0.75f +private val contactCache = Collections.synchronizedMap( + object : LinkedHashMap(MAX_CACHE_SIZE, CACHE_LOAD_FACTOR, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = + size > MAX_CACHE_SIZE + } +) + +fun clearCallContactCache() = contactCache.clear() fun getCallContact(context: Context, call: Call?, callback: (CallContact) -> Unit) { if (call.isConference()) { @@ -20,61 +35,72 @@ fun getCallContact(context: Context, call: Call?, callback: (CallContact) -> Uni return } - val privateCursor = context.getMyContactsCursor(favoritesOnly = false, withPhoneNumbersOnly = true) + val handle = try { + call?.details?.handle?.toString() + } catch (_: NullPointerException) { + null + } + + // everything below has to stay off the main thread: callers do contacts and thumbnail lookups + // inside the callback, so a cache hit must not shortcut the dispatch ensureBackgroundThread { val callContact = CallContact("", "", "", "") - val handle = try { - call?.details?.handle?.toString() - } catch (e: NullPointerException) { - null - } - if (handle == null) { callback(callContact) return@ensureBackgroundThread } + // CallContact is mutable, hand out copies so a caller cannot poison the cache + contactCache[handle]?.let { + callback(it.copy()) + return@ensureBackgroundThread + } + val uri = Uri.decode(handle) - if (uri.startsWith("tel:")) { - val number = uri.substringAfter("tel:") - ContactsHelper(context).getContacts(getAll = true, showOnlyContactsWithNumbers = true) { contacts -> - val privateContacts = MyContactsContentProvider.getContacts(context, privateCursor) - if (privateContacts.isNotEmpty()) { - contacts.addAll(privateContacts) - } + if (!uri.startsWith("tel:")) { + // SIP and other schemes cannot be looked up by number, but the caller still needs an + // answer or the notification is never posted + callback(callContact) + return@ensureBackgroundThread + } - val contactsWithMultipleNumbers = contacts.filter { it.phoneNumbers.size > 1 } - val numbersToContactIDMap = HashMap() - contactsWithMultipleNumbers.forEach { contact -> - contact.phoneNumbers.forEach { phoneNumber -> - numbersToContactIDMap[phoneNumber.value] = contact.contactId - numbersToContactIDMap[phoneNumber.normalizedNumber] = contact.contactId - } - } + val number = uri.substringAfter("tel:") + val privateCursor = context.getMyContactsCursor( + favoritesOnly = false, + withPhoneNumbersOnly = true + ) - callContact.number = if (context.config.formatPhoneNumbers) { - number.formatPhoneNumber() - } else { - number - } + ContactsHelper(context).getContacts(getAll = true, showOnlyContactsWithNumbers = true) { contacts -> + val privateContacts = MyContactsContentProvider.getContacts(context, privateCursor) + if (privateContacts.isNotEmpty()) { + contacts.addAll(privateContacts) + } - val contact = contacts.firstOrNull { it.doesHavePhoneNumber(number) } - if (contact != null) { - callContact.name = contact.getNameToDisplay() - callContact.photoUri = contact.photoUri + callContact.number = if (context.config.formatPhoneNumbers) { + number.formatPhoneNumber() + } else { + number + } + + val contact = contacts.firstOrNull { it.doesHavePhoneNumber(number) } + if (contact != null) { + callContact.name = contact.getNameToDisplay() + callContact.photoUri = contact.photoUri - if (contact.phoneNumbers.size > 1) { - val specificPhoneNumber = contact.phoneNumbers.firstOrNull { it.value == number } - if (specificPhoneNumber != null) { - callContact.numberLabel = context.getPhoneNumberTypeText(specificPhoneNumber.type, specificPhoneNumber.label) - } + if (contact.phoneNumbers.size > 1) { + val specificPhoneNumber = contact.phoneNumbers.firstOrNull { it.value == number } + if (specificPhoneNumber != null) { + callContact.numberLabel = context.getPhoneNumberTypeText( + specificPhoneNumber.type, specificPhoneNumber.label + ) } - } else { - callContact.name = callContact.number } - - callback(callContact) + } else { + callContact.name = callContact.number } + + contactCache[handle] = callContact.copy() + callback(callContact) } } } diff --git a/app/src/main/kotlin/org/fossify/phone/helpers/CallManager.kt b/app/src/main/kotlin/org/fossify/phone/helpers/CallManager.kt index 51541c746..7316eca10 100644 --- a/app/src/main/kotlin/org/fossify/phone/helpers/CallManager.kt +++ b/app/src/main/kotlin/org/fossify/phone/helpers/CallManager.kt @@ -2,6 +2,7 @@ package org.fossify.phone.helpers import android.annotation.SuppressLint import android.os.Handler +import android.os.Looper import android.telecom.Call import android.telecom.CallAudioState import android.telecom.InCallService @@ -99,7 +100,7 @@ class CallManager { private fun getCallAudioState() = inCallService?.callAudioState fun getSupportedAudioRoutes(): Array { - return AudioRoute.values().filter { + return AudioRoute.entries.filter { val supportedRouteMask = getCallAudioState()?.supportedRouteMask if (supportedRouteMask != null) { supportedRouteMask and it.route == it.route @@ -203,7 +204,7 @@ class CallManager { fun keypad(char: Char) { call?.playDtmfTone(char) - Handler().postDelayed({ + Handler(Looper.getMainLooper()).postDelayed({ call?.stopDtmfTone() }, DIALPAD_TONE_LENGTH_MS) } diff --git a/app/src/main/kotlin/org/fossify/phone/helpers/CallNotificationManager.kt b/app/src/main/kotlin/org/fossify/phone/helpers/CallNotificationManager.kt index 90b527e9e..a402cea3e 100644 --- a/app/src/main/kotlin/org/fossify/phone/helpers/CallNotificationManager.kt +++ b/app/src/main/kotlin/org/fossify/phone/helpers/CallNotificationManager.kt @@ -1,6 +1,5 @@ package org.fossify.phone.helpers -import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager.IMPORTANCE_DEFAULT @@ -8,6 +7,7 @@ import android.app.NotificationManager.IMPORTANCE_HIGH import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.telecom.Call import android.widget.RemoteViews import org.fossify.commons.extensions.notificationManager @@ -15,101 +15,133 @@ import org.fossify.commons.extensions.setText import org.fossify.commons.extensions.setVisibleIf import org.fossify.phone.R import org.fossify.phone.activities.CallActivity +import org.fossify.phone.extensions.getStateCompat import org.fossify.phone.receivers.CallActionReceiver class CallNotificationManager(private val context: Context) { companion object { - private const val CALL_NOTIFICATION_ID = 42 + const val CALL_NOTIFICATION_ID = 42 private const val ACCEPT_CALL_CODE = 0 private const val DECLINE_CALL_CODE = 1 + private const val CHANNEL_ID = "simple_dialer_call" + private const val CHANNEL_ID_HIGH_PRIORITY = "simple_dialer_call_high_priority" } private val notificationManager = context.notificationManager private val callContactAvatarHelper = CallContactAvatarHelper(context) - @SuppressLint("NewApi") + /** + * Builds the notification synchronously, so it can be handed to + * [android.app.Service.startForeground] from within onCallAdded. Resolving the caller's contact + * hits the contacts provider and cannot be done here without blocking the InCallService + * callback, so the raw number is shown until [setupNotification] replaces this notification + * with the enriched one under the same id. + * + * [lowPriority] must be the same value that is later passed to [setupNotification], otherwise + * this notification would attach a full screen intent the caller decided not to use. + */ + fun buildForegroundNotification(call: Call, lowPriority: Boolean): Notification { + val number = try { + call.details.handle?.schemeSpecificPart + } catch (_: Exception) { + // details can throw once the call is already being torn down + null + } + + return buildNotification( + callerName = number?.ifEmpty { null } ?: context.getString(R.string.unknown_caller), + callState = call.getStateCompat(), + lowPriority = lowPriority, + avatar = null, + ) + } + fun setupNotification(lowPriority: Boolean = false) { getCallContact(context.applicationContext, CallManager.getPrimaryCall()) { callContact -> - val callContactAvatar = callContactAvatarHelper.getCallContactAvatar(callContact) - val callState = CallManager.getState() - val isHighPriority = callState == Call.STATE_RINGING && !lowPriority - val channelId = - if (isHighPriority) "simple_dialer_call_high_priority" else "simple_dialer_call" - createNotificationChannel(isHighPriority, channelId) - - val openAppIntent = CallActivity.getStartIntent(context) - val openAppPendingIntent = - PendingIntent.getActivity(context, 0, openAppIntent, PendingIntent.FLAG_MUTABLE) - - val acceptCallIntent = Intent(context, CallActionReceiver::class.java) - acceptCallIntent.action = ACCEPT_CALL - val acceptPendingIntent = - PendingIntent.getBroadcast( - context, - ACCEPT_CALL_CODE, - acceptCallIntent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - ) - - val declineCallIntent = Intent(context, CallActionReceiver::class.java) - declineCallIntent.action = DECLINE_CALL - val declinePendingIntent = - PendingIntent.getBroadcast( - context, - DECLINE_CALL_CODE, - declineCallIntent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - ) - + val callState = CallManager.getState() ?: return@getCallContact var callerName = callContact.name.ifEmpty { context.getString(R.string.unknown_caller) } if (callContact.numberLabel.isNotEmpty()) { callerName += " - ${callContact.numberLabel}" } - val contentTextId = when (callState) { - Call.STATE_RINGING -> R.string.is_calling - Call.STATE_DIALING -> R.string.dialing - Call.STATE_DISCONNECTED -> R.string.call_ended - Call.STATE_DISCONNECTING -> R.string.call_ending - else -> R.string.ongoing_call + val notification = buildNotification( + callerName = callerName, + callState = callState, + lowPriority = lowPriority, + // already circular, getCallContactAvatar crops it + avatar = callContactAvatarHelper.getCallContactAvatar(callContact), + ) + + // it's rare but possible for the call state to change by now + if (CallManager.getState() == callState) { + notificationManager.notify(CALL_NOTIFICATION_ID, notification) } + } + } - val collapsedView = RemoteViews(context.packageName, R.layout.call_notification).apply { - setText(R.id.notification_caller_name, callerName) - setText(R.id.notification_call_status, context.getString(contentTextId)) - setVisibleIf(R.id.notification_accept_call, callState == Call.STATE_RINGING) + private fun buildNotification( + callerName: String, + callState: Int, + lowPriority: Boolean, + avatar: Bitmap?, + ): Notification { + val isHighPriority = callState == Call.STATE_RINGING && !lowPriority + val channelId = if (isHighPriority) CHANNEL_ID_HIGH_PRIORITY else CHANNEL_ID + createNotificationChannel(isHighPriority, channelId) + + val openAppPendingIntent = PendingIntent.getActivity( + context, 0, CallActivity.getStartIntent(context), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val acceptPendingIntent = PendingIntent.getBroadcast( + context, ACCEPT_CALL_CODE, + Intent(context, CallActionReceiver::class.java).apply { action = ACCEPT_CALL }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val declinePendingIntent = PendingIntent.getBroadcast( + context, DECLINE_CALL_CODE, + Intent(context, CallActionReceiver::class.java).apply { action = DECLINE_CALL }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val contentTextId = when (callState) { + Call.STATE_RINGING -> R.string.is_calling + Call.STATE_DIALING -> R.string.dialing + Call.STATE_DISCONNECTED -> R.string.call_ended + Call.STATE_DISCONNECTING -> R.string.call_ending + else -> R.string.ongoing_call + } - setOnClickPendingIntent(R.id.notification_decline_call, declinePendingIntent) - setOnClickPendingIntent(R.id.notification_accept_call, acceptPendingIntent) + val collapsedView = RemoteViews(context.packageName, R.layout.call_notification).apply { + setText(R.id.notification_caller_name, callerName) + setText(R.id.notification_call_status, context.getString(contentTextId)) + setVisibleIf(R.id.notification_accept_call, callState == Call.STATE_RINGING) - if (callContactAvatar != null) { - setImageViewBitmap( - R.id.notification_thumbnail, - callContactAvatarHelper.getCircularBitmap(callContactAvatar) - ) - } - } + setOnClickPendingIntent(R.id.notification_decline_call, declinePendingIntent) + setOnClickPendingIntent(R.id.notification_accept_call, acceptPendingIntent) - val builder = Notification.Builder(context, channelId) - .setSmallIcon(R.drawable.ic_phone_vector) - .setContentIntent(openAppPendingIntent) - .setCategory(Notification.CATEGORY_CALL) - .setCustomContentView(collapsedView) - .setOngoing(true) - .setUsesChronometer(callState == Call.STATE_ACTIVE) - .setChannelId(channelId) - .setStyle(Notification.DecoratedCustomViewStyle()) - - if (isHighPriority) { - builder.setFullScreenIntent(openAppPendingIntent, true) + if (avatar != null) { + setImageViewBitmap(R.id.notification_thumbnail, avatar) } + } - val notification = builder.build() - // it's rare but possible for the call state to change by now - if (CallManager.getState() == callState) { - notificationManager.notify(CALL_NOTIFICATION_ID, notification) - } + val builder = Notification.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_phone_vector) + .setContentIntent(openAppPendingIntent) + .setCategory(Notification.CATEGORY_CALL) + .setCustomContentView(collapsedView) + .setOngoing(true) + .setUsesChronometer(callState == Call.STATE_ACTIVE) + .setChannelId(channelId) + .setStyle(Notification.DecoratedCustomViewStyle()) + + if (isHighPriority) { + builder.setFullScreenIntent(openAppPendingIntent, true) } + + return builder.build() } fun createNotificationChannel(isHighPriority: Boolean, channelId: String) { diff --git a/app/src/main/kotlin/org/fossify/phone/helpers/Config.kt b/app/src/main/kotlin/org/fossify/phone/helpers/Config.kt index 9b6e564a3..5e076dec3 100644 --- a/app/src/main/kotlin/org/fossify/phone/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/phone/helpers/Config.kt @@ -135,4 +135,9 @@ class Config(context: Context) : BaseConfig(context) { var alwaysShowFullscreen: Boolean get() = prefs.getBoolean(ALWAYS_SHOW_FULLSCREEN, false) set(alwaysShowFullscreen) = prefs.edit().putBoolean(ALWAYS_SHOW_FULLSCREEN, alwaysShowFullscreen).apply() + + var wasPermissionsSetupShown: Boolean + get() = prefs.getBoolean(WAS_PERMISSIONS_SETUP_SHOWN, false) + set(wasPermissionsSetupShown) = prefs.edit() + .putBoolean(WAS_PERMISSIONS_SETUP_SHOWN, wasPermissionsSetupShown).apply() } diff --git a/app/src/main/kotlin/org/fossify/phone/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/phone/helpers/Constants.kt index 754151c80..d920b81e6 100644 --- a/app/src/main/kotlin/org/fossify/phone/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/phone/helpers/Constants.kt @@ -19,6 +19,7 @@ const val DIALPAD_VIBRATION = "dialpad_vibration" const val DIALPAD_BEEPS = "dialpad_beeps" const val HIDE_DIALPAD_NUMBERS = "hide_dialpad_numbers" const val ALWAYS_SHOW_FULLSCREEN = "always_show_fullscreen" +const val WAS_PERMISSIONS_SETUP_SHOWN = "was_permissions_setup_shown" const val ALL_TABS_MASK = TAB_CONTACTS or TAB_FAVORITES or TAB_CALL_HISTORY diff --git a/app/src/main/kotlin/org/fossify/phone/services/CallService.kt b/app/src/main/kotlin/org/fossify/phone/services/CallService.kt index de161d030..29138fd23 100644 --- a/app/src/main/kotlin/org/fossify/phone/services/CallService.kt +++ b/app/src/main/kotlin/org/fossify/phone/services/CallService.kt @@ -3,6 +3,7 @@ package org.fossify.phone.services import android.telecom.Call import android.telecom.CallAudioState import android.telecom.InCallService +import android.util.Log import org.fossify.commons.extensions.canUseFullScreenIntent import org.fossify.commons.extensions.hasPermission import org.fossify.commons.helpers.PERMISSION_POST_NOTIFICATIONS @@ -14,11 +15,17 @@ import org.fossify.phone.extensions.powerManager import org.fossify.phone.helpers.CallManager import org.fossify.phone.helpers.CallNotificationManager import org.fossify.phone.helpers.NoCall +import org.fossify.phone.helpers.clearCallContactCache import org.fossify.phone.models.Events import org.greenrobot.eventbus.EventBus class CallService : InCallService() { + companion object { + private const val TAG = "CallService" + } + private val callNotificationManager by lazy { CallNotificationManager(this) } + private var isForegroundStarted = false private val callListener = object : Call.Callback() { override fun onStateChanged(call: Call, state: Int) { @@ -49,6 +56,12 @@ class CallService : InCallService() { else -> true } + // Promoting to a phoneCall foreground service keeps the process out of the cached/frozen + // state for the duration of the call and makes the ongoing-call notification + // non-dismissible. The priority must match the one setupNotification uses below, otherwise + // this notification would attach a full screen intent the user opted out of. + startForegroundIfNeeded(call, lowPriority) + callNotificationManager.setupNotification(lowPriority) if ( lowPriority @@ -72,11 +85,17 @@ class CallService : InCallService() { CallManager.onCallRemoved(call) if (CallManager.getPhoneState() == NoCall) { CallManager.inCallService = null + stopForegroundIfNeeded() callNotificationManager.cancelNotification() + clearCallContactCache() } else { callNotificationManager.setupNotification() if (wasPrimaryCall) { - startActivity(CallActivity.getStartIntent(this)) + try { + startActivity(CallActivity.getStartIntent(this)) + } catch (_: Exception) { + callNotificationManager.setupNotification() + } } } @@ -90,8 +109,50 @@ class CallService : InCallService() { } } + override fun onBringToForeground(showDialpad: Boolean) { + super.onBringToForeground(showDialpad) + try { + startActivity(CallActivity.getStartIntent(this)) + } catch (_: Exception) { + callNotificationManager.setupNotification() + } + } + override fun onDestroy() { super.onDestroy() + stopForegroundIfNeeded() callNotificationManager.cancelNotification() } + + private fun startForegroundIfNeeded(call: Call, lowPriority: Boolean) { + if (isForegroundStarted) { + return + } + + try { + startForeground( + CallNotificationManager.CALL_NOTIFICATION_ID, + callNotificationManager.buildForegroundNotification(call, lowPriority) + ) + isForegroundStarted = true + } catch (e: SecurityException) { + // thrown when the phoneCall foreground service type is unavailable to us, e.g. missing + // FOREGROUND_SERVICE_PHONE_CALL or not the default dialer + Log.w(TAG, "Not allowed to start a phoneCall foreground service", e) + } catch (e: IllegalStateException) { + // ForegroundServiceStartNotAllowedException and InvalidForegroundServiceTypeException + Log.w(TAG, "Could not start the foreground service for the ongoing call", e) + } + } + + private fun stopForegroundIfNeeded() { + if (!isForegroundStarted) { + return + } + + // has to happen before cancelNotification, a foreground service's notification cannot be + // dismissed while the service is still attached to it + stopForeground(STOP_FOREGROUND_REMOVE) + isForegroundStarted = false + } } diff --git a/app/src/main/res/layout/activity_permissions.xml b/app/src/main/res/layout/activity_permissions.xml new file mode 100644 index 000000000..e41b4eb38 --- /dev/null +++ b/app/src/main/res/layout/activity_permissions.xml @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 69c98cc5a..5f0c731b1 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -114,6 +114,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1fa84f2b5..75a20ea2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,41 @@ Import call history Calling accounts + + Permissions & access + To place and receive calls reliably, this app needs the access listed below. You can change any of it later. + Required + Recommended + Done + Granted + Not granted + Open settings + Default phone app + Required for this app to handle your calls at all. + Contacts + Shows caller names and photos instead of bare numbers. + Phone + Lets the app place calls, answer them and read your SIM cards. + Call log + Shows your recent calls and records new ones. + Notifications + Needed to show the ongoing call and missed call notifications. + Full screen notifications + Without this, incoming calls only show as a small notification instead of the full screen call UI. + Display over other apps + Helps the call screen appear while you are using another app. + Caller ID & spam + Required only if you want to block unknown or hidden numbers. + Some required permissions are still missing + + + Are some toggles greyed out? + Tap to see how to unblock them + Android blocks apps that were not installed from an app store it recognises from becoming the default phone app or displaying over other apps. The toggle looks greyed out and your device may warn that the app is unsafe. This is not a fault in the app, and you can unblock it yourself. + 1. Tap the greyed out toggle once and dismiss the warning. The unblock option only appears after Android has actually blocked it.\n\n2. Open App info for this app, with the button below or by long pressing the app icon.\n\n3. Tap the three dots (⋮) in the top right corner.\n\n4. Choose \"Allow restricted settings\". It is called \"Allow restricted permission\" on ColorOS and OxygenOS, and on some devices it sits under \"Permissions\" first.\n\n5. Come back here and grant the access again.\n\nIf there is no three dot menu at all, which has been reported on some OnePlus and OPPO builds, install this app through an app store instead of opening the APK file directly. + Open app info + Learn more + I hear incoming calls, but the screen doesn\'t turn on. What can I do? Such issues can have many device and system specific reasons, hard to say in general. You should look around in your device settings and make sure that the app is allowed to pop up when in background and allow displaying over other apps. @@ -91,6 +126,8 @@ Due to system restrictions, emergency calls can only be made through your system\'s default dialer or phone app. To ensure emergency calls work properly, you need to keep the system app installed and enabled. Why are some of my contacts missing from the app? Fossify Phone only displays contacts that have a phone number associated with them. Contacts without a phone number are not shown in the app. + Why can\'t I set this app as my default phone app? The toggle is greyed out. + Since Android 13, and for the default phone app specifically since Android 15, Android blocks apps that were not installed from an app store it recognises from taking sensitive settings. It calls this "Restricted settings", and it affects the default phone app role, displaying over other apps, accessibility, notification access, device admin and usage access. Every Android 15 phone is required to do this, so it is not specific to one manufacturer, though the wording differs: ColorOS and OxygenOS say "Allow restricted permission" while stock Android says "Allow restricted settings".\n\nTo unblock it, tap the greyed out toggle once and dismiss the warning, then open App info for this app, tap the three dots in the top right corner and choose that option. The Permissions & access screen in the app settings has a button that takes you straight to App info. Note the option only shows up after Android has actually blocked something, so the order matters.