8 |
9 | 
10 |
11 | > _Freedom from your phone_
12 |
13 | [](https://f-droid.org/packages/com.jkuester.unlauncher/)
16 |
17 | The goal of Unlauncher is to provide a clean and simple Android launcher experience. We believe you should have easy access to all of your apps without the distraction of bells, whistles, and notifications clamouring for your attention. You want to be able to use your phone, not have your phone use you!
18 |
19 |
20 |
21 |
22 |
23 |
24 | ## About
25 |
26 | Features:
27 |
28 | - List your top apps on the home screen
29 | - No app icons
30 | - Customizable app titles
31 | - Searchable drawer with all apps
32 | - Show/Hide the notification bar on your home screen
33 | - No wallpapers
34 | - Multiple color themes
35 |
36 | ## What people are saying
37 |
38 |
39 | Jake Ginesin - [dumb-phone](https://jakegines.in/blog/2023/dumb-phone)
40 |
41 |
42 | Side Of Burritos - [A minimalist dumb phone you should actually use](https://www.youtube.com/watch?v=OrZacTUhH0c) (VIDEO)
43 |
44 |
45 | zymotux - [Bare-bones smartphone “Unlauncher”](https://write.as/zymotux/bare-bones-smartphone-unlauncher)
46 |
47 |
48 | Maxime Vaillancourt - [Turning my smartphone into a boring tool](https://maximevaillancourt.com/blog/turning-my-smartphone-into-a-boring-tool)
49 |
50 |
51 | Linux Lounge - [A Quick Look At Unlauncher - Can This Launcher Help With Smartphone Addicition?](https://odysee.com/@LinuxLounge:b/a-quick-look-at-unlauncher-can-this) (VIDEO)
52 |
53 | ## Project History
54 |
55 | This project is a fork of the great [Slim Launcher](https://github.com/sduduzog/slim-launcher) by [sduduzog](https://github.com/sduduzog). The contributors to that project deserve all the credit for the beautiful layout of this app!
56 |
57 | The main differentiator between Unlauncher and Slim Launcher lies in the number of apps the launcher gives you access too. Slim Launcher takes the Spartan approach of only allowing access to seven apps. Unlauncher, on the other hand, allows you to pin up to six apps on the home screen and then gives you access to all the rest of your apps by swiping up into a searchable app drawer.
58 |
59 | ## Communication
60 |
61 | If you have any issues or questions, please log a [Github issue](https://github.com/jkuester/unlauncher/issues) for this repository.
62 |
63 | ## Contributing
64 |
65 | Contributions are welcome! Simply open a pull request. See the [development guide](./DEVELOPMENT.md) for details.
66 |
67 | If a monetary contribution is more your style, please consider buying [sduduzog](https://github.com/sduduzog) (the author of the original project) a [cup of coffee](https://buymeacoff.ee/sduduzog).
68 |
--------------------------------------------------------------------------------
/app/src/test/java/com/jkuester/unlauncher/fragment/CustomizeVisibleAppsFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.fragment
2 |
3 | import android.view.LayoutInflater
4 | import androidx.activity.ComponentActivity
5 | import androidx.constraintlayout.widget.ConstraintLayout
6 | import com.jkuester.unlauncher.bindings.setupVisibleAppsBackButton
7 | import com.jkuester.unlauncher.bindings.setupVisibleAppsList
8 | import com.jkuester.unlauncher.datasource.DataRepository
9 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApps
10 | import com.sduduzog.slimlauncher.R
11 | import com.sduduzog.slimlauncher.databinding.CustomizeAppDrawerVisibleAppsBinding
12 | import io.kotest.matchers.ints.exactly
13 | import io.kotest.matchers.shouldBe
14 | import io.mockk.every
15 | import io.mockk.impl.annotations.MockK
16 | import io.mockk.junit5.MockKExtension
17 | import io.mockk.justRun
18 | import io.mockk.mockk
19 | import io.mockk.mockkStatic
20 | import io.mockk.verify
21 | import org.junit.jupiter.api.BeforeEach
22 | import org.junit.jupiter.api.Test
23 | import org.junit.jupiter.api.extension.ExtendWith
24 |
25 | @MockKExtension.CheckUnnecessaryStub
26 | @MockKExtension.ConfirmVerification
27 | @ExtendWith(MockKExtension::class)
28 | class CustomizeVisibleAppsFragmentTest {
29 | @MockK
30 | lateinit var mActivity: ComponentActivity
31 | @MockK
32 | lateinit var appsRepo: DataRepository
33 | @MockK
34 | lateinit var view: ConstraintLayout
35 |
36 | private lateinit var fragment: CustomizeVisibleAppsFragment
37 |
38 | @BeforeEach
39 | fun beforeEach() {
40 | fragment = CustomizeVisibleAppsFragment()
41 | .apply {
42 | unlauncherAppsRepo = appsRepo
43 | iActivity = mActivity
44 | }
45 | }
46 |
47 | @Test
48 | fun onCreateView() {
49 | val inflater = mockk()
50 | every { inflater.inflate(any(), any(), any()) } returns view
51 |
52 | val result = fragment.onCreateView(inflater, null, null)
53 |
54 | result shouldBe view
55 | verify(exactly = 1) {
56 | inflater.inflate(
57 | R.layout.customize_app_drawer_visible_apps,
58 | null,
59 | false
60 | )
61 | }
62 | }
63 |
64 | @Test
65 | fun onViewCreated() {
66 | val options = mockk()
67 | mockkStatic(CustomizeAppDrawerVisibleAppsBinding::bind)
68 | every { CustomizeAppDrawerVisibleAppsBinding.bind(any()) } returns options
69 | val optionConsumer = mockk<(t: CustomizeAppDrawerVisibleAppsBinding) -> Unit>()
70 | justRun { optionConsumer(any()) }
71 | mockkStatic(::setupVisibleAppsBackButton, ::setupVisibleAppsList)
72 | every { setupVisibleAppsBackButton(any()) } returns optionConsumer
73 | every { setupVisibleAppsList(any()) } returns optionConsumer
74 |
75 | fragment.onViewCreated(view, null)
76 |
77 | verify(exactly = 1) { CustomizeAppDrawerVisibleAppsBinding.bind(view) }
78 | verify(exactly = 1) { setupVisibleAppsBackButton(mActivity) }
79 | verify(exactly = 1) { setupVisibleAppsList(appsRepo) }
80 | verify(exactly = 2) { optionConsumer(options) }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/src/main/java/com/jkuester/unlauncher/fragment/FragmentModule.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.fragment
2 |
3 | import android.view.LayoutInflater
4 | import androidx.datastore.core.DataStore
5 | import androidx.fragment.app.Fragment
6 | import androidx.lifecycle.LifecycleOwner
7 | import androidx.lifecycle.lifecycleScope
8 | import com.jkuester.unlauncher.datasource.DataRepository
9 | import com.jkuester.unlauncher.datasource.DataRepositoryImpl
10 | import com.jkuester.unlauncher.datastore.proto.CorePreferences
11 | import com.jkuester.unlauncher.datastore.proto.QuickButtonPreferences
12 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApps
13 | import dagger.Module
14 | import dagger.Provides
15 | import dagger.hilt.InstallIn
16 | import dagger.hilt.android.components.FragmentComponent
17 | import dagger.hilt.android.scopes.FragmentScoped
18 | import javax.inject.Qualifier
19 | import kotlinx.coroutines.CoroutineScope
20 |
21 | @Qualifier
22 | @Retention(AnnotationRetention.BINARY)
23 | annotation class WithFragmentLifecycle
24 |
25 | // java.util.function.Supplier class not supported in our current minimum version so roll our own.
26 | interface Supplier {
27 | fun get(): T
28 | }
29 |
30 | @Module
31 | @InstallIn(FragmentComponent::class)
32 | class FragmentModule {
33 | @Provides
34 | @FragmentScoped
35 | fun provideFragmentManager(fragment: Fragment) = fragment.childFragmentManager
36 |
37 | @Provides
38 | @FragmentScoped
39 | fun provideLayoutInflaterSupplier(fragment: Fragment) = object : Supplier {
40 | override fun get(): LayoutInflater = fragment.layoutInflater
41 | }
42 |
43 | @Provides
44 | @FragmentScoped
45 | fun provideLifecycleScope(fragment: Fragment): CoroutineScope = fragment.lifecycleScope
46 |
47 | @Provides
48 | @FragmentScoped
49 | fun provideLifecycleOwnerSupplier(fragment: Fragment) = object : Supplier {
50 | override fun get(): LifecycleOwner = fragment.viewLifecycleOwner
51 | }
52 |
53 | @Provides @WithFragmentLifecycle
54 | @FragmentScoped
55 | fun provideCorePreferencesRepo(
56 | prefsStore: DataStore,
57 | lifecycleScope: CoroutineScope,
58 | lifecycleOwnerSupplier: Supplier
59 | ): DataRepository = DataRepositoryImpl(
60 | prefsStore,
61 | lifecycleScope,
62 | lifecycleOwnerSupplier,
63 | CorePreferences::getDefaultInstance
64 | )
65 |
66 | @Provides
67 | @FragmentScoped
68 | fun provideQuickButtonPreferencesRepo(
69 | prefsStore: DataStore,
70 | lifecycleScope: CoroutineScope,
71 | lifecycleOwnerSupplier: Supplier
72 | ): DataRepository = DataRepositoryImpl(
73 | prefsStore,
74 | lifecycleScope,
75 | lifecycleOwnerSupplier,
76 | QuickButtonPreferences::getDefaultInstance
77 | )
78 |
79 | @Provides
80 | @FragmentScoped
81 | fun provideUnlauncherAppsRepo(
82 | prefsStore: DataStore,
83 | lifecycleScope: CoroutineScope,
84 | lifecycleOwnerSupplier: Supplier
85 | ): DataRepository = DataRepositoryImpl(
86 | prefsStore,
87 | lifecycleScope,
88 | lifecycleOwnerSupplier,
89 | UnlauncherApps::getDefaultInstance
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/app/src/main/res/navigation/nav_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
16 |
17 |
22 |
25 |
28 |
29 |
34 |
39 |
42 |
43 |
47 |
52 |
55 |
58 |
59 |
64 |
69 |
70 |
--------------------------------------------------------------------------------
/app/src/main/java/com/jkuester/unlauncher/bindings/CustomizeAppDrawerBindings.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.bindings
2 |
3 | import android.content.res.Resources
4 | import android.view.View.OnClickListener
5 | import androidx.activity.ComponentActivity
6 | import androidx.navigation.Navigation
7 | import com.jkuester.unlauncher.datasource.DataRepository
8 | import com.jkuester.unlauncher.datasource.toggleShowDrawerHeadings
9 | import com.jkuester.unlauncher.datastore.proto.CorePreferences
10 | import com.sduduzog.slimlauncher.R
11 | import com.sduduzog.slimlauncher.databinding.CustomizeAppDrawerBinding
12 |
13 | fun setupCustomizeAppDrawerBackButton(activity: ComponentActivity) = { options: CustomizeAppDrawerBinding ->
14 | options.headerBack.setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() }
15 | }
16 |
17 | private fun showHeadingSwitchListener(corePrefsRepo: DataRepository) = OnClickListener {
18 | corePrefsRepo.updateAsync(toggleShowDrawerHeadings())
19 | }
20 |
21 | private fun updateShowHeadingSwitchLayout(options: CustomizeAppDrawerBinding): (CorePreferences) -> Unit = {
22 | options.apply { showHeadingsSwitchToggle.isChecked = it.showDrawerHeadings }
23 | }
24 |
25 | fun setupShowHeadingSwitch(corePrefsRepo: DataRepository) = { options: CustomizeAppDrawerBinding ->
26 | showHeadingSwitchListener(corePrefsRepo)
27 | .also(options.showHeadingsSwitchTitle::setOnClickListener)
28 | .also(options.showHeadingsSwitchSubtitle::setOnClickListener)
29 | .also(options.showHeadingsSwitchToggle::setOnClickListener)
30 | corePrefsRepo.observe(updateShowHeadingSwitchLayout(options))
31 | }
32 |
33 | fun setupVisibleAppsButton(options: CustomizeAppDrawerBinding) = Navigation
34 | .createNavigateOnClickListener(R.id.action_customiseAppDrawerFragment_to_customiseAppDrawerAppListFragment)
35 | .also(options.visibleApps::setOnClickListener)
36 |
37 | private fun getSearchFieldOptionButtonPositionText(resources: Resources, corePrefs: CorePreferences) = corePrefs
38 | .searchBarPosition.number.let {
39 | resources.getStringArray(R.array.search_bar_position_array)[it].lowercase()
40 | }
41 |
42 | private fun getSearchFieldOptionsButtonKeyboardText(resources: Resources, corePrefs: CorePreferences) =
43 | when (corePrefs.activateKeyboardInDrawer) {
44 | true -> R.string.shown
45 | false -> R.string.hidden
46 | }.let(resources::getText)
47 |
48 | private fun getSearchFieldOptionButtonSubtitle(corePrefs: CorePreferences, resources: Resources): CharSequence {
49 | if (corePrefs.hasShowSearchBar() && !corePrefs.showSearchBar) {
50 | return resources.getText(R.string.customize_app_drawer_fragment_search_field_options_subtitle_status_hidden)
51 | }
52 | return resources.getString(
53 | R.string.customize_app_drawer_fragment_search_field_options_subtitle_status_shown,
54 | getSearchFieldOptionButtonPositionText(resources, corePrefs),
55 | getSearchFieldOptionsButtonKeyboardText(resources, corePrefs)
56 | )
57 | }
58 |
59 | fun setupSearchFieldOptionsButton(corePrefsRepo: DataRepository, resources: Resources) =
60 | { options: CustomizeAppDrawerBinding ->
61 | Navigation.createNavigateOnClickListener(R.id.action_customiseAppDrawerFragment_to_customizeSearchFieldFragment)
62 | .also(options.searchFieldOptionsTitle::setOnClickListener)
63 | .also(options.searchFieldOptionsSubtitle::setOnClickListener)
64 | corePrefsRepo.observe {
65 | options.searchFieldOptionsSubtitle.text = getSearchFieldOptionButtonSubtitle(it, resources)
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/app/src/main/java/com/jkuester/unlauncher/bindings/CustomizeHomeBindings.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.bindings
2 |
3 | import android.view.View
4 | import android.view.View.OnClickListener
5 | import android.widget.ImageView
6 | import androidx.activity.ComponentActivity
7 | import androidx.fragment.app.FragmentManager
8 | import androidx.navigation.Navigation
9 | import com.jkuester.unlauncher.adapter.CustomizeHomeAppsListAdapter
10 | import com.jkuester.unlauncher.datasource.DataRepository
11 | import com.jkuester.unlauncher.datasource.QuickButtonIcon
12 | import com.jkuester.unlauncher.datasource.getHomeApps
13 | import com.jkuester.unlauncher.datasource.getIconResourceId
14 | import com.jkuester.unlauncher.datastore.proto.QuickButtonPreferences
15 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApps
16 | import com.jkuester.unlauncher.dialog.QuickButtonIconDialog
17 | import com.sduduzog.slimlauncher.R
18 | import com.sduduzog.slimlauncher.databinding.CustomizeHomeBinding
19 |
20 | fun setupCustomizeQuickButtonsBackButton(activity: ComponentActivity) = { options: CustomizeHomeBinding ->
21 | options.headerBack.setOnClickListener { activity.onBackPressedDispatcher.onBackPressed() }
22 | }
23 |
24 | private fun setIconResource(iconView: ImageView) = { resourceId: Int ->
25 | iconView.setImageResource(resourceId)
26 | when (resourceId) {
27 | R.drawable.ic_empty -> iconView.setBackgroundResource(R.drawable.imageview_border)
28 | else -> iconView.setBackgroundResource(0)
29 | }
30 | }
31 |
32 | private fun updateQuickButtonIcons(binding: CustomizeHomeBinding): (QuickButtonPreferences) -> Unit = { prefs ->
33 | prefs.leftButton.iconId
34 | .let(::getIconResourceId)
35 | ?.let(setIconResource(binding.quickButtonLeft))
36 | prefs.centerButton.iconId
37 | .let(::getIconResourceId)
38 | ?.let(setIconResource(binding.quickButtonCenter))
39 | prefs.rightButton.iconId
40 | .let(::getIconResourceId)
41 | ?.let(setIconResource(binding.quickButtonRight))
42 | }
43 |
44 | private fun showQuickButtonIconDialog(icon: QuickButtonIcon, fragmentManager: FragmentManager) = OnClickListener {
45 | QuickButtonIconDialog(icon.prefId).showNow(fragmentManager, null)
46 | }
47 |
48 | fun setupQuickButtonIcons(prefsRepo: DataRepository, fragmentManager: FragmentManager) =
49 | { binding: CustomizeHomeBinding ->
50 | prefsRepo.observe(updateQuickButtonIcons(binding))
51 | binding.quickButtonLeft.setOnClickListener(showQuickButtonIconDialog(QuickButtonIcon.IC_CALL, fragmentManager))
52 | binding.quickButtonCenter.setOnClickListener(showQuickButtonIconDialog(QuickButtonIcon.IC_COG, fragmentManager))
53 | binding.quickButtonRight.setOnClickListener(
54 | showQuickButtonIconDialog(QuickButtonIcon.IC_PHOTO_CAMERA, fragmentManager)
55 | )
56 | }
57 |
58 | fun setupAddHomeAppButton(appsRepo: DataRepository): (CustomizeHomeBinding) -> Unit = { binding ->
59 | appsRepo.observe {
60 | if (getHomeApps(it).size > 5) {
61 | binding.addHomeApp.visibility = View.GONE
62 | } else {
63 | binding.addHomeApp.visibility = View.VISIBLE
64 | }
65 | }
66 |
67 | Navigation
68 | .createNavigateOnClickListener(R.id.customiseQuickButtonsFragment_to_customizeHomeAppsAddAppFragment)
69 | .also(binding.addHomeApp::setOnClickListener)
70 | }
71 |
72 | fun setupHomeAppsList(appsRepo: DataRepository, fragmentManager: FragmentManager) =
73 | { binding: CustomizeHomeBinding ->
74 | binding.customiseHomeAppsList.adapter = CustomizeHomeAppsListAdapter(appsRepo, fragmentManager)
75 | }
76 |
--------------------------------------------------------------------------------
/app/src/test/java/com/jkuester/unlauncher/bindings/CustomizeHomeAppsAddAppBindingsTest.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.bindings
2 |
3 | import android.view.View
4 | import android.view.View.OnClickListener
5 | import android.widget.ImageView
6 | import android.widget.TextView
7 | import androidx.activity.OnBackPressedDispatcher
8 | import androidx.constraintlayout.widget.ConstraintLayout
9 | import androidx.fragment.app.FragmentActivity
10 | import androidx.recyclerview.widget.RecyclerView
11 | import androidx.viewbinding.ViewBindings
12 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApps
13 | import com.jkuester.unlauncher.util.TestDataRepository
14 | import com.sduduzog.slimlauncher.R
15 | import com.sduduzog.slimlauncher.databinding.CustomizeHomeAppsAddAppBinding
16 | import io.mockk.every
17 | import io.mockk.impl.annotations.MockK
18 | import io.mockk.junit5.MockKExtension
19 | import io.mockk.justRun
20 | import io.mockk.mockk
21 | import io.mockk.mockkStatic
22 | import io.mockk.slot
23 | import io.mockk.verify
24 | import kotlin.reflect.KFunction
25 | import org.junit.jupiter.api.AfterEach
26 | import org.junit.jupiter.api.BeforeEach
27 | import org.junit.jupiter.api.Test
28 | import org.junit.jupiter.api.extension.ExtendWith
29 |
30 | @MockKExtension.CheckUnnecessaryStub
31 | @MockKExtension.ConfirmVerification
32 | @ExtendWith(MockKExtension::class)
33 | class CustomizeHomeAppsAddAppBindingsTest {
34 | @MockK
35 | lateinit var rootView: ConstraintLayout
36 | @MockK
37 | lateinit var headerBack: ImageView
38 | @MockK
39 | lateinit var headerTitle: TextView
40 | @MockK
41 | lateinit var addAppList: RecyclerView
42 |
43 | private lateinit var binding: CustomizeHomeAppsAddAppBinding
44 |
45 | @BeforeEach
46 | fun beforeEach() {
47 | val function: KFunction = ViewBindings::findChildViewById
48 | mockkStatic(function)
49 | every { ViewBindings.findChildViewById(any(), R.id.header_back) } returns headerBack
50 | every { ViewBindings.findChildViewById(any(), R.id.header_title) } returns headerTitle
51 | every { ViewBindings.findChildViewById(any(), R.id.add_app_list) } returns addAppList
52 |
53 | binding = CustomizeHomeAppsAddAppBinding.bind(rootView)
54 | }
55 |
56 | @AfterEach
57 | fun afterEach() {
58 | verify(exactly = 1) { ViewBindings.findChildViewById(rootView, R.id.header_back) }
59 | verify(exactly = 1) { ViewBindings.findChildViewById(rootView, R.id.header_title) }
60 | verify(exactly = 1) { ViewBindings.findChildViewById(rootView, R.id.add_app_list) }
61 | }
62 |
63 | @Test
64 | fun setupCustomizeAppDrawerBackButton() {
65 | val activity = mockk()
66 | val onBackPressedDispatcher = mockk()
67 | every { activity.onBackPressedDispatcher } returns onBackPressedDispatcher
68 | justRun { onBackPressedDispatcher.onBackPressed() }
69 | val clickListenerSlot = slot()
70 | justRun { headerBack.setOnClickListener(capture(clickListenerSlot)) }
71 |
72 | setupAddAppBackButton(activity)(binding)
73 |
74 | verify(exactly = 1) { headerBack.setOnClickListener(clickListenerSlot.captured) }
75 |
76 | clickListenerSlot.captured.onClick(headerBack)
77 |
78 | verify(exactly = 1) { activity.onBackPressedDispatcher }
79 | verify(exactly = 1) { onBackPressedDispatcher.onBackPressed() }
80 | }
81 |
82 | @Test
83 | fun setupAddAppsList() {
84 | justRun { addAppList.adapter = any() }
85 | val appsRepo = TestDataRepository(UnlauncherApps.getDefaultInstance())
86 |
87 | setupAddAppsList(appsRepo, mockk())(binding)
88 |
89 | verify(exactly = 1) { addAppList.adapter = any() }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/app/src/test/java/com/jkuester/unlauncher/fragment/CustomizeAppDrawerFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.fragment
2 |
3 | import android.content.res.Resources
4 | import android.view.LayoutInflater
5 | import androidx.activity.ComponentActivity
6 | import androidx.constraintlayout.widget.ConstraintLayout
7 | import com.jkuester.unlauncher.bindings.setupCustomizeAppDrawerBackButton
8 | import com.jkuester.unlauncher.bindings.setupSearchFieldOptionsButton
9 | import com.jkuester.unlauncher.bindings.setupShowHeadingSwitch
10 | import com.jkuester.unlauncher.bindings.setupVisibleAppsButton
11 | import com.jkuester.unlauncher.datasource.DataRepository
12 | import com.jkuester.unlauncher.datastore.proto.CorePreferences
13 | import com.sduduzog.slimlauncher.R
14 | import com.sduduzog.slimlauncher.databinding.CustomizeAppDrawerBinding
15 | import io.kotest.matchers.ints.exactly
16 | import io.kotest.matchers.shouldBe
17 | import io.mockk.every
18 | import io.mockk.impl.annotations.MockK
19 | import io.mockk.junit5.MockKExtension
20 | import io.mockk.justRun
21 | import io.mockk.mockk
22 | import io.mockk.mockkStatic
23 | import io.mockk.verify
24 | import org.junit.jupiter.api.BeforeEach
25 | import org.junit.jupiter.api.Test
26 | import org.junit.jupiter.api.extension.ExtendWith
27 |
28 | @MockKExtension.CheckUnnecessaryStub
29 | @MockKExtension.ConfirmVerification
30 | @ExtendWith(MockKExtension::class)
31 | class CustomizeAppDrawerFragmentTest {
32 | @MockK
33 | lateinit var mActivity: ComponentActivity
34 | @MockK
35 | lateinit var mResources: Resources
36 | @MockK
37 | lateinit var prefsRepo: DataRepository
38 | @MockK
39 | lateinit var view: ConstraintLayout
40 |
41 | private lateinit var fragment: CustomizeAppDrawerFragment
42 |
43 | @BeforeEach
44 | fun beforeEach() {
45 | fragment = CustomizeAppDrawerFragment()
46 | .apply {
47 | corePreferencesRepo = prefsRepo
48 | iActivity = mActivity
49 | iResources = mResources
50 | }
51 | }
52 |
53 | @Test
54 | fun onCreateView() {
55 | val inflater = mockk()
56 | every { inflater.inflate(any(), any(), any()) } returns view
57 |
58 | val result = fragment.onCreateView(inflater, null, null)
59 |
60 | result shouldBe view
61 | verify(exactly = 1) { inflater.inflate(R.layout.customize_app_drawer, null, false) }
62 | }
63 |
64 | @Test
65 | fun onViewCreated() {
66 | val options = mockk()
67 | mockkStatic(CustomizeAppDrawerBinding::bind)
68 | every { CustomizeAppDrawerBinding.bind(any()) } returns options
69 | val optionConsumer = mockk<(t: CustomizeAppDrawerBinding) -> Unit>()
70 | justRun { optionConsumer(any()) }
71 | mockkStatic(
72 | ::setupVisibleAppsButton,
73 | ::setupCustomizeAppDrawerBackButton,
74 | ::setupSearchFieldOptionsButton,
75 | ::setupShowHeadingSwitch,
76 | )
77 | every { setupVisibleAppsButton(any()) } returns mockk()
78 | every { setupCustomizeAppDrawerBackButton(any()) } returns optionConsumer
79 | every { setupSearchFieldOptionsButton(any(), any()) } returns optionConsumer
80 | every { setupShowHeadingSwitch(any()) } returns optionConsumer
81 |
82 | fragment.onViewCreated(view, null)
83 |
84 | verify(exactly = 1) { CustomizeAppDrawerBinding.bind(view) }
85 | verify(exactly = 1) { setupVisibleAppsButton(options) }
86 | verify(exactly = 1) { setupCustomizeAppDrawerBackButton(mActivity) }
87 | verify(exactly = 1) { setupSearchFieldOptionsButton(prefsRepo, mResources) }
88 | verify(exactly = 1) { setupShowHeadingSwitch(prefsRepo) }
89 | verify(exactly = 3) { optionConsumer(options) }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/app/src/main/java/com/jkuester/unlauncher/datasource/QuickButtonPreferencesMigrations.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.datasource
2 |
3 | import android.content.Context
4 | import androidx.datastore.core.DataMigration
5 | import androidx.datastore.migrations.SharedPreferencesMigration
6 | import androidx.datastore.migrations.SharedPreferencesView
7 | import com.jkuester.unlauncher.datasource.SharedPrefButton.CENTER
8 | import com.jkuester.unlauncher.datasource.SharedPrefButton.LEFT
9 | import com.jkuester.unlauncher.datasource.SharedPrefButton.RIGHT
10 | import com.jkuester.unlauncher.datastore.proto.QuickButtonPreferences
11 |
12 | private const val SHARED_PREF_GROUP_NAME = "settings"
13 | private enum class SharedPrefButton(val key: String) {
14 | LEFT("quick_button_left"),
15 | CENTER("quick_button_center"),
16 | RIGHT("quick_button_right")
17 | }
18 |
19 | private fun populateLeftButton(sharedPrefs: SharedPreferencesView) = fun(currentData: QuickButtonPreferences) = when {
20 | currentData.hasLeftButton() -> currentData
21 | else -> sharedPrefs
22 | .getInt(LEFT.key, QuickButtonIcon.IC_CALL.prefId)
23 | .let { setLeftIconId(it)(currentData) }
24 | }
25 | private fun populateCenterButton(sharedPrefs: SharedPreferencesView) = fun(currentData: QuickButtonPreferences) = when {
26 | currentData.hasCenterButton() -> currentData
27 | else -> sharedPrefs
28 | .getInt(CENTER.key, QuickButtonIcon.IC_COG.prefId)
29 | .let { setCenterIconId(it)(currentData) }
30 | }
31 | private fun populateRightButton(sharedPrefs: SharedPreferencesView) = fun(currentData: QuickButtonPreferences) = when {
32 | currentData.hasRightButton() -> currentData
33 | else -> sharedPrefs
34 | .getInt(RIGHT.key, QuickButtonIcon.IC_PHOTO_CAMERA.prefId)
35 | .let { setRightIconId(it)(currentData) }
36 | }
37 |
38 | fun sharedPrefsMigration(context: Context) = SharedPreferencesMigration(
39 | context,
40 | SHARED_PREF_GROUP_NAME,
41 | SharedPrefButton.entries
42 | .map { it.key }
43 | .toSet()
44 | ) { sharedPrefs: SharedPreferencesView, currentData: QuickButtonPreferences ->
45 | currentData
46 | .let(populateLeftButton(sharedPrefs))
47 | .let(populateCenterButton(sharedPrefs))
48 | .let(populateRightButton(sharedPrefs))
49 | }
50 |
51 | private fun getButtonPrefIds() = QuickButtonIcon.entries.map { it.prefId }
52 |
53 | private fun defaultLeftButton(currentData: QuickButtonPreferences) =
54 | if (getButtonPrefIds().contains(currentData.leftButton.iconId)) {
55 | currentData
56 | } else {
57 | setLeftIconId(QuickButtonIcon.IC_CALL.prefId)(currentData)
58 | }
59 | private fun defaultCenterButton(currentData: QuickButtonPreferences) =
60 | if (getButtonPrefIds().contains(currentData.centerButton.iconId)) {
61 | currentData
62 | } else {
63 | setCenterIconId(QuickButtonIcon.IC_COG.prefId)(currentData)
64 | }
65 | private fun defaultRightButton(currentData: QuickButtonPreferences) =
66 | if (getButtonPrefIds().contains(currentData.rightButton.iconId)) {
67 | currentData
68 | } else {
69 | setRightIconId(QuickButtonIcon.IC_PHOTO_CAMERA.prefId)(currentData)
70 | }
71 |
72 | object ToThreeQuickButtonsMigration : DataMigration {
73 | override suspend fun shouldMigrate(currentData: QuickButtonPreferences): Boolean = !getButtonPrefIds()
74 | .containsAll(listOf(
75 | currentData.leftButton.iconId,
76 | currentData.centerButton.iconId,
77 | currentData.rightButton.iconId
78 | ))
79 |
80 | override suspend fun migrate(currentData: QuickButtonPreferences): QuickButtonPreferences = currentData
81 | .let(::defaultLeftButton)
82 | .let(::defaultCenterButton)
83 | .let(::defaultRightButton)
84 |
85 | override suspend fun cleanUp() {}
86 | }
87 |
--------------------------------------------------------------------------------
/app/src/test/java/com/jkuester/unlauncher/fragment/CustomizeHomeFragmentTest.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.fragment
2 |
3 | import android.view.LayoutInflater
4 | import androidx.activity.ComponentActivity
5 | import androidx.constraintlayout.widget.ConstraintLayout
6 | import androidx.fragment.app.FragmentManager
7 | import com.jkuester.unlauncher.bindings.setupAddHomeAppButton
8 | import com.jkuester.unlauncher.bindings.setupCustomizeQuickButtonsBackButton
9 | import com.jkuester.unlauncher.bindings.setupHomeAppsList
10 | import com.jkuester.unlauncher.bindings.setupQuickButtonIcons
11 | import com.jkuester.unlauncher.datasource.DataRepository
12 | import com.jkuester.unlauncher.datastore.proto.QuickButtonPreferences
13 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApps
14 | import com.sduduzog.slimlauncher.R
15 | import com.sduduzog.slimlauncher.databinding.CustomizeHomeBinding
16 | import io.kotest.matchers.shouldBe
17 | import io.mockk.every
18 | import io.mockk.impl.annotations.MockK
19 | import io.mockk.junit5.MockKExtension
20 | import io.mockk.justRun
21 | import io.mockk.mockk
22 | import io.mockk.mockkStatic
23 | import io.mockk.verify
24 | import org.junit.jupiter.api.BeforeEach
25 | import org.junit.jupiter.api.Test
26 | import org.junit.jupiter.api.extension.ExtendWith
27 |
28 | @MockKExtension.CheckUnnecessaryStub
29 | @MockKExtension.ConfirmVerification
30 | @ExtendWith(MockKExtension::class)
31 | class CustomizeHomeFragmentTest {
32 | @MockK
33 | lateinit var mActivity: ComponentActivity
34 | @MockK
35 | lateinit var mFragmentManager: FragmentManager
36 | @MockK
37 | lateinit var prefsRepo: DataRepository
38 | @MockK
39 | lateinit var mAppsRepo: DataRepository
40 | @MockK
41 | lateinit var view: ConstraintLayout
42 |
43 | private lateinit var fragment: CustomizeHomeFragment
44 |
45 | @BeforeEach
46 | fun beforeEach() {
47 | fragment = CustomizeHomeFragment()
48 | .apply {
49 | quickButtonPreferencesRepo = prefsRepo
50 | iActivity = mActivity
51 | iFragmentManager = mFragmentManager
52 | appsRepo = mAppsRepo
53 | }
54 | }
55 |
56 | @Test
57 | fun onCreateView() {
58 | val inflater = mockk()
59 | every { inflater.inflate(any(), any(), any()) } returns view
60 |
61 | val result = fragment.onCreateView(inflater, null, null)
62 |
63 | result shouldBe view
64 | verify(exactly = 1) { inflater.inflate(R.layout.customize_home, null, false) }
65 | }
66 |
67 | @Test
68 | fun onViewCreated() {
69 | val options = mockk()
70 | mockkStatic(CustomizeHomeBinding::bind)
71 | every { CustomizeHomeBinding.bind(any()) } returns options
72 | val optionConsumer = mockk<(t: CustomizeHomeBinding) -> Unit>()
73 | justRun { optionConsumer(any()) }
74 | mockkStatic(
75 | ::setupCustomizeQuickButtonsBackButton,
76 | ::setupQuickButtonIcons,
77 | ::setupAddHomeAppButton,
78 | ::setupHomeAppsList
79 | )
80 | every { setupCustomizeQuickButtonsBackButton(any()) } returns optionConsumer
81 | every { setupQuickButtonIcons(any(), any()) } returns optionConsumer
82 | every { setupAddHomeAppButton(any()) } returns optionConsumer
83 | every { setupHomeAppsList(any(), any()) } returns optionConsumer
84 |
85 | fragment.onViewCreated(view, null)
86 |
87 | verify(exactly = 1) { CustomizeHomeBinding.bind(view) }
88 | verify(exactly = 1) { setupCustomizeQuickButtonsBackButton(mActivity) }
89 | verify(exactly = 1) { setupQuickButtonIcons(prefsRepo, mFragmentManager) }
90 | verify(exactly = 1) { setupAddHomeAppButton(mAppsRepo) }
91 | verify(exactly = 1) { setupHomeAppsList(mAppsRepo, mFragmentManager) }
92 | verify(exactly = 4) { optionConsumer(options) }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Developing Unlauncher
2 |
3 | ## Kotlin Linting/Formatting
4 |
5 | This project uses [ktlint](https://pinterest.github.io/ktlint/latest/) to format/lint the Kotlin code to ensure consistency of style across the codebase.
6 |
7 | Developers using Android Studio are encouraged to install the [Ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) for a tighter feedback loop and more automation.
8 |
9 | ## Adding a new configuration preference
10 |
11 | Currently user preferences in the Unlauncher code base are stored in one of three different ways:
12 |
13 | 1. In the [SharedPreferences](https://developer.android.com/training/data-storage/shared-preferences). This is an older style of storing basic preferences that was inherited from Slim Launcher.
14 | 1. In an [SQLite database](https://developer.android.com/training/data-storage/sqlite). This format is more appropriate for storing large amounts of data and was inherited from Slim Launcher as the method for storing data about the apps installed on the device.
15 | 1. In a [Proto DataStore](https://developer.android.com/topic/libraries/architecture/datastore#proto-datastore). This data format is suitable for both simple user preference values as well as storing larger data sets.
16 |
17 | The plan is to eventually migrate away from SharedPreferences and SQLite so that all the data is centralized in Proto DataStores. This will maximize consistency and simplicity in the codebase. All new data values should be added to Proto DataStores.
18 |
19 | ## Building a release
20 |
21 | Building an Unlauncher release is straightforward.
22 |
23 | 1. Push a tag to GitHub (e.g. `1.2.1`) from the latest commit on the `master` branch
24 | 1. Make sure that the `versionName` in the [build.gradle.kts](./app/build.gradle.kts) matches the tag that you are pushing
25 | 1. Add release notes to the draft Release on GitHub that was created by the CI and publish the release
26 | 1. Prepare for the next release by incrementing the `versionCode` and `versionName` in the [build.gradle.kts](./app/build.gradle.kts) file
27 | 1. Monitor the [F-Droid build status](#checking-f-droid-build-status) to make sure the tag is successfully published (can take several days depending on the build queue)
28 |
29 | ## Building a beta release
30 |
31 | > A beta release should be published ahead of an official release any time there are major changes to the app or the build process.
32 |
33 | Unfortunately, F-Droid does not have an automatic process for triggering "beta" releases like it does for normal automatic updates [yet](https://gitlab.com/fdroid/fdroidserver/-/issues/161).
34 | However, an F-Droid user will only be prompted to upgrade an app (or have the app be auto-upgraded) if their locally installed app has a version/code that is less than the `CurrenVersion`/`CurrentVersionCode` defined in the [fdroiddata yml config file](https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.jkuester.unlauncher.yml).
35 |
36 | So, creating a beta release for Unlauncher requires the following steps:
37 |
38 | 1. Push a beta tag to GitHub (e.g. `2.0.0-beta.1`)
39 | 1. Make sure that the `versionName` in the [build.gradle.kts](./app/build.gradle.kts) matches the tag that you are pushing
40 | 1. Add release notes to the draft Release on GitHub that was created by the CI and publish it as a pre-release
41 | 1. Raise a MR to [fdroid/fdroiddata](https://gitlab.com/fdroid/fdroiddata) to add a new `Builds` entry for the beta release
42 | 1. _Do not_ update the configured `CurrenVersion`/`CurrentVersionCode` since that will trigger a normal release
43 | 1. Prepare for the next release by incrementing the `versionCode` and `versionName` in the [build.gradle.kts](./app/build.gradle.kts) file
44 |
45 | ## Checking F-Droid build status
46 |
47 | The status for the latest Unlauncher F-Droid build can be found [here](https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.jkuester.unlauncher.yml).
48 |
49 | A list of all recent F-Droid builds can be found [here](https://f-droid.org/wiki/index.php?title=Special:RecentChanges&days=30&from=&hidebots=0&hideanons=1&hideliu=1&limit=500).
50 |
--------------------------------------------------------------------------------
/app/src/test/java/com/jkuester/unlauncher/bindings/CustomizeAppDrawerVisibleAppsBindingsTest.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.bindings
2 |
3 | import android.view.View
4 | import android.view.View.OnClickListener
5 | import android.widget.ImageView
6 | import android.widget.TextView
7 | import androidx.activity.OnBackPressedDispatcher
8 | import androidx.constraintlayout.widget.ConstraintLayout
9 | import androidx.fragment.app.FragmentActivity
10 | import androidx.recyclerview.widget.RecyclerView
11 | import androidx.viewbinding.ViewBindings
12 | import com.jkuester.unlauncher.adapter.CustomizeAppDrawerVisibleAppsAdapter
13 | import com.jkuester.unlauncher.datasource.DataRepository
14 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApps
15 | import com.sduduzog.slimlauncher.R
16 | import com.sduduzog.slimlauncher.databinding.CustomizeAppDrawerVisibleAppsBinding
17 | import io.mockk.every
18 | import io.mockk.impl.annotations.MockK
19 | import io.mockk.junit5.MockKExtension
20 | import io.mockk.justRun
21 | import io.mockk.mockk
22 | import io.mockk.mockkStatic
23 | import io.mockk.slot
24 | import io.mockk.verify
25 | import kotlin.reflect.KFunction
26 | import org.junit.jupiter.api.AfterEach
27 | import org.junit.jupiter.api.BeforeEach
28 | import org.junit.jupiter.api.Test
29 | import org.junit.jupiter.api.extension.ExtendWith
30 |
31 | @MockKExtension.CheckUnnecessaryStub
32 | @MockKExtension.ConfirmVerification
33 | @ExtendWith(MockKExtension::class)
34 | class CustomizeAppDrawerVisibleAppsBindingsTest {
35 | @MockK
36 | lateinit var rootView: ConstraintLayout
37 | @MockK
38 | lateinit var headerBack: ImageView
39 | @MockK
40 | lateinit var headerTitle: TextView
41 | @MockK
42 | lateinit var appsList: RecyclerView
43 |
44 | private lateinit var optionsBinding: CustomizeAppDrawerVisibleAppsBinding
45 |
46 | @BeforeEach
47 | fun beforeEach() {
48 | val function: KFunction = ViewBindings::findChildViewById
49 | mockkStatic(function)
50 | every { ViewBindings.findChildViewById(any(), R.id.header_back) } returns headerBack
51 | every { ViewBindings.findChildViewById(any(), R.id.header_title) } returns headerTitle
52 | every { ViewBindings.findChildViewById(any(), R.id.customize_app_drawer_visible_apps_list) } returns
53 | appsList
54 |
55 | optionsBinding = CustomizeAppDrawerVisibleAppsBinding.bind(rootView)
56 | }
57 |
58 | @AfterEach
59 | fun afterEach() {
60 | verify(exactly = 1) { ViewBindings.findChildViewById(rootView, R.id.header_back) }
61 | verify(exactly = 1) { ViewBindings.findChildViewById(rootView, R.id.header_title) }
62 | verify(exactly = 1) {
63 | ViewBindings.findChildViewById(rootView, R.id.customize_app_drawer_visible_apps_list)
64 | }
65 | }
66 |
67 | @Test
68 | fun setupVisibleAppsBackButton() {
69 | val activity = mockk()
70 | val onBackPressedDispatcher = mockk()
71 | every { activity.onBackPressedDispatcher } returns onBackPressedDispatcher
72 | justRun { onBackPressedDispatcher.onBackPressed() }
73 | val clickListenerSlot = slot()
74 | justRun { headerBack.setOnClickListener(capture(clickListenerSlot)) }
75 |
76 | setupVisibleAppsBackButton(activity)(optionsBinding)
77 |
78 | verify(exactly = 1) { headerBack.setOnClickListener(clickListenerSlot.captured) }
79 |
80 | clickListenerSlot.captured.onClick(headerBack)
81 |
82 | verify(exactly = 1) { activity.onBackPressedDispatcher }
83 | verify(exactly = 1) { onBackPressedDispatcher.onBackPressed() }
84 | }
85 |
86 | @Test
87 | fun setupVisibleAppsList() {
88 | val appsRepo = mockk>()
89 | every { appsRepo.get() } returns mockk()
90 | justRun { appsList.adapter = any() }
91 |
92 | setupVisibleAppsList(appsRepo)(optionsBinding)
93 |
94 | verify(exactly = 1) { appsRepo.get() }
95 | verify(exactly = 1) { appsList.adapter = any() }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/app/src/main/res/values-ru/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | По левому краю
5 | По центру
6 | По правому краю
7 |
8 |
9 | Показать
10 | Скрыть
11 |
12 |
13 | Сверху
14 | Снизу
15 |
16 |
17 | Системная
18 | Полночь
19 | Юпитер
20 | Бирюзовая
21 | Леденец
22 | Пастель
23 | Полдень
24 | Влад
25 | Волнующая
26 |
27 |
28 | по умолчанию
29 | 24-часовой
30 | 12-часовой
31 |
32 |
33 | Поиск приложений
34 | Информация о приложение
35 | Выбрать выравнивание
36 | Установить расположение строки поиска
37 | Выбрать тему
38 | Выбрать формат времени
39 | Добавить
40 | Требуется чтобы приложение использовалось по умолчанию
41 | Использовать фон темы как обои
42 | Показывать клавиатуру
43 | Показывать клавиатуру, когда открыт выдвигающийся список
44 | Расположение
45 | Настройки строки поиска
46 | Строка поиска скрыта
47 | Расположение: %s, клавиатура %s
48 | Выполнять группировку
49 | Разделять приложения в группы по алфавиту
50 | Показывать строку поиска
51 | Видимые приложения
52 | скрыта
53 | Скрыть приложение
54 | Настройки
55 | Удалить
56 | Переименовать
57 | Открыть приложение
58 | Изменить тему
59 | Настроить выравнивание
60 | Изменить формат времени
61 | Настроить приложения
62 | Настроить выдвигающийся список
63 | Настройки устройства
64 | Скрыть/показать строку состояния
65 | Настроить кнопки быстрого запуска
66 | Переименовать приложение
67 | отображается
68 | Удалить приложение
69 |
--------------------------------------------------------------------------------
/app/src/test/java/com/jkuester/unlauncher/adapter/CustomizeHomeAppsAddAppAdapterTest.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher.adapter
2 |
3 | import android.content.Context
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.View.OnClickListener
7 | import android.view.ViewGroup
8 | import android.widget.TextView
9 | import androidx.activity.ComponentActivity
10 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApp
11 | import com.jkuester.unlauncher.datastore.proto.UnlauncherApps
12 | import com.jkuester.unlauncher.util.TestDataRepository
13 | import com.sduduzog.slimlauncher.R
14 | import io.kotest.matchers.shouldBe
15 | import io.mockk.every
16 | import io.mockk.impl.annotations.MockK
17 | import io.mockk.junit5.MockKExtension
18 | import io.mockk.justRun
19 | import io.mockk.mockk
20 | import io.mockk.mockkStatic
21 | import io.mockk.slot
22 | import io.mockk.verify
23 | import org.junit.jupiter.api.BeforeEach
24 | import org.junit.jupiter.api.Test
25 | import org.junit.jupiter.api.extension.ExtendWith
26 |
27 | private val app0 = UnlauncherApp
28 | .newBuilder()
29 | .setDisplayName("App A")
30 | .setPackageName("a")
31 | .setHomeAppIndex(0)
32 | .build()
33 | private val app1 = UnlauncherApp
34 | .newBuilder()
35 | .setDisplayName("App B")
36 | .setPackageName("b")
37 | .build()
38 | private val app2 = UnlauncherApp
39 | .newBuilder()
40 | .setDisplayName("App C")
41 | .setPackageName("c")
42 | .build()
43 |
44 | @MockKExtension.CheckUnnecessaryStub
45 | @MockKExtension.ConfirmVerification
46 | @ExtendWith(MockKExtension::class)
47 | class CustomizeHomeAppsAddAppAdapterTest {
48 | @MockK
49 | lateinit var mActivity: ComponentActivity
50 |
51 | private val mAppsRepo = TestDataRepository(UnlauncherApps.getDefaultInstance())
52 | private lateinit var adapter: CustomizeHomeAppsAddAppAdapter
53 |
54 | @BeforeEach
55 | fun beforeEach() {
56 | mAppsRepo.updateAsync { it.toBuilder().addAllApps(listOf(app0, app1, app2)).build() }
57 | adapter = CustomizeHomeAppsAddAppAdapter(mAppsRepo, mActivity)
58 | }
59 |
60 | @Test
61 | fun getItemCount() {
62 | val count = adapter.itemCount
63 | count shouldBe 2
64 | }
65 |
66 | @Test
67 | fun onBindViewHolder() {
68 | val viewHolder = mockk()
69 | val appName = mockk()
70 | every { viewHolder.appName } returns appName
71 | justRun { appName.text = any() }
72 | val onClickedSlot = slot()
73 | justRun { appName.setOnClickListener(capture(onClickedSlot)) }
74 | justRun { mActivity.onBackPressedDispatcher.onBackPressed() }
75 |
76 | adapter.onBindViewHolder(viewHolder, 0)
77 |
78 | verify(exactly = 2) { viewHolder.appName }
79 | verify(exactly = 1) { appName.text = app1.displayName }
80 | verify(exactly = 1) { appName.setOnClickListener(onClickedSlot.captured) }
81 |
82 | onClickedSlot.captured.onClick(appName)
83 |
84 | verify(exactly = 1) { mActivity.onBackPressedDispatcher.onBackPressed() }
85 | mAppsRepo.get().appsList[1].homeAppIndex shouldBe 1
86 | }
87 |
88 | @Test
89 | fun onCreateViewHolder() {
90 | val parent = mockk()
91 | val context = mockk()
92 | every { parent.context } returns context
93 | mockkStatic(LayoutInflater::from)
94 | val layoutInflater = mockk()
95 | every { LayoutInflater.from(any()) } returns layoutInflater
96 | val view = mockk()
97 | every { layoutInflater.inflate(any(), parent, false) } returns view
98 | val textView = mockk()
99 | every { view.findViewById(any()) } returns textView
100 |
101 | val viewHolder = adapter.onCreateViewHolder(parent, 0)
102 |
103 | viewHolder.appName shouldBe textView
104 | verify(exactly = 1) { parent.context }
105 | verify(exactly = 1) { LayoutInflater.from(context) }
106 | verify(exactly = 1) { layoutInflater.inflate(R.layout.app_list_item, parent, false) }
107 | verify(exactly = 1) { view.findViewById(R.id.app_list_item_name) }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/app/src/main/java/com/jkuester/unlauncher/ThemeManager.kt:
--------------------------------------------------------------------------------
1 | package com.jkuester.unlauncher
2 |
3 | import android.app.Activity
4 | import android.app.WallpaperManager
5 | import android.content.res.Configuration
6 | import android.content.res.Resources
7 | import android.graphics.Canvas
8 | import android.os.Build
9 | import android.util.DisplayMetrics
10 | import androidx.appcompat.app.AppCompatActivity
11 | import androidx.core.graphics.createBitmap
12 | import androidx.datastore.core.DataStore
13 | import com.jkuester.unlauncher.datasource.DataRepository
14 | import com.jkuester.unlauncher.datasource.getThemeStyleResource
15 | import com.jkuester.unlauncher.datastore.proto.CorePreferences
16 | import com.jkuester.unlauncher.datastore.proto.Theme
17 | import com.sduduzog.slimlauncher.utils.isDefaultLauncher
18 | import kotlinx.coroutines.flow.first
19 |
20 | private fun getScreenResolution(activity: Activity) = if (androidSdkAtLeast(Build.VERSION_CODES.R)) {
21 | val bounds = activity.windowManager.currentWindowMetrics.bounds
22 | Pair(bounds.width(), bounds.height())
23 | } else {
24 | val metrics = DisplayMetrics()
25 | .also(activity.windowManager.defaultDisplay::getMetrics)
26 | Pair(metrics.widthPixels, metrics.heightPixels)
27 | }
28 |
29 | private fun createColoredWallpaperBitmap(color: Int, width: Int, height: Int) = createBitmap(width, height)
30 | .also { Canvas(it).drawColor(color) }
31 |
32 | private fun setWallpaperBackgroundColor(activity: Activity) = { color: Int ->
33 | WallpaperManager
34 | .getInstance(activity)
35 | .run {
36 | val screenRes = getScreenResolution(activity)
37 | val width = desiredMinimumWidth.takeIf { it > 0 } ?: screenRes.first
38 | val height = desiredMinimumHeight.takeIf { it > 0 } ?: screenRes.second
39 | val wallpaperBitmap = createColoredWallpaperBitmap(color, width, height)
40 | setBitmap(wallpaperBitmap)
41 | }
42 | }
43 |
44 | private fun getThemeBackgroundColor(theme: Resources.Theme, themeRes: Int): Int {
45 | val typedArray = theme.obtainStyledAttributes(themeRes, intArrayOf(android.R.attr.colorBackground))
46 | return try {
47 | typedArray.getColor(0, Int.MIN_VALUE)
48 | } finally {
49 | typedArray.recycle()
50 | }
51 | }
52 |
53 | private suspend fun setWallpaper(
54 | activity: AppCompatActivity,
55 | corePrefsStore: DataStore,
56 | theme: Resources.Theme,
57 | resId: Int
58 | ) {
59 | val corePrefs = corePrefsStore.data.first()
60 | if (corePrefs.keepDeviceWallpaper || !isDefaultLauncher(activity)) {
61 | return
62 | }
63 |
64 | getThemeBackgroundColor(theme, resId)
65 | .takeUnless { it == Int.MIN_VALUE }
66 | ?.let(setWallpaperBackgroundColor(activity))
67 | }
68 |
69 | private fun isDarkTheme(configuration: Configuration): Boolean =
70 | configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
71 |
72 | class ThemeManager(private val activity: AppCompatActivity) {
73 | private var darkModeStatus: Boolean? = null
74 | private lateinit var currentTheme: Theme
75 |
76 | suspend fun setDeviceWallpaper(
77 | corePrefsStore: DataStore,
78 | theme: Resources.Theme?,
79 | resId: Int,
80 | first: Boolean
81 | ) {
82 | // first is true when starting the app (theme has not actually changed)
83 | if (theme == null || (first && !this.darkModeChanged())) {
84 | return
85 | }
86 | setWallpaper(activity, corePrefsStore, theme, resId)
87 | }
88 |
89 | fun listenForThemeChanges(corePrefRepo: DataRepository, initialTheme: Theme) {
90 | currentTheme = initialTheme
91 | corePrefRepo.observe {
92 | if (it.theme == currentTheme) {
93 | return@observe
94 | }
95 |
96 | currentTheme = it.theme
97 | activity.setTheme(getThemeStyleResource(currentTheme))
98 | activity.recreate()
99 | }
100 | }
101 |
102 | private fun darkModeChanged(): Boolean {
103 | val originalStatus = darkModeStatus
104 | darkModeStatus = isDarkTheme(activity.resources.configuration)
105 | return originalStatus != null && originalStatus != darkModeStatus
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/app/src/main/res/values-zh-rCN/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 左
7 | 中
8 | 右
9 |
10 |
11 | 无时钟
12 | 数字时钟
13 | 指针时钟12
14 | 二进制时钟
15 | 指针时钟0
16 | 指针时钟1
17 | 指针时钟2
18 | 指针时钟3
19 | 指针时钟4
20 | 指针时钟6
21 | 指针时钟60
22 |
23 |
24 | 显示
25 | 隐藏
26 |
27 |
28 | 顶部
29 | 底部
30 |
31 |
32 | 系统默认
33 | 午夜
34 | 木星
35 | 水鸭色
36 | 糖果色
37 | 粉彩色
38 | 正午
39 | 弗拉德
40 | 格罗维
41 |
42 |
43 | 跟随系统设置
44 | 24 小时
45 | 12 小时
46 |
47 |
48 | 搜索应用
49 | 应用信息
50 | 选择对齐方向
51 | 选择时钟类型
52 | 设置搜索栏位置
53 | 选择主题
54 | 选择时间格式
55 | 返回
56 | 添加
57 | 需设为默认启动器
58 | 将主题背景设为壁纸
59 | 打开键盘
60 | 显示应用抽屉时展示键盘
61 | 搜索所有应用
62 | 搜索时也能找到隐藏的应用
63 | 位置
64 | 搜索栏选项
65 | 隐藏搜索栏
66 | 位于 %s,键盘为 %s
67 | 显示分组
68 | 按应用英文名首字母将应用分组
69 | 显示搜索栏
70 | 可见的应用
71 | 隐藏的
72 | 隐藏应用
73 | EEE, MMM dd
74 | 选项
75 | 删除
76 | 重命名
77 | 打开应用
78 | 更改主题
79 | 选择对齐方向
80 | 选择时钟类型
81 | 选择时间格式
82 | 自定义应用
83 | 自定义抽屉
84 | 设备设置
85 | 切换状态栏状态
86 | 自定义快捷按钮
87 | 重命名应用
88 | 显示
89 | 卸载
90 |
91 |
--------------------------------------------------------------------------------