From 69e961727288a726f6fa797503a8dc20c5cdff25 Mon Sep 17 00:00:00 2001 From: Colin Lee Date: Mon, 28 Jan 2019 10:46:39 -0600 Subject: [PATCH] Fixes #127: Add architecture classes --- app/build.gradle | 11 ++ .../org/mozilla/fenix/home/HomeFragment.kt | 17 +- .../fenix/home/sessions/SessionsComponent.kt | 40 +++++ .../fenix/home/sessions/SessionsLayouts.kt | 25 +++ .../fenix/home/sessions/SessionsUIView.kt | 39 +++++ .../org/mozilla/fenix/mvi/ActionBusFactory.kt | 157 ++++++++++++++++++ .../java/org/mozilla/fenix/mvi/Concepts.kt | 50 ++++++ .../java/org/mozilla/fenix/mvi/UIComponent.kt | 32 ++++ .../main/java/org/mozilla/fenix/mvi/UIView.kt | 43 +++++ .../main/res/layout/component_sessions.xml | 7 + app/src/main/res/layout/fragment_home.xml | 10 -- buildSrc/src/main/java/Dependencies.kt | 11 ++ 12 files changed, 420 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/home/sessions/SessionsComponent.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/sessions/SessionsLayouts.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/sessions/SessionsUIView.kt create mode 100644 app/src/main/java/org/mozilla/fenix/mvi/ActionBusFactory.kt create mode 100644 app/src/main/java/org/mozilla/fenix/mvi/Concepts.kt create mode 100644 app/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt create mode 100644 app/src/main/java/org/mozilla/fenix/mvi/UIView.kt create mode 100644 app/src/main/res/layout/component_sessions.xml diff --git a/app/build.gradle b/app/build.gradle index 75864b495..4db6d45f4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -65,12 +65,23 @@ android.applicationVariants.all { variant -> println("Version code: " + variant.mergedFlavor.versionCode) } +androidExtensions { + experimental = true +} + dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation Deps.kotlin_stdlib implementation Deps.androidx_appcompat implementation Deps.androidx_constraintlayout + implementation Deps.rxAndroid + implementation Deps.rxKotlin + implementation Deps.anko_commons + implementation Deps.anko_sdk + implementation Deps.anko_appcompat + implementation Deps.anko_constraintlayout + implementation Deps.mozilla_concept_engine implementation Deps.mozilla_concept_storage implementation Deps.mozilla_concept_toolbar diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index f57155962..f09e4c58f 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -9,15 +9,15 @@ import android.transition.TransitionInflater import android.view.LayoutInflater import android.view.View import android.view.ViewGroup - import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.navigation.Navigation import androidx.navigation.fragment.FragmentNavigator -import androidx.recyclerview.widget.LinearLayoutManager import kotlinx.android.synthetic.main.fragment_home.* import org.mozilla.fenix.R -import org.mozilla.fenix.home.sessions.SessionsAdapter +import org.mozilla.fenix.home.sessions.SessionsComponent +import org.mozilla.fenix.home.sessions.layoutComponents +import org.mozilla.fenix.mvi.ActionBusFactory class HomeFragment : Fragment() { override fun onCreateView( @@ -27,8 +27,6 @@ class HomeFragment : Fragment() { return inflater.inflate(R.layout.fragment_home, container, false) } - private lateinit var sessionsAdapter: SessionsAdapter - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -36,8 +34,6 @@ class HomeFragment : Fragment() { menuButton.visibility = View.GONE privateBrowsingButton.visibility = View.GONE - sessionsAdapter = SessionsAdapter() - toolbar_wrapper.clipToOutline = false toolbar.setOnClickListener { it -> val extras = FragmentNavigator.Extras.Builder().addSharedElement( @@ -46,11 +42,8 @@ class HomeFragment : Fragment() { Navigation.findNavController(it).navigate(R.id.action_homeFragment_to_searchFragment, null, null, extras) } - session_list.apply { - adapter = sessionsAdapter - layoutManager = LinearLayoutManager(requireContext()) - setHasFixedSize(true) - } + SessionsComponent(homeLayout, ActionBusFactory.get(this)).setup() + layoutComponents(homeLayout) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsComponent.kt b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsComponent.kt new file mode 100644 index 000000000..b0e8e5b62 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsComponent.kt @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessions + +import android.annotation.SuppressLint +import android.view.ViewGroup +import mozilla.components.browser.session.Session +import org.mozilla.fenix.mvi.* + +class SessionsComponent(private val container: ViewGroup, override val bus: ActionBusFactory) : + UIComponent(bus) { + + override var initialState: SessionsState = SessionsState(emptyList()) + + override val reducer : (SessionsState, SessionsChange) -> SessionsState = { state, change -> + when (change) { + is SessionsChange.SessionsChanged -> state // copy state with changes here + } + } + + override fun initView() = SessionsUIView(container, bus) + + @SuppressLint("CheckResult") + fun setup(): SessionsComponent { + render(reducer) + return this + } +} + +data class SessionsState(val sessions: List) : ViewState + +sealed class SessionsAction : Action { + object Select : SessionsAction() +} + +sealed class SessionsChange : Change { + object SessionsChanged : SessionsChange() +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsLayouts.kt b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsLayouts.kt new file mode 100644 index 000000000..b0b3cd167 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsLayouts.kt @@ -0,0 +1,25 @@ +package org.mozilla.fenix.home.sessions + +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID +import kotlinx.android.synthetic.main.component_sessions.* +import kotlinx.android.synthetic.main.fragment_home.* +import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.BOTTOM +import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.TOP +import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.START +import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.END +import org.jetbrains.anko.constraint.layout.applyConstraintSet +import org.mozilla.fenix.home.HomeFragment + +fun HomeFragment.layoutComponents(layout: ConstraintLayout) { + layout.applyConstraintSet { + session_list { + connect( + BOTTOM to BOTTOM of PARENT_ID, + START to START of PARENT_ID, + END to END of PARENT_ID, + TOP to BOTTOM of toolbar_wrapper + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsUIView.kt b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsUIView.kt new file mode 100644 index 000000000..70a748c50 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessions/SessionsUIView.kt @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.home.sessions + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.functions.Consumer +import kotlinx.android.synthetic.main.component_sessions.* +import kotlinx.android.synthetic.main.fragment_home.* +import org.mozilla.fenix.R +import org.mozilla.fenix.mvi.ActionBusFactory +import org.mozilla.fenix.mvi.UIView + +class SessionsUIView(container: ViewGroup, bus: ActionBusFactory) : UIView(container, bus) { + + val view: ConstraintLayout = LayoutInflater.from(container.context) + .inflate(R.layout.component_sessions, container, true) as ConstraintLayout + + private var sessionAdapter = SessionsAdapter() + + init { + session_list.apply { + layoutManager = LinearLayoutManager(view.context) + adapter = sessionAdapter + setHasFixedSize(true) + } + } + + override fun updateView() = Consumer { + + } + +} diff --git a/app/src/main/java/org/mozilla/fenix/mvi/ActionBusFactory.kt b/app/src/main/java/org/mozilla/fenix/mvi/ActionBusFactory.kt new file mode 100644 index 000000000..e4a2ab39f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/mvi/ActionBusFactory.kt @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * Copyright (C) 2018 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Created by Juliano Moraes, Rohan Dhruva, Emmanuel Boudrant. + */ +package org.mozilla.fenix.mvi + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.OnLifecycleEvent +import io.reactivex.Observable +import io.reactivex.rxkotlin.merge +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject + +/** + * It implements a Factory pattern generating Rx Subjects based on Event Types. + * It maintain a map of Rx Subjects, one per type per instance of ActionBusFactory. + * + * @param owner is a LifecycleOwner used to auto dispose based on destroy observable + */ +class ActionBusFactory private constructor(val owner: LifecycleOwner) { + + companion object { + + private val buses = mutableMapOf() + + /** + * Return the [ActionBusFactory] associated to the [LifecycleOwner]. It there is no bus it will create one. + * If the [LifecycleOwner] used is a fragment it use [Fragment#getViewLifecycleOwner()] + */ + @JvmStatic + fun get(lifecycleOwner: LifecycleOwner): ActionBusFactory { + return with(lifecycleOwner) { + var bus = buses[lifecycleOwner] + if (bus == null) { + bus = ActionBusFactory(lifecycleOwner) + buses[lifecycleOwner] = bus + // LifecycleOwner + lifecycleOwner.lifecycle.addObserver(bus.observer) + } + bus + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + val map = HashMap, Subject<*>>() + + internal val observer = object : LifecycleObserver { + + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun onDestroy() { + map.forEach { entry -> entry.value.onComplete() } + buses.remove(owner) + } + } + + private fun create(clazz: Class): Subject { + val subject = PublishSubject.create().toSerialized() + map[clazz] = subject + return subject + } + + /** + * emit will create (if needed) or use the existing Rx Subject to send events. + * + * @param clazz is the Event Class + * @param event is the instance of the Event to be sent + */ + fun emit(clazz: Class, event: T) { + val subject = if (map[clazz] != null) map[clazz] else create(clazz) + (subject as Subject).onNext(event) + } + + /**x + * getSafeManagedObservable returns an Rx Observable which is + * *Safe* against reentrant events as it is serialized and + * *Managed* since it disposes itself based on the lifecycle + * + * @param clazz is the class of the event type used by this observable + */ + fun getSafeManagedObservable(clazz: Class): Observable { + return if (map[clazz] != null) map[clazz] as Observable else create(clazz) + } + + fun logMergedObservables() { + // TODO make this observe new items in the map and combine them + map.values.merge().compose(logState()).subscribe() + } + + /** + * getDestroyObservable observes to Lifecycle owner and fires when + * lifecycle.currentState == Lifecycle.State.DESTROYED + */ + fun getDestroyObservable(): Observable { + return owner.createDestroyObservable() + } +} + +/** + * Extension on [LifecycleOwner] used to emit an event. + */ +inline fun LifecycleOwner.emit(event: T) = + kotlin.with(ActionBusFactory.get(this)) { + getSafeManagedObservable(T::class.java) + emit(T::class.java, event) + } + +/** + * Extension on [LifecycleOwner] used used to get the state observable. + */ +inline fun LifecycleOwner.getSafeManagedObservable(): Observable = + ActionBusFactory.get(this).getSafeManagedObservable(T::class.java) + +/** + * This method returns a destroy observable that can be passed to [org.mozilla.fenix.mvi.UIView]s as needed. + * This is deliberately scoped to the attached [LifecycleOwner]'s [Lifecycle.Event.ON_DESTROY] + * because a viewholder can be reused across adapter destroys. + */ +inline fun LifecycleOwner?.createDestroyObservable(): Observable { + return Observable.create { emitter -> + if (this == null || this.lifecycle.currentState == Lifecycle.State.DESTROYED) { + emitter.onNext(kotlin.Unit) + emitter.onComplete() + return@create + } + this.lifecycle.addObserver(object : LifecycleObserver { + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun emitDestroy() { + if (emitter.isDisposed) { + emitter.onNext(kotlin.Unit) + emitter.onComplete() + } + } + }) + } +} + diff --git a/app/src/main/java/org/mozilla/fenix/mvi/Concepts.kt b/app/src/main/java/org/mozilla/fenix/mvi/Concepts.kt new file mode 100644 index 000000000..b6fc050db --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/mvi/Concepts.kt @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.mvi + +import io.reactivex.ObservableTransformer +import io.reactivex.subjects.Subject +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.fenix.BuildConfig + +/** + * An action is a command or intent the user performed + */ +interface Action + +/** + * A Change is a change to the view coming from the model + * (Extending action so we can reuse the ActionBusFactory) + */ +interface Change : Action + +/** + * A ViewState is a model reflecting the current state of the view + */ +interface ViewState + +/** + * A Reducer applies changes to the ViewState + */ +typealias Reducer = (S, C) -> S + +/** + * Simple logger for tracking ViewState changes + */ +fun logState(): ObservableTransformer = ObservableTransformer { observable -> + observable.doOnNext { + if (BuildConfig.DEBUG) Logger("State").debug(it.toString()) + } +} + +/** + * For capturing state to a Subject for testing + */ +fun captureState(subject: Subject): + ObservableTransformer = ObservableTransformer { observable -> + observable.doOnNext { + subject.onNext(it) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt b/app/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt new file mode 100644 index 000000000..0de58ff6b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.mvi + +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers + +abstract class UIComponent(open val bus: ActionBusFactory) { + abstract var initialState: S + abstract val reducer: Reducer + val uiView: UIView by lazy { initView() } + + abstract fun initView(): UIView + open fun getContainerId() = uiView.containerId + inline fun getUserInteractionEvents(): Observable = bus.getSafeManagedObservable(A::class.java) + inline fun getModelChangeEvents(): Observable = bus.getSafeManagedObservable(C::class.java) + + /** + * Render the ViewState to the View through the Reducer + */ + inline fun render(noinline reducer: Reducer): Disposable = + bus.getSafeManagedObservable(C::class.java) + .scan(initialState, reducer) + .distinctUntilChanged() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(uiView.updateView()) +} diff --git a/app/src/main/java/org/mozilla/fenix/mvi/UIView.kt b/app/src/main/java/org/mozilla/fenix/mvi/UIView.kt new file mode 100644 index 000000000..b0c8cca2d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/mvi/UIView.kt @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.mvi + +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IdRes +import io.reactivex.functions.Consumer +import kotlinx.android.extensions.LayoutContainer + +abstract class UIView( + private val container: ViewGroup, val bus: ActionBusFactory +) : LayoutContainer { + /** + * Get the XML id for the UIView + */ + @get:IdRes + val containerId: Int + get() = container.id + + /** + * Provides container to empower Kotlin Android Extensions + */ + override val containerView: View? + get() = container + + /** + * Show the UIView + */ + open fun show() { container.visibility = View.VISIBLE } + + /** + * Hide the UIView + */ + open fun hide() { container.visibility = View.GONE } + + /** + * Update the view from the ViewState + */ + abstract fun updateView(): Consumer +} diff --git a/app/src/main/res/layout/component_sessions.xml b/app/src/main/res/layout/component_sessions.xml new file mode 100644 index 000000000..208178124 --- /dev/null +++ b/app/src/main/res/layout/component_sessions.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 4e296238f..086171143 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -71,14 +71,4 @@ android:transitionName="firstTransitionName" /> - - \ No newline at end of file diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 73fc33eae..a3d1e9bc6 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -6,6 +6,9 @@ private object Versions { const val kotlin = "1.3.11" const val android_gradle_plugin = "3.2.1" const val geckoNightly = "66.0.20190128092811" + const val rxAndroid = "2.1.0" + const val rxKotlin = "2.3.0" + const val anko = "0.10.8" const val androidx_appcompat = "1.0.2" const val androidx_constraint_layout = "2.0.0-alpha3" @@ -26,6 +29,14 @@ object Deps { const val tools_kotlingradle = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" const val kotlin_stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin}" + const val rxKotlin = "io.reactivex.rxjava2:rxkotlin:${Versions.rxKotlin}" + const val rxAndroid = "io.reactivex.rxjava2:rxandroid:${Versions.rxAndroid}" + + const val anko_commons = "org.jetbrains.anko:anko-commons:${Versions.anko}" + const val anko_sdk = "org.jetbrains.anko:anko-sdk25:${Versions.anko}" + const val anko_appcompat = "org.jetbrains.anko:anko-appcompat-v7:${Versions.anko}" + const val anko_constraintlayout = "org.jetbrains.anko:anko-constraint-layout:${Versions.anko}" + const val geckoview_nightly_arm = "org.mozilla.geckoview:geckoview-nightly-armeabi-v7a:${Versions.geckoNightly}" const val geckoview_nightly_x86 = "org.mozilla.geckoview:geckoview-nightly-x86:${Versions.geckoNightly}"