Browse Source

Engineering Test Mode

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
Chris Narkiewicz 5 years ago
parent
commit
7d3d2c9f73
25 changed files with 992 additions and 1 deletions
  1. 2 0
      build.gradle
  2. 4 0
      src/main/AndroidManifest.xml
  3. 3 1
      src/main/java/com/nextcloud/client/di/AppComponent.java
  4. 2 0
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  5. 64 0
      src/main/java/com/nextcloud/client/di/ViewModelFactory.kt
  6. 30 0
      src/main/java/com/nextcloud/client/di/ViewModelKey.kt
  7. 38 0
      src/main/java/com/nextcloud/client/di/ViewModelModule.kt
  8. 91 0
      src/main/java/com/nextcloud/client/etm/EtmActivity.kt
  9. 28 0
      src/main/java/com/nextcloud/client/etm/EtmBaseFragment.kt
  10. 68 0
      src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt
  11. 25 0
      src/main/java/com/nextcloud/client/etm/EtmMenuEntry.kt
  12. 53 0
      src/main/java/com/nextcloud/client/etm/EtmMenuFragment.kt
  13. 71 0
      src/main/java/com/nextcloud/client/etm/EtmViewModel.kt
  14. 73 0
      src/main/java/com/nextcloud/client/etm/pages/EtmPreferencesFragment.kt
  15. 1 0
      src/main/java/com/owncloud/android/MainApp.java
  16. 10 0
      src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
  17. 39 0
      src/main/res/layout/activity_etm.xml
  18. 32 0
      src/main/res/layout/fragment_etm_menu.xml
  19. 33 0
      src/main/res/layout/fragment_etm_preferences.xml
  20. 51 0
      src/main/res/layout/material_list_item_single_line.xml
  21. 33 0
      src/main/res/menu/etm_preferences.xml
  22. 4 0
      src/main/res/values/strings.xml
  23. 30 0
      src/main/res/values/styles.xml
  24. 5 0
      src/main/res/xml/preferences.xml
  25. 202 0
      src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt

+ 2 - 0
build.gradle

@@ -256,6 +256,7 @@ dependencies {
     implementation 'androidx.appcompat:appcompat:1.0.2'
     implementation 'androidx.cardview:cardview:1.0.0'
     implementation 'androidx.exifinterface:exifinterface:1.0.0'
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.0.0"
     implementation 'com.github.albfernandez:juniversalchardet:2.0.3' // need this version for Android <7
     implementation 'com.google.code.findbugs:annotations:2.0.1'
     implementation 'commons-io:commons-io:2.6'
@@ -305,6 +306,7 @@ dependencies {
     testImplementation 'org.powermock:powermock-api-mockito2:2.0.2'
     testImplementation 'org.json:json:20180813'
     testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0"
+    testImplementation "androidx.arch.core:core-testing:2.0.1"
 
     // dependencies for instrumented tests
     // JUnit4 Rules

+ 4 - 0
src/main/AndroidManifest.xml

@@ -386,6 +386,10 @@
             android:name=".ui.activity.SsoGrantPermissionActivity"
             android:exported="true"
             android:theme="@style/Theme.ownCloud.Dialog.NoTitle" />
+
+        <activity
+            android:name="com.nextcloud.client.etm.EtmActivity"
+            android:theme="@style/Theme.ownCloud.Toolbar"/>
     </application>
 
 </manifest>

+ 3 - 1
src/main/java/com/nextcloud/client/di/AppComponent.java

@@ -42,10 +42,12 @@ import dagger.android.support.AndroidSupportInjectionModule;
     AppInfoModule.class,
     NetworkModule.class,
     DeviceModule.class,
-    OnboardingModule.class
+    OnboardingModule.class,
+    ViewModelModule.class
 })
 @Singleton
 public interface AppComponent {
+
     void inject(MainApp app);
 
     @Component.Builder

+ 2 - 0
src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -21,6 +21,7 @@
 package com.nextcloud.client.di;
 
 import com.nextcloud.client.onboarding.FirstRunActivity;
+import com.nextcloud.client.etm.EtmActivity;
 import com.nextcloud.client.onboarding.WhatsNewActivity;
 import com.owncloud.android.authentication.AuthenticatorActivity;
 import com.owncloud.android.authentication.DeepLinkLoginActivity;
@@ -121,6 +122,7 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector abstract UploadPathActivity uploadPathActivity();
     @ContributesAndroidInjector abstract UserInfoActivity userInfoActivity();
     @ContributesAndroidInjector abstract WhatsNewActivity whatsNewActivity();
+    @ContributesAndroidInjector abstract EtmActivity etmActivity();
 
     @ContributesAndroidInjector abstract ExtendedListFragment extendedListFragment();
     @ContributesAndroidInjector abstract FileDetailFragment fileDetailFragment();

+ 64 - 0
src/main/java/com/nextcloud/client/di/ViewModelFactory.kt

@@ -0,0 +1,64 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.di
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import javax.inject.Inject
+import javax.inject.Provider
+
+/**
+ * This factory provide [ViewModel] instances initialized by Dagger 2 dependency injection system.
+ *
+ * Each [javax.inject.Provider] instance accesses Dagger machinery, which provide
+ * fully-initialized [ViewModel] instance.
+ *
+ * @see ViewModelModule
+ * @see ViewModelKey
+ */
+class ViewModelFactory @Inject constructor(
+    private val viewModelProviders: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
+) : ViewModelProvider.Factory {
+
+    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
+        var vmProvider: Provider<ViewModel>? = viewModelProviders.get(modelClass)
+
+        if (vmProvider == null) {
+            for (entry in viewModelProviders.entries) {
+                if (modelClass.isAssignableFrom(entry.key)) {
+                    vmProvider = entry.value
+                    break
+                }
+            }
+        }
+
+        if (vmProvider == null) {
+            throw IllegalArgumentException("${modelClass.simpleName} view model class is not supported")
+        }
+
+        @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown", "UNCHECKED_CAST")
+        try {
+            val vm = vmProvider.get() as T
+            return vm
+        } catch (e: Exception) {
+            throw RuntimeException(e)
+        }
+    }
+}

+ 30 - 0
src/main/java/com/nextcloud/client/di/ViewModelKey.kt

@@ -0,0 +1,30 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.di
+
+import androidx.lifecycle.ViewModel
+import dagger.MapKey
+import kotlin.reflect.KClass
+
+@MustBeDocumented
+@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
+@Retention(AnnotationRetention.RUNTIME)
+@MapKey
+annotation class ViewModelKey(val value: KClass<out ViewModel>)

+ 38 - 0
src/main/java/com/nextcloud/client/di/ViewModelModule.kt

@@ -0,0 +1,38 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.di
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import com.nextcloud.client.etm.EtmViewModel
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+
+@Module
+abstract class ViewModelModule {
+    @Binds
+    @IntoMap
+    @ViewModelKey(EtmViewModel::class)
+    abstract fun etmViewModel(vm: EtmViewModel): ViewModel
+
+    @Binds
+    abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
+}

+ 91 - 0
src/main/java/com/nextcloud/client/etm/EtmActivity.kt

@@ -0,0 +1,91 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProvider
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.di.ViewModelFactory
+import com.owncloud.android.R
+import com.owncloud.android.ui.activity.ToolbarActivity
+import javax.inject.Inject
+
+class EtmActivity : ToolbarActivity(), Injectable {
+
+    companion object {
+        @JvmStatic
+        fun launch(context: Context) {
+            val etmIntent = Intent(context, EtmActivity::class.java)
+            context.startActivity(etmIntent)
+        }
+    }
+
+    @Inject
+    lateinit var viewModelFactory: ViewModelFactory
+    internal lateinit var vm: EtmViewModel
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_etm)
+        setupToolbar()
+        updateActionBarTitleAndHomeButtonByString(getString(R.string.etm_title))
+        vm = ViewModelProvider(this, viewModelFactory).get(EtmViewModel::class.java)
+        vm.currentPage.observe(this, Observer {
+            onPageChanged(it)
+        })
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
+        return when (item?.itemId) {
+            android.R.id.home -> {
+                if (!vm.onBackPressed()) {
+                    finish()
+                }
+                true
+            }
+            else -> super.onOptionsItemSelected(item)
+        }
+    }
+
+    override fun onBackPressed() {
+        if (!vm.onBackPressed()) {
+            super.onBackPressed()
+        }
+    }
+
+    private fun onPageChanged(page: EtmMenuEntry?) {
+        if (page != null) {
+            val fragment = page.pageClass.java.getConstructor().newInstance()
+            supportFragmentManager.beginTransaction()
+                .replace(R.id.etm_page_container, fragment)
+                .commit()
+            updateActionBarTitleAndHomeButtonByString("ETM - ${getString(page.titleRes)}")
+        } else {
+            supportFragmentManager.beginTransaction()
+                .replace(R.id.etm_page_container, EtmMenuFragment())
+                .commitNow()
+            updateActionBarTitleAndHomeButtonByString(getString(R.string.etm_title))
+        }
+    }
+}

+ 28 - 0
src/main/java/com/nextcloud/client/etm/EtmBaseFragment.kt

@@ -0,0 +1,28 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm
+
+import androidx.fragment.app.Fragment
+
+abstract class EtmBaseFragment : Fragment() {
+    protected val vm: EtmViewModel get() {
+        return (activity as EtmActivity).vm
+    }
+}

+ 68 - 0
src/main/java/com/nextcloud/client/etm/EtmMenuAdapter.kt

@@ -0,0 +1,68 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.owncloud.android.R
+
+class EtmMenuAdapter(
+    context: Context,
+    val onItemClicked: (Int) -> Unit
+) : RecyclerView.Adapter<EtmMenuAdapter.PageViewHolder>() {
+
+    private val layoutInflater = LayoutInflater.from(context)
+    var pages: List<EtmMenuEntry> = listOf()
+        set(value) {
+            field = value
+            notifyDataSetChanged()
+        }
+
+    class PageViewHolder(view: View, onClick: (Int) -> Unit) : RecyclerView.ViewHolder(view) {
+        val primaryAction: ImageView = view.findViewById(R.id.primary_action)
+        val text: TextView = view.findViewById(R.id.text)
+        val secondaryAction: ImageView = view.findViewById(R.id.secondary_action)
+
+        init {
+            itemView.setOnClickListener { onClick(adapterPosition) }
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder {
+        val view = layoutInflater.inflate(R.layout.material_list_item_single_line, parent, false)
+        return PageViewHolder(view, onItemClicked)
+    }
+
+    override fun onBindViewHolder(holder: PageViewHolder, position: Int) {
+        val page = pages[position]
+        holder.primaryAction.setImageResource(page.iconRes)
+        holder.text.setText(page.titleRes)
+        holder.secondaryAction.setImageResource(0)
+    }
+
+    override fun getItemCount(): Int {
+        return pages.size
+    }
+}

+ 25 - 0
src/main/java/com/nextcloud/client/etm/EtmMenuEntry.kt

@@ -0,0 +1,25 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm
+
+import androidx.fragment.app.Fragment
+import kotlin.reflect.KClass
+
+data class EtmMenuEntry(val iconRes: Int, val titleRes: Int, val pageClass: KClass<out Fragment>)

+ 53 - 0
src/main/java/com/nextcloud/client/etm/EtmMenuFragment.kt

@@ -0,0 +1,53 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.owncloud.android.R
+
+class EtmMenuFragment : EtmBaseFragment() {
+
+    private lateinit var adapter: EtmMenuAdapter
+    private lateinit var list: RecyclerView
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        adapter = EtmMenuAdapter(context!!, this::onClickedItem)
+        adapter.pages = vm.pages
+        val view = inflater.inflate(R.layout.fragment_etm_menu, container, false)
+        list = view.findViewById(R.id.etm_menu_list)
+        list.layoutManager = LinearLayoutManager(context!!)
+        list.adapter = adapter
+        return view
+    }
+
+    override fun onResume() {
+        super.onResume()
+        activity?.setTitle(R.string.etm_title)
+    }
+
+    private fun onClickedItem(position: Int) {
+        vm.onPageSelected(position)
+    }
+}

+ 71 - 0
src/main/java/com/nextcloud/client/etm/EtmViewModel.kt

@@ -0,0 +1,71 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm
+
+import android.content.SharedPreferences
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.nextcloud.client.etm.pages.EtmPreferencesFragment
+import com.owncloud.android.R
+import javax.inject.Inject
+
+class EtmViewModel @Inject constructor(
+    private val defaultPreferences: SharedPreferences
+) : ViewModel() {
+    val currentPage: LiveData<EtmMenuEntry?> = MutableLiveData()
+    val pages: List<EtmMenuEntry> = listOf(
+        EtmMenuEntry(
+            iconRes = R.drawable.ic_settings,
+            titleRes = R.string.etm_preferences,
+            pageClass = EtmPreferencesFragment::class
+        )
+    )
+
+    val preferences: Map<String, String> get() {
+        return defaultPreferences.all
+            .map { it.key to "${it.value}" }
+            .sortedBy { it.first }
+            .toMap()
+    }
+
+    init {
+        (currentPage as MutableLiveData).apply {
+            value = null
+        }
+    }
+
+    fun onPageSelected(index: Int) {
+        if (index < pages.size) {
+            currentPage as MutableLiveData
+            currentPage.value = pages[index]
+        }
+    }
+
+    fun onBackPressed(): Boolean {
+        (currentPage as MutableLiveData)
+        return if (currentPage.value != null) {
+            currentPage.value = null
+            true
+        } else {
+            false
+        }
+    }
+}

+ 73 - 0
src/main/java/com/nextcloud/client/etm/pages/EtmPreferencesFragment.kt

@@ -0,0 +1,73 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm.pages
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import com.nextcloud.client.etm.EtmBaseFragment
+import com.owncloud.android.R
+import kotlinx.android.synthetic.main.fragment_etm_preferences.*
+
+class EtmPreferencesFragment : EtmBaseFragment() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setHasOptionsMenu(true)
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        return inflater.inflate(R.layout.fragment_etm_preferences, container, false)
+    }
+
+    override fun onResume() {
+        super.onResume()
+        val builder = StringBuilder()
+        vm.preferences.forEach { builder.append("${it.key}: ${it.value}\n") }
+        etm_preferences_text.text = builder
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
+        super.onCreateOptionsMenu(menu, inflater)
+        inflater?.inflate(R.menu.etm_preferences, menu)
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
+        return when (item?.itemId) {
+            R.id.etm_preferences_share -> {
+                onClickedShare(); true
+            }
+            else -> super.onOptionsItemSelected(item)
+        }
+    }
+
+    private fun onClickedShare() {
+        val intent = Intent(Intent.ACTION_SEND)
+        intent.putExtra(Intent.EXTRA_SUBJECT, "Nextcloud preferences")
+        intent.putExtra(Intent.EXTRA_TEXT, etm_preferences_text.text)
+        intent.type = "text/plain"
+        startActivity(intent)
+    }
+}

+ 1 - 0
src/main/java/com/owncloud/android/MainApp.java

@@ -48,6 +48,7 @@ import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.appinfo.AppInfo;
 import com.nextcloud.client.device.PowerManagementService;
 import com.nextcloud.client.di.ActivityInjector;
+import com.nextcloud.client.di.AppComponent;
 import com.nextcloud.client.di.DaggerAppComponent;
 import com.nextcloud.client.network.ConnectivityService;
 import com.nextcloud.client.onboarding.OnboardingService;

+ 10 - 0
src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java

@@ -55,6 +55,7 @@ import android.webkit.URLUtil;
 
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.di.Injectable;
+import com.nextcloud.client.etm.EtmActivity;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.nextcloud.client.preferences.AppPreferencesImpl;
 import com.owncloud.android.BuildConfig;
@@ -207,6 +208,15 @@ public class SettingsActivity extends PreferenceActivity
                     return true;
                 });
             }
+
+            /* Engineering Test Mode */
+            Preference pEtm = findPreference("etm");
+            if (pEtm != null) {
+                pEtm.setOnPreferenceClickListener(preference -> {
+                    EtmActivity.launch(this);
+                    return true;
+                });
+            }
         } else {
             preferenceScreen.removePreference(preferenceCategoryDev);
         }

+ 39 - 0
src/main/res/layout/activity_etm.xml

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".etm.EtmActivity">
+
+    <include layout="@layout/toolbar_standard"/>
+
+    <FrameLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:layout_behavior="@string/appbar_scrolling_view_behavior"
+        android:id="@+id/etm_page_container">
+
+    </FrameLayout>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 32 - 0
src/main/res/layout/fragment_etm_menu.xml

@@ -0,0 +1,32 @@
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.nextcloud.client.etm.EtmMenuFragment">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/etm_menu_list"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+    </androidx.recyclerview.widget.RecyclerView>
+
+</FrameLayout>

+ 33 - 0
src/main/res/layout/fragment_etm_preferences.xml

@@ -0,0 +1,33 @@
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.nextcloud.client.etm.pages.EtmPreferencesFragment">
+
+    <TextView
+        android:id="@+id/etm_preferences_text"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:padding="@dimen/standard_padding"
+        android:scrollbars="vertical"/>
+
+</FrameLayout>

+ 51 - 0
src/main/res/layout/material_list_item_single_line.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
+<!--
+    This is a generic, single row list item matching Material Design specification.
+-->
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    style="@style/MaterialListItemSingleLine">
+
+    <androidx.appcompat.widget.AppCompatImageView
+        style="@style/MaterialListItemPrimaryAction"
+        android:id="@+id/primary_action"
+        tools:src="@drawable/ic_alert"/>
+
+    <TextView
+        android:id="@+id/text"
+        android:layout_gravity="center_vertical"
+        android:layout_width="0dip"
+        android:layout_weight="1"
+        android:layout_height="wrap_content"
+        android:textSize="16sp"
+        android:lines="1"
+        android:ellipsize="end"
+        android:textColor="?android:attr/textColorPrimary"
+        tools:text="Single line of text"/>
+
+    <androidx.appcompat.widget.AppCompatImageView
+        style="@style/MaterialListItemSecondaryAction"
+        android:id="@+id/secondary_action"
+        tools:src="@drawable/ic_alert"/>
+
+</LinearLayout>

+ 33 - 0
src/main/res/menu/etm_preferences.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Nextcloud Android client application
+
+    @author Chris Narkiewicz
+    Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="AppCompatResource">
+
+    <item
+        android:id="@+id/etm_preferences_share"
+        android:title="@string/etm_share"
+        app:showAsAction="ifRoom"
+        android:showAsAction="ifRoom"
+        android:icon="@drawable/ic_share" />
+
+</menu>

+ 4 - 0
src/main/res/values/strings.xml

@@ -872,4 +872,8 @@
     <string name="copy_internal_link">Copy internal link</string>
     <string name="copy_internal_link_subline">Only works for users with access to this folder</string>
     <string name="failed_to_download">Failed to pass file to download manager</string>
+
+    <string name="etm_title">Engineering Test Mode</string>
+    <string name="etm_preferences">Preferences</string>
+    <string name="etm_share">Share</string>
 </resources>

+ 30 - 0
src/main/res/values/styles.xml

@@ -4,6 +4,7 @@
 
   Copyright (C) 2012  Bartek Przybylski
   Copyright (C) 2015 ownCloud Inc.
+  Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
 
   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License version 2,
@@ -284,4 +285,33 @@
 		<item name="android:textSize">16sp</item>
 		<item name="textAllCaps">false</item>
 	</style>
+
+    <style name="MaterialListItemSingleLine">
+        <item name="android:clickable">true</item>
+        <item name="android:background">?android:selectableItemBackground</item>
+        <item name="android:paddingLeft">16dp</item>
+        <item name="android:paddingRight">16dp</item>
+        <item name="android:layout_width">match_parent</item>
+        <item name="android:layout_height">48dp</item>
+        <item name="android:gravity">center_vertical</item>
+    </style>
+
+    <style name="MaterialListItemPrimaryAction">
+        <item name="tint">?android:attr/textColorSecondary</item>
+        <item name="android:layout_width">32dp</item>
+        <item name="android:layout_height">32dp</item>
+        <item name="android:layout_marginRight">16dp</item>
+        <item name="android:scaleType">fitCenter</item>
+        <item name="android:layout_gravity">center_vertical</item>
+    </style>
+
+    <style name="MaterialListItemSecondaryAction">
+        <item name="tint">?android:attr/textColorSecondary</item>
+        <item name="android:layout_width">24dp</item>
+        <item name="android:layout_height">24dp</item>
+        <item name="android:layout_marginLeft">16dp</item>
+        <item name="android:scaleType">fitCenter</item>
+        <item name="android:layout_gravity">center_vertical</item>
+    </style>
+
 </resources>

+ 5 - 0
src/main/res/xml/preferences.xml

@@ -4,6 +4,7 @@
 
   Copyright (C) 2012  Bartek Przybylski
   Copyright (C) 2012-2013 ownCloud Inc.
+  Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
 
   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License version 2,
@@ -95,6 +96,10 @@
 		<Preference android:id="@+id/changelog_link"
 					android:title="Changelog dev version"
 					android:key="changelog_link" />
+
+        <Preference android:id="@+id/etm"
+                    android:title="@string/etm_title"
+                    android:key="etm" />
 	</PreferenceCategory>
 
 </PreferenceScreen>

+ 202 - 0
src/test/java/com/nextcloud/client/etm/TestEtmViewModel.kt

@@ -0,0 +1,202 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Chris Narkiewicz
+ * Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client.etm
+
+import android.content.SharedPreferences
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.Observer
+import com.nhaarman.mockitokotlin2.anyOrNull
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.reset
+import com.nhaarman.mockitokotlin2.same
+import com.nhaarman.mockitokotlin2.times
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Suite
+
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+    TestEtmViewModel.MainPage::class,
+    TestEtmViewModel.PreferencesPage::class
+)
+class TestEtmViewModel {
+
+    internal abstract class Base {
+
+        @get:Rule
+        val rule = InstantTaskExecutorRule()
+
+        protected lateinit var sharedPreferences: SharedPreferences
+        protected lateinit var vm: EtmViewModel
+
+        @Before
+        fun setUpBase() {
+            sharedPreferences = mock()
+            vm = EtmViewModel(sharedPreferences)
+        }
+    }
+
+    internal class MainPage : Base() {
+
+        @Test
+        fun `current page is not set`() {
+            // GIVEN
+            //      main page is displayed
+            // THEN
+            //      current page is null
+            assertNull(vm.currentPage.value)
+        }
+
+        @Test
+        fun `back key is not handled`() {
+            // GIVEN
+            //      main page is displayed
+            // WHEN
+            //      back key is pressed
+            val handled = vm.onBackPressed()
+
+            // THEN
+            //      is not handled
+            assertFalse(handled)
+        }
+
+        @Test
+        fun `page is selected`() {
+            val observer: Observer<EtmMenuEntry?> = mock()
+            val selectedPageIndex = 0
+            val expectedPage = vm.pages[selectedPageIndex]
+
+            // GIVEN
+            //      main page is displayed
+            //      current page observer is registered
+            vm.currentPage.observeForever(observer)
+            reset(observer)
+
+            // WHEN
+            //      page is selected
+            vm.onPageSelected(selectedPageIndex)
+
+            // THEN
+            //      current page is set
+            //      page observer is called once with selected entry
+            assertNotNull(vm.currentPage.value)
+            verify(observer, times(1)).onChanged(same(expectedPage))
+        }
+
+        @Test
+        fun `out of range index is ignored`() {
+            val maxIndex = vm.pages.size
+            // GIVEN
+            //      observer is registered
+            val observer: Observer<EtmMenuEntry?> = mock()
+            vm.currentPage.observeForever(observer)
+            reset(observer)
+
+            // WHEN
+            //      out of range page index is selected
+            vm.onPageSelected(maxIndex + 1)
+
+            // THEN
+            //      nothing happens
+            verify(observer, never()).onChanged(anyOrNull())
+            assertNull(vm.currentPage.value)
+        }
+    }
+
+    internal class PreferencesPage : Base() {
+
+        @Before
+        fun setUp() {
+            vm.onPageSelected(0)
+        }
+
+        @Test
+        fun `back goes back to main page`() {
+            val observer: Observer<EtmMenuEntry?> = mock()
+
+            // GIVEN
+            //      a page is selected
+            //      page observer is registered
+            assertNotNull(vm.currentPage.value)
+            vm.currentPage.observeForever(observer)
+
+            // WHEN
+            //      back is pressed
+            val handled = vm.onBackPressed()
+
+            // THEN
+            //      back press is handled
+            //      observer is called with null page
+            assertTrue(handled)
+            verify(observer).onChanged(eq(null))
+        }
+
+        @Test
+        fun `back is handled only once`() {
+            // GIVEN
+            //      a page is selected
+            assertNotNull(vm.currentPage.value)
+
+            // WHEN
+            //      back is pressed twice
+            val first = vm.onBackPressed()
+            val second = vm.onBackPressed()
+
+            // THEN
+            //      back is handled only once
+            assertTrue(first)
+            assertFalse(second)
+        }
+
+        @Test
+        fun `preferences are loaded from shared preferences`() {
+            // GIVEN
+            //      shared preferences contain values of different types
+            val preferenceValues: Map<String, Any> = mapOf(
+                "key1" to 1,
+                "key2" to "value2",
+                "key3" to false
+            )
+            whenever(sharedPreferences.all).thenReturn(preferenceValues)
+
+            // WHEN
+            //      vm preferences are read
+            val prefs = vm.preferences
+
+            // THEN
+            //      all preferences are converted to strings
+            assertEquals(preferenceValues.size, prefs.size)
+            assertEquals("1", prefs["key1"])
+            assertEquals("value2", prefs["key2"])
+            assertEquals("false", prefs["key3"])
+        }
+    }
+}