diff --git a/build.gradle b/build.gradle index df69918f7..7c156fc29 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,12 @@ -// Top-level build file where you can add configuration options common to all -// sub-projects/modules. buildscript { + + ext { + room_version = "2.5.0" + navVersion = '2.5.3' + appcompatVersion = "1.6.1" + lifecycleVersion = "2.2.0" + } + repositories { google() mavenCentral() @@ -8,11 +14,13 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.4.1' classpath "com.diffplug.spotless:spotless-plugin-gradle:6.13.0" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion" } } apply plugin: 'com.android.application' apply plugin: "com.diffplug.spotless" +apply plugin: "androidx.navigation.safeargs" repositories { @@ -45,17 +53,25 @@ dependencies { // Conversations 3.0 dependencies - def room_version = "2.5.0" - implementation project(':libs:annotation') annotationProcessor project(':libs:annotation-processor') coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.8' + implementation "androidx.appcompat:appcompat:$rootProject.ext.appcompatVersion" + + implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.ext.lifecycleVersion" + implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-guava:$room_version" + implementation "androidx.navigation:navigation-fragment:$rootProject.ext.navVersion" + implementation "androidx.navigation:navigation-ui:$rootProject.ext.navVersion" + + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + implementation "androidx.security:security-crypto:1.0.0" @@ -82,8 +98,7 @@ dependencies { quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1' implementation 'org.sufficientlysecure:openpgp-api:10.0' implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0' - implementation 'androidx.appcompat:appcompat:1.6.0' - implementation 'androidx.exifinterface:exifinterface:1.3.5' + implementation 'androidx.exifinterface:exifinterface:1.3.6' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'com.google.android.material:material:1.8.0' diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 837ebd3be..f3ea49578 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -85,7 +85,7 @@ android:networkSecurityConfig="@xml/network_security_configuration" android:preserveLegacyExternalStorage="true" android:requestLegacyExternalStorage="true" - android:theme="@style/ConversationsTheme" + android:theme="@style/Conversations3Theme" tools:replace="android:label" tools:targetApi="q"> @@ -121,6 +121,14 @@ + + + + + + + @@ -138,10 +146,7 @@ android:name=".ui.ConversationActivity" android:exported="true" android:theme="@style/SplashTheme"> - - - - + = Build.VERSION_CODES.O) { + window.setNavigationBarColor(SurfaceColors.SURFACE_1.getColor(activity)); + if (isLightMode) { + view.setSystemUiVisibility( + flags + | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); + } + } else if (isLightMode) { + view.setSystemUiVisibility(flags | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); + } + } + + private static boolean isLightMode(final Context context) { + final int nightModeFlags = + context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return nightModeFlags != Configuration.UI_MODE_NIGHT_YES; + } +} diff --git a/src/main/java/im/conversations/android/ui/BindingAdapters.java b/src/main/java/im/conversations/android/ui/BindingAdapters.java new file mode 100644 index 000000000..9d9796fee --- /dev/null +++ b/src/main/java/im/conversations/android/ui/BindingAdapters.java @@ -0,0 +1,31 @@ +package im.conversations.android.ui; + +import android.view.KeyEvent; +import androidx.annotation.NonNull; +import androidx.databinding.BindingAdapter; +import androidx.lifecycle.LiveData; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; +import com.google.common.base.Supplier; + +public class BindingAdapters { + + @BindingAdapter("errorText") + public static void setErrorText( + final TextInputLayout textInputLayout, final LiveData error) { + textInputLayout.setError(error.getValue()); + } + + @BindingAdapter("editorAction") + public static void setEditorAction( + final TextInputEditText editText, final @NonNull Supplier callback) { + editText.setOnEditorActionListener( + (v, actionId, event) -> { + // event is null when using software keyboard + if (event == null || event.getAction() == KeyEvent.ACTION_UP) { + return Boolean.TRUE.equals(callback.get()); + } + return true; + }); + } +} diff --git a/src/main/java/im/conversations/android/ui/Event.java b/src/main/java/im/conversations/android/ui/Event.java new file mode 100644 index 000000000..35ff27c58 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/Event.java @@ -0,0 +1,39 @@ +/* + * Copyright 2019 Daniel Gultsch + * 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. + */ + +package im.conversations.android.ui; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class Event { + + private final T event; + private final AtomicBoolean isConsumable = new AtomicBoolean(true); + + public Event(T event) { + this.event = event; + } + + public T consume() { + if (isConsumable.compareAndSet(true, false)) { + return event; + } + throw new IllegalStateException("Event has already been consumed"); + } + + public boolean isConsumable() { + return isConsumable.get(); + } +} diff --git a/src/main/java/im/conversations/android/ui/NavControllers.java b/src/main/java/im/conversations/android/ui/NavControllers.java new file mode 100644 index 000000000..4dc39e805 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/NavControllers.java @@ -0,0 +1,23 @@ +package im.conversations.android.ui; + +import androidx.annotation.IdRes; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; + +public final class NavControllers { + + private NavControllers() {} + + public static NavController findNavController( + final AppCompatActivity activity, @IdRes int fragmentId) { + final FragmentManager fragmentManager = activity.getSupportFragmentManager(); + final Fragment fragment = fragmentManager.findFragmentById(fragmentId); + if (fragment instanceof NavHostFragment) { + return ((NavHostFragment) fragment).getNavController(); + } + throw new IllegalStateException("Fragment was not of type NavHostFragment"); + } +} diff --git a/src/main/java/im/conversations/android/ui/activity/SetupActivity.java b/src/main/java/im/conversations/android/ui/activity/SetupActivity.java new file mode 100644 index 000000000..ca814d840 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/activity/SetupActivity.java @@ -0,0 +1,50 @@ +package im.conversations.android.ui.activity; + +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.databinding.DataBindingUtil; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavController; +import eu.siacs.conversations.R; +import eu.siacs.conversations.SetupNavigationDirections; +import eu.siacs.conversations.databinding.ActivitySetupBinding; +import im.conversations.android.ui.Activities; +import im.conversations.android.ui.Event; +import im.conversations.android.ui.NavControllers; +import im.conversations.android.ui.model.SetupViewModel; + +public class SetupActivity extends AppCompatActivity { + + private SetupViewModel setupViewModel; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final ActivitySetupBinding binding = + DataBindingUtil.setContentView(this, R.layout.activity_setup); + final ViewModelProvider viewModelProvider = + new ViewModelProvider(this, getDefaultViewModelProviderFactory()); + this.setupViewModel = viewModelProvider.get(SetupViewModel.class); + this.setupViewModel.getRedirection().observe(this, this::onRedirectionEvent); + Activities.setStatusAndNavigationBarColors(this, binding.getRoot()); + } + + private void onRedirectionEvent(final Event targetEvent) { + if (targetEvent.isConsumable()) { + final NavController navController = getNavController(); + final SetupViewModel.Target target = targetEvent.consume(); + switch (target) { + case ENTER_PASSWORD: + navController.navigate(SetupNavigationDirections.enterPassword()); + break; + default: + throw new IllegalStateException( + String.format("Unable to navigate to target %s", target)); + } + } + } + + private NavController getNavController() { + return NavControllers.findNavController(this, R.id.nav_host_fragment); + } +} diff --git a/src/main/java/im/conversations/android/ui/fragment/setup/AbstractSetupFragment.java b/src/main/java/im/conversations/android/ui/fragment/setup/AbstractSetupFragment.java new file mode 100644 index 000000000..45adbcc25 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/fragment/setup/AbstractSetupFragment.java @@ -0,0 +1,18 @@ +package im.conversations.android.ui.fragment.setup; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import im.conversations.android.ui.model.SetupViewModel; + +public abstract class AbstractSetupFragment extends Fragment { + + SetupViewModel setupViewModel; + + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final ViewModelProvider viewModelProvider = + new ViewModelProvider(requireActivity(), getDefaultViewModelProviderFactory()); + this.setupViewModel = viewModelProvider.get(SetupViewModel.class); + } +} diff --git a/src/main/java/im/conversations/android/ui/fragment/setup/HostnameFragment.java b/src/main/java/im/conversations/android/ui/fragment/setup/HostnameFragment.java new file mode 100644 index 000000000..d609c6700 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/fragment/setup/HostnameFragment.java @@ -0,0 +1,3 @@ +package im.conversations.android.ui.fragment.setup; + +public class HostnameFragment extends AbstractSetupFragment {} diff --git a/src/main/java/im/conversations/android/ui/fragment/setup/PasswordFragment.java b/src/main/java/im/conversations/android/ui/fragment/setup/PasswordFragment.java new file mode 100644 index 000000000..389aaca59 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/fragment/setup/PasswordFragment.java @@ -0,0 +1,24 @@ +package im.conversations.android.ui.fragment.setup; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.databinding.DataBindingUtil; +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.FragmentPasswordBinding; + +public class PasswordFragment extends AbstractSetupFragment { + + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + FragmentPasswordBinding binding = + DataBindingUtil.inflate(inflater, R.layout.fragment_password, container, false); + binding.setSetupViewModel(setupViewModel); + binding.setLifecycleOwner(getViewLifecycleOwner()); + return binding.getRoot(); + } +} diff --git a/src/main/java/im/conversations/android/ui/fragment/setup/SignInFragment.java b/src/main/java/im/conversations/android/ui/fragment/setup/SignInFragment.java new file mode 100644 index 000000000..dac120a11 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/fragment/setup/SignInFragment.java @@ -0,0 +1,24 @@ +package im.conversations.android.ui.fragment.setup; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.databinding.DataBindingUtil; +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.FragmentSignInBinding; + +public class SignInFragment extends AbstractSetupFragment { + + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + FragmentSignInBinding binding = + DataBindingUtil.inflate(inflater, R.layout.fragment_sign_in, container, false); + binding.setSetupViewModel(setupViewModel); + binding.setLifecycleOwner(getViewLifecycleOwner()); + return binding.getRoot(); + } +} diff --git a/src/main/java/im/conversations/android/ui/model/SetupViewModel.java b/src/main/java/im/conversations/android/ui/model/SetupViewModel.java new file mode 100644 index 000000000..db1a0d410 --- /dev/null +++ b/src/main/java/im/conversations/android/ui/model/SetupViewModel.java @@ -0,0 +1,63 @@ +package im.conversations.android.ui.model; + +import android.app.Application; +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import im.conversations.android.ui.Event; +import org.jetbrains.annotations.NotNull; + +public class SetupViewModel extends AndroidViewModel { + + private final MutableLiveData xmppAddress = new MutableLiveData<>(); + private final MutableLiveData xmppAddressError = new MutableLiveData<>(); + private final MutableLiveData password = new MutableLiveData<>(); + private final MutableLiveData passwordError = new MutableLiveData<>(); + private final MutableLiveData loading = new MutableLiveData<>(false); + + private final MutableLiveData> redirection = new MutableLiveData<>(); + + public SetupViewModel(@NonNull @NotNull Application application) { + super(application); + } + + public LiveData isLoading() { + return this.loading; + } + + public LiveData getXmppAddressError() { + return Transformations.distinctUntilChanged(xmppAddressError); + } + + public MutableLiveData getXmppAddress() { + return this.xmppAddress; + } + + public MutableLiveData getPassword() { + return password; + } + + public LiveData getPasswordError() { + return Transformations.distinctUntilChanged(this.passwordError); + } + + public boolean submitXmppAddress() { + redirection.postValue(new Event<>(Target.ENTER_PASSWORD)); + return true; + } + + public boolean submitPassword() { + return true; + } + + public LiveData> getRedirection() { + return this.redirection; + } + + public enum Target { + ENTER_PASSWORD, + ENTER_HOSTNAME + } +} diff --git a/src/main/res/anim/slide_from_left.xml b/src/main/res/anim/slide_from_left.xml new file mode 100644 index 000000000..b7d1e72a8 --- /dev/null +++ b/src/main/res/anim/slide_from_left.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/anim/slide_from_right.xml b/src/main/res/anim/slide_from_right.xml new file mode 100644 index 000000000..7116130b4 --- /dev/null +++ b/src/main/res/anim/slide_from_right.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/anim/slide_to_left.xml b/src/main/res/anim/slide_to_left.xml new file mode 100644 index 000000000..7fd524385 --- /dev/null +++ b/src/main/res/anim/slide_to_left.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/anim/slide_to_right.xml b/src/main/res/anim/slide_to_right.xml new file mode 100644 index 000000000..0dbe63b6f --- /dev/null +++ b/src/main/res/anim/slide_to_right.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/drawable/ic_account_circle_24dp.xml b/src/main/res/drawable/ic_account_circle_24dp.xml new file mode 100644 index 000000000..f9110996c --- /dev/null +++ b/src/main/res/drawable/ic_account_circle_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_settings_24dp.xml b/src/main/res/drawable/ic_settings_24dp.xml new file mode 100644 index 000000000..a7c7678dc --- /dev/null +++ b/src/main/res/drawable/ic_settings_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/layout/activity_setup.xml b/src/main/res/layout/activity_setup.xml new file mode 100644 index 000000000..1ea709c2b --- /dev/null +++ b/src/main/res/layout/activity_setup.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/fragment_password.xml b/src/main/res/layout/fragment_password.xml new file mode 100644 index 000000000..fa9d2b8ca --- /dev/null +++ b/src/main/res/layout/fragment_password.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/fragment_sign_in.xml b/src/main/res/layout/fragment_sign_in.xml new file mode 100644 index 000000000..018557a7d --- /dev/null +++ b/src/main/res/layout/fragment_sign_in.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +