1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.systemui.accessibility.fontscaling
17 
18 import android.content.Context
19 import android.content.pm.ActivityInfo
20 import android.content.res.Configuration
21 import android.database.ContentObserver
22 import android.os.Bundle
23 import android.os.Handler
24 import android.provider.Settings
25 import android.util.TypedValue
26 import android.view.LayoutInflater
27 import android.widget.Button
28 import android.widget.SeekBar
29 import android.widget.TextView
30 import androidx.annotation.MainThread
31 import androidx.annotation.WorkerThread
32 import com.android.systemui.R
33 import com.android.systemui.common.ui.view.SeekBarWithIconButtonsView
34 import com.android.systemui.common.ui.view.SeekBarWithIconButtonsView.OnSeekBarWithIconButtonsChangeListener
35 import com.android.systemui.common.ui.view.SeekBarWithIconButtonsView.OnSeekBarWithIconButtonsChangeListener.ControlUnitType
36 import com.android.systemui.dagger.qualifiers.Background
37 import com.android.systemui.dagger.qualifiers.Main
38 import com.android.systemui.settings.UserTracker
39 import com.android.systemui.statusbar.phone.SystemUIDialog
40 import com.android.systemui.util.concurrency.DelayableExecutor
41 import com.android.systemui.util.settings.SecureSettings
42 import com.android.systemui.util.settings.SystemSettings
43 import com.android.systemui.util.time.SystemClock
44 import java.util.concurrent.atomic.AtomicInteger
45 import kotlin.math.roundToInt
46 
47 /** The Dialog that contains a seekbar for changing the font size. */
48 class FontScalingDialog(
49     context: Context,
50     private val systemSettings: SystemSettings,
51     private val secureSettings: SecureSettings,
52     private val systemClock: SystemClock,
53     private val userTracker: UserTracker,
54     @Main mainHandler: Handler,
55     @Background private val backgroundDelayableExecutor: DelayableExecutor
56 ) : SystemUIDialog(context) {
57     private val MIN_UPDATE_INTERVAL_MS: Long = 800
58     private val CHANGE_BY_SEEKBAR_DELAY_MS: Long = 100
59     private val CHANGE_BY_BUTTON_DELAY_MS: Long = 300
60     private val strEntryValues: Array<String> =
61         context.resources.getStringArray(com.android.settingslib.R.array.entryvalues_font_size)
62     private lateinit var title: TextView
63     private lateinit var doneButton: Button
64     private lateinit var seekBarWithIconButtonsView: SeekBarWithIconButtonsView
65     private var lastProgress: AtomicInteger = AtomicInteger(-1)
66     private var lastUpdateTime: Long = 0
67     private var cancelUpdateFontScaleRunnable: Runnable? = null
68 
69     private val configuration: Configuration = Configuration(context.resources.configuration)
70 
71     private val fontSizeObserver =
72         object : ContentObserver(mainHandler) {
73             override fun onChange(selfChange: Boolean) {
74                 lastUpdateTime = systemClock.elapsedRealtime()
75             }
76         }
77 
78     override fun onCreate(savedInstanceState: Bundle?) {
79         setTitle(R.string.font_scaling_dialog_title)
80         setView(LayoutInflater.from(context).inflate(R.layout.font_scaling_dialog, null))
81         setPositiveButton(
82             R.string.quick_settings_done,
83             /* onClick = */ null,
84             /* dismissOnClick = */ true
85         )
86         super.onCreate(savedInstanceState)
87 
88         title = requireViewById(com.android.internal.R.id.alertTitle)
89         doneButton = requireViewById(com.android.internal.R.id.button1)
90         seekBarWithIconButtonsView = requireViewById(R.id.font_scaling_slider)
91 
92         val labelArray = arrayOfNulls<String>(strEntryValues.size)
93         for (i in strEntryValues.indices) {
94             labelArray[i] =
95                 context.resources.getString(
96                     com.android.settingslib.R.string.font_scale_percentage,
97                     (strEntryValues[i].toFloat() * 100).roundToInt()
98                 )
99         }
100         seekBarWithIconButtonsView.setProgressStateLabels(labelArray)
101 
102         seekBarWithIconButtonsView.setMax((strEntryValues).size - 1)
103 
104         val currentScale =
105             systemSettings.getFloatForUser(Settings.System.FONT_SCALE, 1.0f, userTracker.userId)
106         lastProgress.set(fontSizeValueToIndex(currentScale))
107         seekBarWithIconButtonsView.setProgress(lastProgress.get())
108 
109         seekBarWithIconButtonsView.setOnSeekBarWithIconButtonsChangeListener(
110             object : OnSeekBarWithIconButtonsChangeListener {
111                 override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
112                     // Always provide preview configuration for text first when there is a change
113                     // in the seekbar progress.
114                     createTextPreview(progress)
115                 }
116 
117                 override fun onStartTrackingTouch(seekBar: SeekBar) {
118                     // Do nothing
119                 }
120 
121                 override fun onStopTrackingTouch(seekBar: SeekBar) {
122                     // Do nothing
123                 }
124 
125                 override fun onUserInteractionFinalized(
126                     seekBar: SeekBar,
127                     @ControlUnitType control: Int
128                 ) {
129                     if (control == ControlUnitType.BUTTON) {
130                         // The seekbar progress is changed by icon buttons
131                         changeFontSize(seekBar.progress, CHANGE_BY_BUTTON_DELAY_MS)
132                     } else {
133                         changeFontSize(seekBar.progress, CHANGE_BY_SEEKBAR_DELAY_MS)
134                     }
135                 }
136             }
137         )
138         doneButton.setOnClickListener { dismiss() }
139         systemSettings.registerContentObserver(Settings.System.FONT_SCALE, fontSizeObserver)
140     }
141 
142     /**
143      * Avoid SeekBar flickers when changing font scale. See the description from Setting at {@link
144      * TextReadingPreviewController#postCommitDelayed} for the reasons of flickers.
145      */
146     @MainThread
147     fun updateFontScaleDelayed(delayMsFromSource: Long) {
148         doneButton.isEnabled = false
149 
150         var delayMs = delayMsFromSource
151         if (systemClock.elapsedRealtime() - lastUpdateTime < MIN_UPDATE_INTERVAL_MS) {
152             delayMs += MIN_UPDATE_INTERVAL_MS
153         }
154         cancelUpdateFontScaleRunnable?.run()
155         cancelUpdateFontScaleRunnable =
156             backgroundDelayableExecutor.executeDelayed({ updateFontScale() }, delayMs)
157     }
158 
159     override fun stop() {
160         cancelUpdateFontScaleRunnable?.run()
161         cancelUpdateFontScaleRunnable = null
162         systemSettings.unregisterContentObserver(fontSizeObserver)
163     }
164 
165     @MainThread
166     private fun changeFontSize(progress: Int, changedWithDelay: Long) {
167         if (progress != lastProgress.get()) {
168             lastProgress.set(progress)
169 
170             if (!fontSizeHasBeenChangedFromTile) {
171                 backgroundDelayableExecutor.execute { updateSecureSettingsIfNeeded() }
172                 fontSizeHasBeenChangedFromTile = true
173             }
174 
175             updateFontScaleDelayed(changedWithDelay)
176         }
177     }
178 
179     @WorkerThread
180     private fun fontSizeValueToIndex(value: Float): Int {
181         var lastValue = strEntryValues[0].toFloat()
182         for (i in 1 until strEntryValues.size) {
183             val thisValue = strEntryValues[i].toFloat()
184             if (value < lastValue + (thisValue - lastValue) * .5f) {
185                 return i - 1
186             }
187             lastValue = thisValue
188         }
189         return strEntryValues.size - 1
190     }
191 
192     override fun onConfigurationChanged(configuration: Configuration) {
193         super.onConfigurationChanged(configuration)
194 
195         val configDiff = configuration.diff(this.configuration)
196         this.configuration.setTo(configuration)
197 
198         if (configDiff and ActivityInfo.CONFIG_FONT_SCALE != 0) {
199             title.post {
200                 title.setTextAppearance(R.style.TextAppearance_Dialog_Title)
201                 doneButton.setTextAppearance(R.style.Widget_Dialog_Button)
202                 doneButton.isEnabled = true
203             }
204         }
205     }
206 
207     @WorkerThread
208     fun updateFontScale() {
209         if (
210             !systemSettings.putStringForUser(
211                 Settings.System.FONT_SCALE,
212                 strEntryValues[lastProgress.get()],
213                 userTracker.userId
214             )
215         ) {
216             title.post { doneButton.isEnabled = true }
217         }
218     }
219 
220     @WorkerThread
221     fun updateSecureSettingsIfNeeded() {
222         if (
223             secureSettings.getStringForUser(
224                 Settings.Secure.ACCESSIBILITY_FONT_SCALING_HAS_BEEN_CHANGED,
225                 userTracker.userId
226             ) != ON
227         ) {
228             secureSettings.putStringForUser(
229                 Settings.Secure.ACCESSIBILITY_FONT_SCALING_HAS_BEEN_CHANGED,
230                 ON,
231                 userTracker.userId
232             )
233         }
234     }
235 
236     /** Provides font size preview for text before putting the final settings to the system. */
237     fun createTextPreview(index: Int) {
238         val previewConfig = Configuration(configuration)
239         previewConfig.fontScale = strEntryValues[index].toFloat()
240 
241         val previewConfigContext = context.createConfigurationContext(previewConfig)
242         previewConfigContext.theme.setTo(context.theme)
243 
244         title.setTextSize(
245             TypedValue.COMPLEX_UNIT_PX,
246             previewConfigContext.resources.getDimension(R.dimen.dialog_title_text_size)
247         )
248     }
249 
250     companion object {
251         private const val ON = "1"
252         private const val OFF = "0"
253         private var fontSizeHasBeenChangedFromTile = false
254     }
255 }
256