diff --git a/app/src/main/java/zechs/drive/stream/data/local/AccountsDao.kt b/app/src/main/java/zechs/drive/stream/data/local/AccountsDao.kt index b6b916f..38a7643 100644 --- a/app/src/main/java/zechs/drive/stream/data/local/AccountsDao.kt +++ b/app/src/main/java/zechs/drive/stream/data/local/AccountsDao.kt @@ -5,24 +5,54 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow import zechs.drive.stream.data.model.Account +import zechs.drive.stream.data.model.AccountWithClient +import zechs.drive.stream.data.model.Client @Dao interface AccountsDao { - @Query("SELECT * FROM `accounts`") - suspend fun getAccounts(): List - - @Query("SELECT * FROM `accounts` WHERE name = :name LIMIT 1") - suspend fun getAccount(name: String): Account? - - @Query("UPDATE `accounts` SET accessToken = :accessToken WHERE refreshToken = :refreshToken") - suspend fun updateAccessToken(refreshToken: String, accessToken: String) - - @Query("DELETE FROM `accounts` WHERE name = :name") - suspend fun deleteAccount(name: String) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addClient(client: Client) @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun addAccount(account: Account): Long + suspend fun addAccount(account: Account) + + @Transaction + @Query("DELETE FROM clients WHERE id = :clientId") + suspend fun deleteClient(clientId: String) + + @Query("SELECT * FROM clients") + fun getClients(): Flow> + + @Transaction + @Query( + "SELECT accounts.*, clients.secret AS clientSecret, clients.redirectUri " + + "FROM accounts JOIN clients ON accounts.clientId = clients.id" + ) + fun getAccounts(): Flow> + + @Transaction + @Query( + "SELECT accounts.*, clients.secret AS clientSecret, clients.redirectUri " + + "FROM accounts JOIN clients ON accounts.clientId = clients.id " + + "WHERE accounts.name = :accountName" + ) + suspend fun getAccount(accountName: String): AccountWithClient? + + @Query("UPDATE accounts SET name = :newName WHERE name = :oldName") + suspend fun updateAccountName(oldName: String, newName: String) + + @Query("DELETE FROM accounts WHERE name = :accountName") + suspend fun deleteAccount(accountName: String) + + @Transaction + @Query("UPDATE clients SET secret = :secret, redirectUri = :redirectUri WHERE id = :clientId") + suspend fun updateClient(clientId: String, secret: String, redirectUri: String) + + @Query("UPDATE accounts SET accessToken = :newToken WHERE name = :accountName") + suspend fun updateAccessToken(accountName: String, newToken: String) } \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/data/local/AccountsDatabase.kt b/app/src/main/java/zechs/drive/stream/data/local/AccountsDatabase.kt index 3e85088..d88fdcf 100644 --- a/app/src/main/java/zechs/drive/stream/data/local/AccountsDatabase.kt +++ b/app/src/main/java/zechs/drive/stream/data/local/AccountsDatabase.kt @@ -3,10 +3,11 @@ package zechs.drive.stream.data.local import androidx.room.Database import androidx.room.RoomDatabase import zechs.drive.stream.data.model.Account +import zechs.drive.stream.data.model.Client @Database( - entities = [Account::class], + entities = [Account::class, Client::class], version = 1, exportSchema = false ) diff --git a/app/src/main/java/zechs/drive/stream/data/model/Account.kt b/app/src/main/java/zechs/drive/stream/data/model/Account.kt deleted file mode 100644 index 8398d70..0000000 --- a/app/src/main/java/zechs/drive/stream/data/model/Account.kt +++ /dev/null @@ -1,35 +0,0 @@ -package zechs.drive.stream.data.model - -import androidx.annotation.Keep -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.PrimaryKey -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken - -@Keep -@Entity(tableName = "accounts") -data class Account( - @PrimaryKey val name: String, - val clientId: String, - val clientSecret: String, - val redirectUri: String, - val refreshToken: String, - val accessToken: String -) { - - @Ignore var isDefault: Boolean = false - - fun getDriveClient() = DriveClient( - clientId = clientId, - clientSecret = clientSecret, - redirectUri = redirectUri, - scopes = listOf("https://www.googleapis.com/auth/drive") - ) - - fun getAccessTokenResponse(): TokenResponse { - val type = object : TypeToken() {}.type - return Gson().fromJson(accessToken, type) - } - -} diff --git a/app/src/main/java/zechs/drive/stream/data/model/AccountWithClient.kt b/app/src/main/java/zechs/drive/stream/data/model/AccountWithClient.kt new file mode 100644 index 0000000..3585c10 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/data/model/AccountWithClient.kt @@ -0,0 +1,63 @@ +package zechs.drive.stream.data.model + +import androidx.annotation.Keep +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +@Entity(tableName = "clients") +data class Client( + @PrimaryKey val id: String, + val secret: String, + val redirectUri: String +) { + fun isEmpty() = id.isEmpty() || secret.isEmpty() || redirectUri.isEmpty() +} + +@Entity( + tableName = "accounts", + foreignKeys = [ForeignKey( + entity = Client::class, + parentColumns = ["id"], + childColumns = ["clientId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("clientId")] +) +data class Account( + @PrimaryKey val name: String, + val refreshToken: String, + val accessToken: String, + val clientId: String +) + +@Keep +data class AccountWithClient( + val name: String, + val clientId: String, + val clientSecret: String, + val redirectUri: String, + val refreshToken: String, + val accessToken: String +) { + + @Ignore + var isDefault: Boolean = false + + fun getDriveClient() = DriveClient( + clientId = clientId, + clientSecret = clientSecret, + redirectUri = redirectUri, + scopes = listOf("https://www.googleapis.com/auth/drive") + ) + + fun getAccessTokenResponse(): TokenResponse { + val type = object : TypeToken() {}.type + return Gson().fromJson(accessToken, type) + } + +} diff --git a/app/src/main/java/zechs/drive/stream/data/model/DriveClient.kt b/app/src/main/java/zechs/drive/stream/data/model/DriveClient.kt index 8e25878..3f9aaf0 100644 --- a/app/src/main/java/zechs/drive/stream/data/model/DriveClient.kt +++ b/app/src/main/java/zechs/drive/stream/data/model/DriveClient.kt @@ -22,4 +22,10 @@ data class DriveClient( } catch (e: Exception) { null } + + fun getClient() = Client( + id = clientId, + secret = clientSecret, + redirectUri = redirectUri + ) } diff --git a/app/src/main/java/zechs/drive/stream/data/model/TokenRequestBody.kt b/app/src/main/java/zechs/drive/stream/data/model/TokenRequestBody.kt new file mode 100644 index 0000000..85cf726 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/data/model/TokenRequestBody.kt @@ -0,0 +1,8 @@ +package zechs.drive.stream.data.model + +import com.google.errorprone.annotations.Keep + +@Keep +data class TokenRequestBody( + val token: String +) diff --git a/app/src/main/java/zechs/drive/stream/data/remote/RevokeTokenApi.kt b/app/src/main/java/zechs/drive/stream/data/remote/RevokeTokenApi.kt new file mode 100644 index 0000000..c9c7801 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/data/remote/RevokeTokenApi.kt @@ -0,0 +1,15 @@ +package zechs.drive.stream.data.remote + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST +import zechs.drive.stream.data.model.TokenRequestBody + +interface RevokeTokenApi { + + @POST("/revoke") + suspend fun revokeToken( + @Body body: TokenRequestBody + ): Response + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/di/ApiModule.kt b/app/src/main/java/zechs/drive/stream/di/ApiModule.kt index a0be256..52bd6b4 100644 --- a/app/src/main/java/zechs/drive/stream/di/ApiModule.kt +++ b/app/src/main/java/zechs/drive/stream/di/ApiModule.kt @@ -15,6 +15,7 @@ import zechs.drive.stream.BuildConfig import zechs.drive.stream.data.model.StarredAdapter import zechs.drive.stream.data.remote.DriveApi import zechs.drive.stream.data.remote.GithubApi +import zechs.drive.stream.data.remote.RevokeTokenApi import zechs.drive.stream.data.remote.TokenApi import zechs.drive.stream.data.repository.DriveRepository import zechs.drive.stream.data.repository.GithubRepository @@ -23,6 +24,7 @@ import zechs.drive.stream.utils.SessionManager import zechs.drive.stream.utils.util.Constants.Companion.GITHUB_API import zechs.drive.stream.utils.util.Constants.Companion.GOOGLE_ACCOUNTS_URL import zechs.drive.stream.utils.util.Constants.Companion.GOOGLE_API +import zechs.drive.stream.utils.util.Constants.Companion.GOOGLE_OAUTH_URL import javax.inject.Named import javax.inject.Singleton @@ -133,6 +135,21 @@ object ApiModule { .create(GithubApi::class.java) } + @Provides + @Singleton + fun provideRevokeTokenApi( + @Named("OkHttpClient") + client: OkHttpClient, + moshi: Moshi + ): RevokeTokenApi { + return Retrofit.Builder() + .baseUrl(GOOGLE_OAUTH_URL) + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(RevokeTokenApi::class.java) + } + @Provides @Singleton fun provideDriveRepository( diff --git a/app/src/main/java/zechs/drive/stream/ui/add_account/DialogAddAccount.kt b/app/src/main/java/zechs/drive/stream/ui/add_account/DialogAddAccount.kt new file mode 100644 index 0000000..340c710 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/add_account/DialogAddAccount.kt @@ -0,0 +1,39 @@ +package zechs.drive.stream.ui.add_account + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.Window +import android.widget.Toast +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputLayout +import zechs.drive.stream.R + +class DialogAddAccount( + context: Context, + val onNextClickListener: (String) -> Unit +) : Dialog(context, R.style.ThemeOverlay_Fade_MaterialAlertDialog) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.dialog_new_account) + + val etNickname = findViewById(R.id.tf_nickname).editText!! + val nextButton = findViewById(R.id.btn_next) + + nextButton.setOnClickListener { + if (etNickname.text.toString().isEmpty()) { + Toast.makeText( + context, + context.getString(R.string.please_enter_a_nickname), + Toast.LENGTH_SHORT + ).show() + } else { + onNextClickListener.invoke(etNickname.text.toString()) + dismiss() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/add_client/DialogAddClient.kt b/app/src/main/java/zechs/drive/stream/ui/add_client/DialogAddClient.kt new file mode 100644 index 0000000..2115f3b --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/add_client/DialogAddClient.kt @@ -0,0 +1,145 @@ +package zechs.drive.stream.ui.add_client + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.Window +import android.widget.EditText +import androidx.appcompat.widget.AppCompatImageButton +import androidx.core.view.isGone +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.textview.MaterialTextView +import zechs.drive.stream.R +import zechs.drive.stream.data.model.Client +import zechs.drive.stream.utils.util.GoogleClientValidator +import zechs.drive.stream.utils.util.UrlValidator + +class DialogAddClient( + context: Context, + val client: Client? = null, + val onSubmitClickListener: (Client) -> Unit +) : Dialog(context, R.style.ThemeOverlay_Fade_MaterialAlertDialog) { + + private lateinit var title: MaterialTextView + private lateinit var tfClientId: TextInputLayout + private lateinit var etClientId: EditText + private lateinit var tfClientSecret: TextInputLayout + private lateinit var etClientSecret: EditText + private lateinit var tfRedirectUri: TextInputLayout + private lateinit var etRedirectUri: EditText + private lateinit var btnClientInfo: AppCompatImageButton + private lateinit var submitButton: MaterialButton + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.dialog_client) + + title = findViewById(R.id.tv_title) + tfClientId = findViewById(R.id.tf_client_id) + etClientId = tfClientId.editText!! + tfClientSecret = findViewById(R.id.tf_client_secret) + etClientSecret = tfClientSecret.editText!! + tfRedirectUri = findViewById(R.id.tf_redirect_uri) + etRedirectUri = tfRedirectUri.editText!! + btnClientInfo = findViewById(R.id.btnClientInfo) + val chipScope = findViewById(R.id.chipScope) + submitButton = findViewById(R.id.btn_submit) + + submitButton.setOnClickListener { + if (!areInputsValid()) { + return@setOnClickListener + } + onSubmitClickListener.invoke( + Client( + id = etClientId.text.trim().toString(), + secret = etClientSecret.text.trim().toString(), + redirectUri = etRedirectUri.text.trim().toString() + ) + ) + dismiss() + } + + chipScope.setOnCloseIconClickListener { + MaterialAlertDialogBuilder(context) + .setTitle(context.getString(R.string.important_notice)) + .setMessage(context.getString(R.string.important_notice_scope_warning)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + }.show() + } + + setupDialog() + } + + private fun areInputsValid(): Boolean { + val clientId = etClientId.text.trim().toString() + val clientSecret = etClientSecret.text.trim().toString() + val redirectUri = etRedirectUri.text.trim().toString() + + var isValid = true + + // Validate Client ID + if (client == null) { + if (!GoogleClientValidator.isValidClientId(clientId)) { + tfClientId.isErrorEnabled = true + tfClientId.error = "Invalid Client ID. Use the one provided by Google." + isValid = false + } else { + tfClientId.isErrorEnabled = false + tfClientId.error = null + } + } + + // Validate Client Secret + if (clientSecret.isBlank()) { + tfClientSecret.isErrorEnabled = true + tfClientSecret.error = "Client Secret is required." + isValid = false + } else { + tfClientSecret.isErrorEnabled = false + tfClientSecret.error = null + } + + // Validate Redirect URI + if (!UrlValidator.startsWithHttpOrHttps(redirectUri)) { + tfRedirectUri.isErrorEnabled = true + tfRedirectUri.error = "Invalid URI. It must start with 'http' or 'https'." + isValid = false + } else { + tfRedirectUri.isErrorEnabled = false + tfRedirectUri.error = null + } + + return isValid + } + + private fun setupDialog() { + if (client != null) { + title.text = context.getString(R.string.edit_client) + etClientId.setText(client.id) + etClientId.isEnabled = false + btnClientInfo.isVisible = true + btnClientInfo.setOnClickListener { + MaterialAlertDialogBuilder(context) + .setTitle(context.getString(R.string.important_notice)) + .setMessage(context.getString(R.string.important_notice_client_id_warning)) + .setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + }.show() + } + etClientSecret.setText(client.secret) + etRedirectUri.setText(client.redirectUri) + submitButton.text = context.getString(R.string.done) + } else { + btnClientInfo.isGone = true + title.text = context.getString(R.string.add_new_client) + submitButton.text = context.getString(R.string.submit) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/clients/ClientsFragment.kt b/app/src/main/java/zechs/drive/stream/ui/clients/ClientsFragment.kt new file mode 100644 index 0000000..136f43f --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/clients/ClientsFragment.kt @@ -0,0 +1,172 @@ +package zechs.drive.stream.ui.clients + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch +import zechs.drive.stream.R +import zechs.drive.stream.data.model.Client +import zechs.drive.stream.databinding.FragmentClientsBinding +import zechs.drive.stream.ui.BaseFragment +import zechs.drive.stream.ui.add_client.DialogAddClient +import zechs.drive.stream.ui.clients.adapter.ClientsAdapter +import zechs.drive.stream.utils.ext.navigateSafe + + +class ClientsFragment : BaseFragment() { + + companion object { + const val TAG = "ClientsFragment" + } + + private var _binding: FragmentClientsBinding? = null + private val binding get() = _binding!! + + private val viewModel by activityViewModels() + private val args by navArgs() + + private val clientsAdapter by lazy { + ClientsAdapter( + onClickListener = { client -> + navigateToLogin(client) + }, + onMenuClickListener = { view, client -> + showClientMenu(view, client) + }) + } + + private fun showClientMenu(view: View, client: Client) { + PopupMenu(requireContext(), view).apply { + inflate(R.menu.client_menu) + setOnMenuItemClickListener { menuItem -> + when (menuItem?.itemId) { + R.id.client_action_edit -> { + showEditDialog(client) + return@setOnMenuItemClickListener true + } + + R.id.client_action_delete -> { + handleClientDelete(client) + return@setOnMenuItemClickListener true + } + } + return@setOnMenuItemClickListener false + } + }.also { it.show() } + } + + private fun showEditDialog(client: Client) { + showClientDialog(client, onSubmitClickListener = { viewModel.updateClient(it) }) + } + + private fun showAddDialog() { + showClientDialog(null, onSubmitClickListener = { viewModel.addClient(it) }) + } + + private fun showClientDialog(client: Client?, onSubmitClickListener: (Client) -> Unit) { + val editDialog = DialogAddClient( + requireContext(), + client = client, + onSubmitClickListener = onSubmitClickListener + ) + editDialog.also { + it.show() + it.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + } + + private fun handleClientDelete(client: Client) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.confirm_delete_client_title)) + .setMessage(getString(R.string.confirm_delete_client_warning)) + .setNegativeButton(getString(R.string.no)) { dialog, _ -> + dialog.dismiss() + } + .setPositiveButton(getString(R.string.yes)) { dialog, _ -> + viewModel.deleteClient(client) + dialog.dismiss() + Log.d(TAG, "Deleting client ${client.id} along with all associated accounts.") + } + .show() + } + + + private fun navigateToLogin(client: Client) { + val nickname = args.nickname + val action = ClientsFragmentDirections.actionClientsFragmentToLoginFragment( + nickname, + client.id, client.secret, client.redirectUri + ) + findNavController().navigateSafe(action) + Log.d(TAG, "navigateToLogin(nickname=$nickname, clientId=${client.id})") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentClientsBinding.inflate( + inflater, container, /* attachToParent */false + ) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentClientsBinding.bind(view) + + setupRecyclerView() + setupClientsObserver() + setupAddClientFab() + } + + private fun setupAddClientFab() { + binding.btnAddClient.setOnClickListener { showAddDialog() } + } + + private fun setupClientsObserver() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.clients.collect { clients -> + clientsAdapter.submitList(clients) + } + } + } + } + + private fun setupRecyclerView() { + val linearLayoutManager = LinearLayoutManager( + /* context */ context, + /* orientation */ RecyclerView.VERTICAL, + /* reverseLayout */ false + ) + binding.rvList.apply { + adapter = clientsAdapter + layoutManager = linearLayoutManager + } + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/clients/ClientsViewModel.kt b/app/src/main/java/zechs/drive/stream/ui/clients/ClientsViewModel.kt new file mode 100644 index 0000000..81c208f --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/clients/ClientsViewModel.kt @@ -0,0 +1,30 @@ +package zechs.drive.stream.ui.clients + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import zechs.drive.stream.data.local.AccountsDao +import zechs.drive.stream.data.model.Client +import javax.inject.Inject + +@HiltViewModel +class ClientsViewModel @Inject constructor( + private val accountsManager: AccountsDao +) : ViewModel() { + + val clients = accountsManager.getClients() + + fun addClient(client : Client) = viewModelScope.launch(Dispatchers.IO) { + accountsManager.addClient(client) + } + fun updateClient(updated: Client) = viewModelScope.launch(Dispatchers.IO) { + accountsManager.updateClient(updated.id, updated.secret, updated.redirectUri) + } + + fun deleteClient(client: Client) = viewModelScope.launch(Dispatchers.IO) { + accountsManager.deleteClient(client.id) + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsAdapter.kt b/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsAdapter.kt new file mode 100644 index 0000000..c53f177 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsAdapter.kt @@ -0,0 +1,35 @@ +package zechs.drive.stream.ui.clients.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import zechs.drive.stream.R +import zechs.drive.stream.data.model.Client +import zechs.drive.stream.databinding.ItemClientBinding + +class ClientsAdapter( + val onClickListener: (Client) -> Unit, + val onMenuClickListener: (View, Client) -> Unit +) : ListAdapter(ClientsItemDiffCallback()) { + + override fun onCreateViewHolder( + parent: ViewGroup, viewType: Int + ) = ClientsViewHolder( + itemBinding = ItemClientBinding.inflate( + LayoutInflater.from(parent.context), + parent, false + ), + clientsAdapter = this + ) + + override fun onBindViewHolder(holder: ClientsViewHolder, position: Int) { + val item = getItem(position) + return holder.bind(item) + } + + override fun getItemViewType( + position: Int + ) = R.layout.item_client + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsItemDiffCallback.kt b/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsItemDiffCallback.kt new file mode 100644 index 0000000..61b64b6 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsItemDiffCallback.kt @@ -0,0 +1,17 @@ +package zechs.drive.stream.ui.clients.adapter + +import androidx.recyclerview.widget.DiffUtil +import zechs.drive.stream.data.model.Client + +class ClientsItemDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: Client, + newItem: Client + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: Client, newItem: Client + ) = oldItem == newItem + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsViewHolder.kt b/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsViewHolder.kt new file mode 100644 index 0000000..b02607c --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/clients/adapter/ClientsViewHolder.kt @@ -0,0 +1,24 @@ +package zechs.drive.stream.ui.clients.adapter + +import androidx.recyclerview.widget.RecyclerView +import zechs.drive.stream.data.model.Client +import zechs.drive.stream.databinding.ItemClientBinding + +class ClientsViewHolder( + private val itemBinding: ItemClientBinding, + val clientsAdapter: ClientsAdapter +) : RecyclerView.ViewHolder(itemBinding.root) { + + fun bind(client: Client) { + itemBinding.apply { + textView.text = client.id + root.setOnClickListener { + clientsAdapter.onClickListener.invoke(client) + } + btnMenu.setOnClickListener { + clientsAdapter.onMenuClickListener.invoke(btnMenu, client) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/code/DialogCode.kt b/app/src/main/java/zechs/drive/stream/ui/code/DialogCode.kt deleted file mode 100644 index 27e99a2..0000000 --- a/app/src/main/java/zechs/drive/stream/ui/code/DialogCode.kt +++ /dev/null @@ -1,60 +0,0 @@ -package zechs.drive.stream.ui.code - -import android.app.Dialog -import android.content.Context -import android.os.Bundle -import android.view.View -import android.view.Window -import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE -import android.widget.Toast -import androidx.constraintlayout.widget.ConstraintLayout -import com.google.android.material.button.MaterialButton -import com.google.android.material.textfield.TextInputLayout -import zechs.drive.stream.R -import zechs.drive.stream.utils.util.Keyboard - -class DialogCode( - context: Context, - val onSubmitClickListener: (String) -> Unit -) : Dialog(context) { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - requestWindowFeature(Window.FEATURE_NO_TITLE) - setContentView(R.layout.dialog_code) - window?.setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_VISIBLE) - - val root = findViewById(R.id.root) - val codeText = findViewById(R.id.tf_code) - val submitButton = findViewById(R.id.btn_submit) - - submitButton.setOnClickListener { - val authCode = codeText.editText!!.text.toString() - - if (authCode.isEmpty()) { - showToast(context.getString(R.string.please_enter_auth_url)) - } else { - onSubmitClickListener.invoke(authCode) - } - - } - - codeText.editText!!.requestFocus() - - codeText.editText!!.onFocusChangeListener = - View.OnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - Keyboard.show(v) - } else { - Keyboard.hide(root) - } - } - } - - private fun showToast(msg: String) { - Toast.makeText( - context, msg, Toast.LENGTH_SHORT - ).show() - } - -} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/edit_account/DialogEditAccount.kt b/app/src/main/java/zechs/drive/stream/ui/edit_account/DialogEditAccount.kt new file mode 100644 index 0000000..256a5a8 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/edit_account/DialogEditAccount.kt @@ -0,0 +1,55 @@ +package zechs.drive.stream.ui.edit_account + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.text.Editable +import android.util.Log +import android.view.Window +import android.widget.Toast +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.textview.MaterialTextView +import zechs.drive.stream.R + +class DialogEditAccount( + context: Context, + val name: String, + val onUpdateClickListener: (String) -> Unit +) : Dialog(context) { + + companion object { + const val TAG = "DialogEditAccount" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestWindowFeature(Window.FEATURE_NO_TITLE) + // Can reuse this same layout from DialogAddAccount + setContentView(R.layout.dialog_new_account) + + val title = findViewById(R.id.tv_title) + val etNickname = findViewById(R.id.tf_nickname).editText!! + val nextButton = findViewById(R.id.btn_next) + + etNickname.text = Editable.Factory.getInstance().newEditable(name) + nextButton.text = context.getString(R.string.update) + title.text = context.getString(R.string.edit_nickname) + + nextButton.setOnClickListener { + if (etNickname.text.toString().isEmpty()) { + Toast.makeText( + context, + context.getString(R.string.nickname_cannot_be_empty), + Toast.LENGTH_SHORT + ).show() + } else if (etNickname.text.toString() == name) { + Log.d(TAG, "Ignoring update, nickname is the same") + } else { + onUpdateClickListener.invoke(etNickname.text.toString()) + dismiss() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/home/HomeFragment.kt b/app/src/main/java/zechs/drive/stream/ui/home/HomeFragment.kt index 8273462..e1d3802 100644 --- a/app/src/main/java/zechs/drive/stream/ui/home/HomeFragment.kt +++ b/app/src/main/java/zechs/drive/stream/ui/home/HomeFragment.kt @@ -1,17 +1,22 @@ package zechs.drive.stream.ui.home +import android.content.Context import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.core.view.isGone import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.button.MaterialButton -import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.delay import kotlinx.coroutines.launch import zechs.drive.stream.R @@ -91,6 +96,7 @@ class HomeFragment : BaseFragment() { setupToolbar() observeLogOutState() + observeAccountName() } private fun navigateToFiles( @@ -109,18 +115,8 @@ class HomeFragment : BaseFragment() { private fun setupToolbar() { binding.toolbar.setOnMenuItemClickListener { item -> when (item.itemId) { - R.id.action_logOut -> { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.log_out_dialog_title)) - .setNegativeButton(getString(R.string.no)) { dialog, _ -> - dialog.dismiss() - } - .setPositiveButton(getString(R.string.yes)) { dialog, _ -> - dialog.dismiss() - Log.d(TAG, "Logging out...") - viewModel.logOut() - } - .show() + R.id.action_account_switch -> { + findNavController().navigateSafe(R.id.action_homeFragment_to_profileFragment) return@setOnMenuItemClickListener true } @@ -146,9 +142,44 @@ class HomeFragment : BaseFragment() { } } - override fun onDestroyView() { - super.onDestroyView() + private fun observeAccountName() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.selectedAccount.collect { selectedAccount -> + binding.tvAccountName.isGone = selectedAccount.isNullOrBlank() + binding.tvAccountName.text = selectedAccount + } + } + } + } + + override fun onDestroy() { + super.onDestroy() _binding = null } + private var backPressedOnce = false + + override fun onAttach(context: Context) { + super.onAttach(context) + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (backPressedOnce) { + requireActivity().finish() + } else { + backPressedOnce = true + Toast.makeText( + context, + getString(R.string.press_back_again_to_exit), + Toast.LENGTH_SHORT + ).show() + Handler(Looper.getMainLooper()).postDelayed({ + backPressedOnce = false + }, 2000) + } + } + } + + requireActivity().onBackPressedDispatcher.addCallback(this, callback) + } } \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/home/HomeViewModel.kt b/app/src/main/java/zechs/drive/stream/ui/home/HomeViewModel.kt index 994ffc2..574998b 100644 --- a/app/src/main/java/zechs/drive/stream/ui/home/HomeViewModel.kt +++ b/app/src/main/java/zechs/drive/stream/ui/home/HomeViewModel.kt @@ -25,4 +25,5 @@ class HomeViewModel @Inject constructor( _hasLoggedOut.value = true } + val selectedAccount = sessionManager.get().flowSelectedAccountName() } diff --git a/app/src/main/java/zechs/drive/stream/ui/login/LoginFragment.kt b/app/src/main/java/zechs/drive/stream/ui/login/LoginFragment.kt new file mode 100644 index 0000000..6f8d11f --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/login/LoginFragment.kt @@ -0,0 +1,113 @@ +package zechs.drive.stream.ui.login + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.snackbar.Snackbar +import zechs.drive.stream.R +import zechs.drive.stream.data.model.DriveClient +import zechs.drive.stream.databinding.FragmentLoginBinding +import zechs.drive.stream.ui.BaseFragment +import zechs.drive.stream.utils.state.Resource + +class LoginFragment : BaseFragment() { + + companion object { + const val TAG = "LoginFragment" + } + + private var _binding: FragmentLoginBinding? = null + private val binding get() = _binding!! + + private val viewModel by activityViewModels() + private val args by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLoginBinding.inflate( + inflater, container, /* attachToParent */false + ) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentLoginBinding.bind(view) + + binding.tvClientId.text = getString(R.string.client_id_used, args.clientId) + + binding.btnLaunch.setOnClickListener { + val driveClient = DriveClient( + clientId = args.clientId, + clientSecret = args.clientSecret, + redirectUri = args.redirectUri, + scopes = listOf("https://www.googleapis.com/auth/drive") + ) + Log.d(TAG, "Auth url: ${driveClient.authUrl()}") + + Intent().setAction(Intent.ACTION_VIEW) + .setData(driveClient.authUrl()) + .also { startActivity(it) } + } + + binding.btnLogin.setOnClickListener { + val authUrl = binding.tfClientSecret.editText!!.text.toString() + if (authUrl.isEmpty()) { + showSnackbar(getString(R.string.empty_auth_url_message)) + } else { + viewModel.addAccount( + args.nickname, + args.clientId, args.clientSecret, + args.redirectUri, authUrl + ) + } + } + + setupLoginObserver() + } + + private fun setupLoginObserver() { + viewModel.loginStatus.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { response -> + when (response) { + is Resource.Error -> { + showSnackbar(response.message) + isLoading(false) + } + + is Resource.Loading -> { + isLoading(true) + } + + is Resource.Success -> { + findNavController().navigate(R.id.action_loginFragment_to_profileFragment) + } + } + } + } + } + + private fun isLoading(loading: Boolean) { + binding.progressBar.isGone = !loading + binding.actions.isGone = loading + } + + private fun showSnackbar(message: String?) { + Snackbar.make( + binding.root, + message ?: getString(R.string.something_went_wrong), + Snackbar.LENGTH_LONG + ).show() + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/login/LoginViewModel.kt b/app/src/main/java/zechs/drive/stream/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..bdafd65 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/login/LoginViewModel.kt @@ -0,0 +1,61 @@ +package zechs.drive.stream.ui.login + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import zechs.drive.stream.data.local.AccountsDao +import zechs.drive.stream.data.model.Account +import zechs.drive.stream.data.model.AuthorizationResponse +import zechs.drive.stream.data.model.DriveClient +import zechs.drive.stream.data.repository.DriveRepository +import zechs.drive.stream.utils.Event +import zechs.drive.stream.utils.state.Resource +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val accountsManager: AccountsDao, + private val driveRepository: DriveRepository, + private val gson: Gson +) : ViewModel() { + + private val _loginStatus = MutableLiveData>>() + val loginStatus: LiveData>> + get() = _loginStatus + + fun addAccount( + name: String, + clientId: String, + clientSecret: String, + redirectUri: String, + authCodeUri: String + ) = viewModelScope.launch(Dispatchers.IO) { + val client = DriveClient( + clientId = clientId, + clientSecret = clientSecret, + redirectUri = redirectUri, + scopes = listOf("https://www.googleapis.com/auth/drive") + ) + _loginStatus.postValue(Event(Resource.Loading())) + val authCode = Uri.parse(authCodeUri).getQueryParameter("code") + if (authCode == null) { + _loginStatus.postValue(Event(Resource.Error("Authorization code not found, please check url"))) + } else { + val response = driveRepository.fetchRefreshToken(client, authCode) + if (response is Resource.Success) { + val data = response.data!! + accountsManager.addAccount( + Account(name, data.refreshToken, gson.toJson(data.toTokenResponse()), clientId) + ) + } + _loginStatus.postValue(Event(response)) + } + } +} + diff --git a/app/src/main/java/zechs/drive/stream/ui/main/MainActivity.kt b/app/src/main/java/zechs/drive/stream/ui/main/MainActivity.kt index 39d8ad3..c334a59 100644 --- a/app/src/main/java/zechs/drive/stream/ui/main/MainActivity.kt +++ b/app/src/main/java/zechs/drive/stream/ui/main/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController +import androidx.navigation.NavGraph import androidx.navigation.fragment.NavHostFragment import com.google.android.gms.ads.AdRequest import com.google.android.gms.ads.AdSize @@ -37,12 +38,11 @@ import zechs.drive.stream.data.model.LatestRelease import zechs.drive.stream.databinding.ActivityMainBinding import zechs.drive.stream.utils.AdUnits import zechs.drive.stream.utils.AppTheme -import zechs.drive.stream.utils.ext.navigateSafe import zechs.drive.stream.utils.state.Resource import zechs.drive.stream.utils.util.NotificationKeys.Companion.UPDATE_CHANNEL_CODE import zechs.drive.stream.utils.util.NotificationKeys.Companion.UPDATE_CHANNEL_ID import zechs.drive.stream.utils.util.NotificationKeys.Companion.UPDATE_CHANNEL_NAME -import java.util.* +import java.util.Random @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -115,18 +115,19 @@ class MainActivity : AppCompatActivity() { private fun redirectOnLogin() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.hasLoggedIn.collect { hasLoggedIn -> - Log.d(TAG, "hasLoggedIn=${hasLoggedIn}") - if (hasLoggedIn) handleLogin() + viewModel.hasDefault.collect { hasDefault -> + Log.d(TAG, "hasDefault=${hasDefault}") + if (hasDefault) handleDefault() } } } } - private fun handleLogin() { + private fun handleDefault() { val currentFragment = navController.currentDestination?.id - if (currentFragment != null && currentFragment == R.id.signInFragment) { - navController.navigateSafe(R.id.action_signInFragment_to_homeFragment) + if (currentFragment != null && currentFragment == R.id.profileFragment && !viewModel.hasTransitionedToDefault) { + navController.navigate(R.id.action_profileFragment_to_homeFragment) + viewModel.hasTransitionedToDefault = true } } diff --git a/app/src/main/java/zechs/drive/stream/ui/main/MainViewModel.kt b/app/src/main/java/zechs/drive/stream/ui/main/MainViewModel.kt index 357a3eb..a0e9f07 100755 --- a/app/src/main/java/zechs/drive/stream/ui/main/MainViewModel.kt +++ b/app/src/main/java/zechs/drive/stream/ui/main/MainViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import zechs.drive.stream.data.local.AccountsDao import zechs.drive.stream.data.model.LatestRelease import zechs.drive.stream.data.repository.GithubRepository import zechs.drive.stream.utils.AppSettings @@ -28,13 +29,14 @@ class MainViewModel @Inject constructor( private val githubRepository: GithubRepository, private val appSettings: AppSettings, private val firstRunProfileMigrator: FirstRunProfileMigrator, + private val accountsManager: AccountsDao ) : ViewModel() { private val _isLoading = MutableStateFlow(true) val isLoading = _isLoading.asStateFlow() - private val _hasLoggedIn = MutableStateFlow(false) - val hasLoggedIn = _hasLoggedIn.asStateFlow() + private val _hasDefault = MutableStateFlow(false) + val hasDefault = _hasDefault.asStateFlow() private val _latest = MutableLiveData>() val latest: LiveData> @@ -58,30 +60,33 @@ class MainViewModel @Inject constructor( var adsEnabled = false private set + var hasTransitionedToDefault = false + init { viewModelScope.launch(Dispatchers.IO) { firstRunProfileMigrator.migrateSessionToAccountsTable() getTheme() getEnableAds() - val status = getLoginStatus() - if (status) { - getPlayer() - getLastUpdated() - } - _hasLoggedIn.value = status + getDefaultAccount() + getPlayer() + getLastUpdated() delay(250L) _isLoading.value = false } getLatestRelease() // check for update } - private suspend fun getLoginStatus(): Boolean { - sessionManager.fetchClient() ?: return false - sessionManager.fetchRefreshToken() ?: return false - return true + private suspend fun getDefaultAccount() { + val default = sessionManager.fetchDefault() ?: return + accountsManager.getAccount(default)?.let { account -> + sessionManager.saveClient(account.getDriveClient()) + sessionManager.saveRefreshToken(account.refreshToken) + sessionManager.saveAccessToken(account.getAccessTokenResponse()) + _hasDefault.value = true + } } - fun getLatestRelease() = viewModelScope.launch { + fun getLatestRelease() = viewModelScope.launch(Dispatchers.IO) { _latest.postValue(Resource.Loading()) isChecking = true _latest.postValue(githubRepository.getLatestRelease()) diff --git a/app/src/main/java/zechs/drive/stream/ui/profile/ProfileFragment.kt b/app/src/main/java/zechs/drive/stream/ui/profile/ProfileFragment.kt new file mode 100644 index 0000000..91de462 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/profile/ProfileFragment.kt @@ -0,0 +1,239 @@ +package zechs.drive.stream.ui.profile + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import zechs.drive.stream.R +import zechs.drive.stream.data.model.AccountWithClient +import zechs.drive.stream.databinding.FragmentProfileBinding +import zechs.drive.stream.ui.BaseFragment +import zechs.drive.stream.ui.add_account.DialogAddAccount +import zechs.drive.stream.ui.edit_account.DialogEditAccount +import zechs.drive.stream.ui.profile.ProfileViewModel.AccountUpdateState +import zechs.drive.stream.ui.profile.ProfileViewModel.AccountValidationState +import zechs.drive.stream.ui.profile.adapter.AccountsAdapter +import zechs.drive.stream.utils.ext.navigateSafe + + +class ProfileFragment : BaseFragment() { + + companion object { + const val TAG = "ProfileFragment" + } + + private var _binding: FragmentProfileBinding? = null + private val binding get() = _binding!! + + private val viewModel by activityViewModels() + + private val accountsAdapter by lazy { + AccountsAdapter( + onClickListener = { switchAccount(it) }, + onMenuClickListener = { view, account -> + showAccountMenu(view, account) + } + ) + } + + private fun showAccountMenu(view: View, account: AccountWithClient) { + PopupMenu(requireContext(), view).apply { + inflate(R.menu.profile_menu) + menu.findItem(R.id.action_set_as_default).isVisible = !account.isDefault + setOnMenuItemClickListener { menuItem -> + when (menuItem?.itemId) { + R.id.action_set_as_default -> { + handleSetDefault(account) + return@setOnMenuItemClickListener true + } + + R.id.action_rename -> { + showEditDialog(account) + return@setOnMenuItemClickListener true + } + + R.id.action_delete -> { + handleDeleteAccount(account) + return@setOnMenuItemClickListener true + } + } + return@setOnMenuItemClickListener false + } + }.also { it.show() } + } + + private fun showEditDialog(account: AccountWithClient) { + val editDialog = DialogEditAccount( + requireContext(), + name = account.name, + onUpdateClickListener = { newName -> + viewModel.updateAccountName(account.name, newName) + } + ) + editDialog.also { + it.show() + it.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + } + + private fun handleDeleteAccount(account: AccountWithClient) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.confirm_delete_account_title)) + .setMessage(getString(R.string.confirm_delete_account_warning)) + .setNegativeButton(getString(R.string.no)) { dialog, _ -> + dialog.dismiss() + } + .setNeutralButton(getString(R.string.just_delete)) { dialog, _ -> + viewModel.deleteAccount(account, revoke = false) + dialog.dismiss() + Log.d(TAG, "Deleting account $account") + } + .setPositiveButton(getString(R.string.yes)) { dialog, _ -> + viewModel.deleteAccount(account, revoke = true) + dialog.dismiss() + Log.d(TAG, "Deleting account $account") + } + .show() + } + + private fun handleSetDefault(account: AccountWithClient) { + viewModel.markDefault(account) + viewModel.selectAccount(account) + findNavController().navigate(R.id.action_profileFragment_to_homeFragment) + } + + private fun switchAccount(account: AccountWithClient) { + viewModel.selectAccount(account) + findNavController().navigate(R.id.action_profileFragment_to_homeFragment) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentProfileBinding.inflate( + inflater, container, /* attachToParent */false + ) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentProfileBinding.bind(view) + + binding.btnAddAccount.setOnClickListener { + showNewAccountDialog() + } + + newAccountObserver() + accountUpdateObserver() + setupRecyclerView() + setupAccountsObserver() + } + + private fun showNewAccountDialog() { + val addDialog = DialogAddAccount( + requireContext(), + onNextClickListener = { name -> + viewModel.validateAccountName(name) + } + ) + addDialog.also { + it.show() + it.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + } + + private fun setupAccountsObserver() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.accounts.collect { accounts -> + accountsAdapter.submitList(accounts) + } + } + } + } + + private fun newAccountObserver() { + viewModel.accountName.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { validation -> + Log.d(TAG, "newAccountObserver: $validation") + when (validation) { + AccountValidationState.Conflict -> { + Snackbar.make( + binding.root, + getString(R.string.account_already_exists), + Snackbar.LENGTH_SHORT + ).show() + } + + is AccountValidationState.Valid -> { + // Navigate to next step with the nickname + navigateToClients(validation.name) + } + } + } + } + } + + private fun navigateToClients(nickname: String) { + val action = ProfileFragmentDirections.actionProfileFragmentToClientsFragment(nickname) + findNavController().navigateSafe(action) + Log.d(TAG, "navigateToClients(nickname=$nickname)") + } + + private fun accountUpdateObserver() { + viewModel.accountUpdate.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { validation -> + Log.d(TAG, "accountUpdateObserver: $validation") + when (validation) { + AccountUpdateState.Conflict -> { + Snackbar.make( + binding.root, + getString(R.string.account_already_exists), + Snackbar.LENGTH_SHORT + ).show() + } + + is AccountUpdateState.Updated -> { + Log.d(TAG, "accountUpdateObserver: updated") + } + } + } + } + } + + private fun setupRecyclerView() { + val linearLayoutManager = LinearLayoutManager( + /* context */ context, + /* orientation */ RecyclerView.VERTICAL, + /* reverseLayout */ false + ) + binding.rvList.apply { + adapter = accountsAdapter + layoutManager = linearLayoutManager + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/profile/ProfileViewModel.kt b/app/src/main/java/zechs/drive/stream/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..c19e591 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/profile/ProfileViewModel.kt @@ -0,0 +1,119 @@ +package zechs.drive.stream.ui.profile + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import zechs.drive.stream.data.local.AccountsDao +import zechs.drive.stream.data.model.AccountWithClient +import zechs.drive.stream.data.model.TokenRequestBody +import zechs.drive.stream.data.remote.RevokeTokenApi +import zechs.drive.stream.ui.profile.ProfileFragment.Companion.TAG +import zechs.drive.stream.utils.Event +import zechs.drive.stream.utils.SessionManager +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val sessionManager: SessionManager, + private val accountsManager: AccountsDao, + private val revokeTokenApi: RevokeTokenApi +) : ViewModel() { + + val accounts = accountsManager + .getAccounts() + .map { accounts -> + val default = sessionManager.fetchDefault() + accounts.map { account -> + account.isDefault = account.name == default + account + } + } + + fun selectAccount(account: AccountWithClient) = viewModelScope.launch(Dispatchers.IO) { + sessionManager.saveSelectedAccountName(account.name) + sessionManager.saveClient(account.getDriveClient()) + sessionManager.saveRefreshToken(account.refreshToken) + sessionManager.saveAccessToken(account.getAccessTokenResponse()) + } + + fun markDefault(account: AccountWithClient) = viewModelScope.launch(Dispatchers.IO) { + sessionManager.saveDefault(account.name) + } + + fun deleteAccount( + account: AccountWithClient, + revoke: Boolean + ) = viewModelScope.launch(Dispatchers.IO) { + if (account.isDefault) { + sessionManager.saveDefault(null) + } + if (account.refreshToken == sessionManager.fetchRefreshToken()) { + if (revoke) { + revokeTokenApi.revokeToken(TokenRequestBody(account.refreshToken)) + } + Log.d(TAG, "${if (revoke) "" else "Not "}Revoking token") + val default = sessionManager.fetchDefault() + sessionManager.resetDataStore() + if (default != null) { + accountsManager.getAccount(default) + ?.let { account -> + selectAccount(account) + } + } + } + accountsManager.deleteAccount(account.name) + } + + sealed interface AccountValidationState { + object Conflict : AccountValidationState + data class Valid(val name: String) : AccountValidationState + } + + private val _accountName = MutableLiveData>() + val accountName: LiveData> + get() = _accountName + + fun validateAccountName(name: String) = viewModelScope.launch(Dispatchers.IO) { + val doesExist = accountsManager.getAccount(name) != null + Log.d(TAG, "validateAccountName: $doesExist") + if (doesExist) { + _accountName.postValue(Event(AccountValidationState.Conflict)) + } else { + _accountName.postValue(Event(AccountValidationState.Valid(name))) + } + } + + sealed interface AccountUpdateState { + object Conflict : AccountUpdateState + object Updated : AccountUpdateState + } + + private val _accountUpdate = MutableLiveData>() + val accountUpdate: LiveData> + get() = _accountUpdate + + fun updateAccountName( + oldName: String, + newName: String + ) = viewModelScope.launch(Dispatchers.IO) { + val doesExist = accountsManager.getAccount(newName) != null + Log.d(TAG, "validateAccountName: $doesExist") + if (doesExist) { + _accountUpdate.postValue(Event(AccountUpdateState.Conflict)) + } else { + val isDefault = sessionManager.fetchDefault() == oldName + if (isDefault) { + sessionManager.saveDefault(newName) + } + accountsManager.updateAccountName(oldName, newName) + _accountUpdate.postValue(Event(AccountUpdateState.Updated)) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountItemDiffCallback.kt b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountItemDiffCallback.kt index 8e56d53..5f2310f 100644 --- a/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountItemDiffCallback.kt +++ b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountItemDiffCallback.kt @@ -1,17 +1,17 @@ package zechs.drive.stream.ui.profile.adapter import androidx.recyclerview.widget.DiffUtil -import zechs.drive.stream.data.model.Account +import zechs.drive.stream.data.model.AccountWithClient -class AccountItemDiffCallback : DiffUtil.ItemCallback() { +class AccountItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: Account, - newItem: Account + oldItem: AccountWithClient, + newItem: AccountWithClient ): Boolean = oldItem.name == newItem.name override fun areContentsTheSame( - oldItem: Account, newItem: Account + oldItem: AccountWithClient, newItem: AccountWithClient ) = oldItem == newItem } \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsAdapter.kt b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsAdapter.kt index 3951b23..a6c6efe 100644 --- a/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsAdapter.kt +++ b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsAdapter.kt @@ -5,13 +5,13 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.ListAdapter import zechs.drive.stream.R -import zechs.drive.stream.data.model.Account +import zechs.drive.stream.data.model.AccountWithClient import zechs.drive.stream.databinding.ItemTextBinding class AccountsAdapter( - val onClickListener: (Account) -> Unit, - val onMenuClickListener: (View, Account) -> Unit -) : ListAdapter(AccountItemDiffCallback()) { + val onClickListener: (AccountWithClient) -> Unit, + val onMenuClickListener: (View, AccountWithClient) -> Unit +) : ListAdapter(AccountItemDiffCallback()) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int @@ -26,7 +26,6 @@ class AccountsAdapter( override fun onBindViewHolder(holder: AccountsViewHolder, position: Int) { val item = getItem(position) return holder.bind(item) - } override fun getItemViewType( diff --git a/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsViewHolder.kt b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsViewHolder.kt index 2e34c2a..c523d9d 100644 --- a/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsViewHolder.kt +++ b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsViewHolder.kt @@ -3,6 +3,7 @@ package zechs.drive.stream.ui.profile.adapter import androidx.core.view.isGone import androidx.recyclerview.widget.RecyclerView import zechs.drive.stream.data.model.Account +import zechs.drive.stream.data.model.AccountWithClient import zechs.drive.stream.databinding.ItemTextBinding class AccountsViewHolder( @@ -10,7 +11,7 @@ class AccountsViewHolder( val accountsAdapter: AccountsAdapter ) : RecyclerView.ViewHolder(itemBinding.root) { - fun bind(account: Account) { + fun bind(account: AccountWithClient) { itemBinding.apply { textView.text = account.name root.setOnClickListener { diff --git a/app/src/main/java/zechs/drive/stream/ui/signin/SignInFragment.kt b/app/src/main/java/zechs/drive/stream/ui/signin/SignInFragment.kt deleted file mode 100644 index 77e90ee..0000000 --- a/app/src/main/java/zechs/drive/stream/ui/signin/SignInFragment.kt +++ /dev/null @@ -1,170 +0,0 @@ -package zechs.drive.stream.ui.signin - -import android.content.Intent -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.net.Uri -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import zechs.drive.stream.R -import zechs.drive.stream.data.model.DriveClient -import zechs.drive.stream.databinding.FragmentSignInBinding -import zechs.drive.stream.ui.BaseFragment -import zechs.drive.stream.ui.code.DialogCode -import zechs.drive.stream.utils.ext.hideKeyboardWhenOffFocus -import zechs.drive.stream.utils.ext.navigateSafe -import zechs.drive.stream.utils.state.Resource -import zechs.drive.stream.utils.util.Constants.Companion.GUIDE_TO_MAKE_DRIVE_CLIENT - - -class SignInFragment : BaseFragment() { - - companion object { - const val TAG = "SignInFragment" - } - - private var _binding: FragmentSignInBinding? = null - private val binding get() = _binding!! - - private var _codeDialog: DialogCode? = null - private val codeDialog get() = _codeDialog!! - - private val viewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSignInBinding.inflate( - inflater, container, /* attachToParent */false - ) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - _binding = FragmentSignInBinding.bind(view) - - binding.signInText.setOnClickListener { - if (!updateClient()) { - return@setOnClickListener - } - Log.d(TAG, "Auth url: ${viewModel.client!!.authUrl()}") - - Intent().setAction(Intent.ACTION_VIEW) - .setData(viewModel.client!!.authUrl()) - .also { startActivity(it) } - } - - - binding.btnHelp.setOnClickListener { - Intent().setAction(Intent.ACTION_VIEW) - .setData(Uri.parse(GUIDE_TO_MAKE_DRIVE_CLIENT)) - .also { startActivity(it) } - } - - binding.enterCode.setOnClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Please note") - .setMessage(getString(R.string.important_note_message)) - .setPositiveButton("Continue") { dialog, _ -> - dialog.dismiss() - showCodeDialog() - }.show() - } - - binding.clientId.editText!!.hideKeyboardWhenOffFocus() - binding.clientSecret.editText!!.hideKeyboardWhenOffFocus() - binding.redirectUri.editText!!.hideKeyboardWhenOffFocus() - - loginObserver() - } - - private fun loginObserver() { - viewModel.loginStatus.observe(viewLifecycleOwner) { response -> - when (response) { - is Resource.Success -> { - findNavController().navigateSafe( - R.id.action_signInFragment_to_homeFragment - ) - } - is Resource.Error -> { - isLoading(false) - Snackbar.make( - binding.root, - response.message!!, - Snackbar.LENGTH_LONG - ).show() - } - is Resource.Loading -> isLoading(true) - } - } - } - - private fun showCodeDialog() { - if (_codeDialog == null) { - _codeDialog = DialogCode( - context = requireContext(), - onSubmitClickListener = { codeUri -> - viewModel.requestRefreshToken(codeUri) - codeDialog.dismiss() - } - ) - } - - codeDialog.also { - it.show() - it.window?.apply { - setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - setLayout(MATCH_PARENT, WRAP_CONTENT) - } - it.setOnDismissListener { - _codeDialog = null - } - } - } - - private fun isLoading(loading: Boolean) { - binding.apply { - this.loading.isVisible = loading - layoutConfigure.isVisible = !loading - enterCode.isVisible = !loading - } - } - - private fun updateClient(): Boolean { - val clientId = binding.clientId.editText!!.text.toString() - val clientSecret = binding.clientSecret.editText!!.text.toString() - val redirectUri = binding.redirectUri.editText!!.text.toString() - val scopes = binding.scopes.editText!!.text.toString() - if (clientId.isEmpty() - || clientSecret.isEmpty() - || redirectUri.isEmpty() - || scopes.isEmpty() - ) { - Snackbar.make( - binding.root, - getString(R.string.fill_all_fields), - Snackbar.LENGTH_LONG - ).show() - return false - } - - viewModel.client = DriveClient( - clientId, clientSecret, - redirectUri, listOf(scopes) - ) - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/ui/signin/SignInViewModel.kt b/app/src/main/java/zechs/drive/stream/ui/signin/SignInViewModel.kt deleted file mode 100644 index 044ff32..0000000 --- a/app/src/main/java/zechs/drive/stream/ui/signin/SignInViewModel.kt +++ /dev/null @@ -1,45 +0,0 @@ -package zechs.drive.stream.ui.signin - -import android.net.Uri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.Lazy -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import zechs.drive.stream.data.model.AuthorizationResponse -import zechs.drive.stream.data.model.DriveClient -import zechs.drive.stream.data.repository.DriveRepository -import zechs.drive.stream.utils.state.Resource -import javax.inject.Inject - -@HiltViewModel -class SignInViewModel @Inject constructor( - private val driveRepository: Lazy -) : ViewModel() { - - private val _loginStatus = MutableLiveData>() - val loginStatus: LiveData> - get() = _loginStatus - - var client: DriveClient? = null - - fun requestRefreshToken( - authCodeUri: String - ) = viewModelScope.launch { - _loginStatus.postValue(Resource.Loading()) - val authCode = Uri.parse(authCodeUri).getQueryParameter("code") - if (authCode == null) { - _loginStatus.postValue(Resource.Error("Authorization code not found, please check url")) - } else { - if (client == null) { - _loginStatus.postValue(Resource.Error("Client not found")) - return@launch - } - val response = driveRepository.get().fetchRefreshToken(client!!, authCode) - _loginStatus.postValue(response) - } - } - -} diff --git a/app/src/main/java/zechs/drive/stream/utils/FirstRunProfileMigrator.kt b/app/src/main/java/zechs/drive/stream/utils/FirstRunProfileMigrator.kt index 6a6098e..de745ea 100644 --- a/app/src/main/java/zechs/drive/stream/utils/FirstRunProfileMigrator.kt +++ b/app/src/main/java/zechs/drive/stream/utils/FirstRunProfileMigrator.kt @@ -2,8 +2,8 @@ package zechs.drive.stream.utils import android.content.Context import android.util.Log +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.google.gson.Gson import kotlinx.coroutines.flow.first @@ -23,34 +23,35 @@ class FirstRunProfileMigrator( private suspend fun markFirstRun() { if (!isFirstRun()) return - val dataStoreKey = stringPreferencesKey(IS_FIRST_RUN) + val dataStoreKey = booleanPreferencesKey(IS_FIRST_RUN) sessionStore.edit { settings -> - settings[dataStoreKey] = true.toString() + settings[dataStoreKey] = false } Log.d(TAG, "First run completed") } private suspend fun isFirstRun(): Boolean { - val dataStoreKey = stringPreferencesKey(IS_FIRST_RUN) + val dataStoreKey = booleanPreferencesKey(IS_FIRST_RUN) val preferences = sessionStore.data.first() - val value = preferences[dataStoreKey] ?: return true - return value.toBoolean() + val value = preferences[dataStoreKey] ?: true + Log.d(TAG, "isFirstRun: $value") + return value } suspend fun migrateSessionToAccountsTable() { - val client = sessionManager.fetchClient() - if (isFirstRun() && client != null) { + val driveClient = sessionManager.fetchClient() + if (isFirstRun() && driveClient != null) { val accessToken = sessionManager.fetchAccessToken() ?: return val refreshToken = sessionManager.fetchRefreshToken() ?: return val profileName = "DriveStream" + val client = driveClient.getClient() val account = Account( name = profileName, - clientId = client.clientId, - clientSecret = client.clientSecret, - redirectUri = client.redirectUri, + clientId = client.id, refreshToken = refreshToken, accessToken = gson.toJson(accessToken) ) + accountsManager.addClient(client) accountsManager.addAccount(account) sessionManager.saveDefault(profileName) Log.d(TAG, "migrateSessionToAccountsTable: $account") diff --git a/app/src/main/java/zechs/drive/stream/utils/SessionManager.kt b/app/src/main/java/zechs/drive/stream/utils/SessionManager.kt index 604c7a4..e8745ab 100644 --- a/app/src/main/java/zechs/drive/stream/utils/SessionManager.kt +++ b/app/src/main/java/zechs/drive/stream/utils/SessionManager.kt @@ -9,6 +9,7 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import zechs.drive.stream.data.local.AccountsDao import zechs.drive.stream.data.model.DriveClient import zechs.drive.stream.data.model.TokenResponse @@ -25,6 +26,21 @@ class SessionManager @Inject constructor( private val sessionStore = appContext.dataStore + suspend fun saveSelectedAccountName(nickname: String) { + val dataStoreKey = stringPreferencesKey(SELECTED_ACCOUNT) + sessionStore.edit { settings -> + settings[dataStoreKey] = nickname + } + Log.d(TAG, "saveSelectedAccountName: $nickname") + } + + fun flowSelectedAccountName() = sessionStore.data.map { preferences -> + val dataStoreKey = stringPreferencesKey(SELECTED_ACCOUNT) + val value = preferences[dataStoreKey] + Log.d(TAG, "fetchSelectedAccountName: $value") + value + } + suspend fun saveClient(client: DriveClient) { val dataStoreKey = stringPreferencesKey(DRIVE_CLIENT) sessionStore.edit { settings -> @@ -83,10 +99,14 @@ class SessionManager @Inject constructor( return value } - suspend fun saveDefault(profile: String) { + suspend fun saveDefault(profile: String?) { val dataStoreKey = stringPreferencesKey(DEFAULT_PROFILE) sessionStore.edit { settings -> - settings[dataStoreKey] = profile + if (profile == null) { + settings.remove(dataStoreKey) + } else { + settings[dataStoreKey] = profile + } } Log.d(TAG, "saveDefault: $profile") } @@ -112,6 +132,7 @@ class SessionManager @Inject constructor( const val ACCESS_TOKEN = "ACCESS_TOKEN" const val REFRESH_TOKEN = "REFRESH_TOKEN" const val DEFAULT_PROFILE = "DEFAULT_PROFILE" + const val SELECTED_ACCOUNT = "SELECTED_ACCOUNT" } } \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/utils/util/Constants.kt b/app/src/main/java/zechs/drive/stream/utils/util/Constants.kt index d4b104b..e5d5ae0 100644 --- a/app/src/main/java/zechs/drive/stream/utils/util/Constants.kt +++ b/app/src/main/java/zechs/drive/stream/utils/util/Constants.kt @@ -6,6 +6,7 @@ class Constants { const val GOOGLE_API = "https://www.googleapis.com" const val DRIVE_API = "${GOOGLE_API}/drive/v3" const val GOOGLE_ACCOUNTS_URL = "https://accounts.google.com" + const val GOOGLE_OAUTH_URL = "https://oauth2.googleapis.com" const val GUIDE_TO_MAKE_DRIVE_CLIENT = "https://rclone.org/drive/#making-your-own-client-id" } } \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/utils/util/GoogleClientValidator.kt b/app/src/main/java/zechs/drive/stream/utils/util/GoogleClientValidator.kt new file mode 100644 index 0000000..de095f6 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/utils/util/GoogleClientValidator.kt @@ -0,0 +1,10 @@ +package zechs.drive.stream.utils.util + +object GoogleClientValidator { + + fun isValidClientId(clientId: String?): Boolean { + if (clientId.isNullOrBlank()) return false + return clientId.endsWith(".apps.googleusercontent.com") + } + +} \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/utils/util/UrlValidator.kt b/app/src/main/java/zechs/drive/stream/utils/util/UrlValidator.kt new file mode 100644 index 0000000..6d93e3c --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/utils/util/UrlValidator.kt @@ -0,0 +1,9 @@ +package zechs.drive.stream.utils.util + +object UrlValidator { + + fun startsWithHttpOrHttps(url: String): Boolean { + return url.trim().matches(Regex("^(http|https)://.*$")) + } + +} \ No newline at end of file diff --git a/app/src/main/res/anim/dialog_enter_anim.xml b/app/src/main/res/anim/dialog_enter_anim.xml new file mode 100644 index 0000000..1d04ac4 --- /dev/null +++ b/app/src/main/res/anim/dialog_enter_anim.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/dialog_exit_anim.xml b/app/src/main/res/anim/dialog_exit_anim.xml new file mode 100644 index 0000000..1ea0f83 --- /dev/null +++ b/app/src/main/res/anim/dialog_exit_anim.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_24.xml b/app/src/main/res/drawable/ic_add_24.xml new file mode 100644 index 0000000..f09eb92 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_24.xml b/app/src/main/res/drawable/ic_info_24.xml new file mode 100644 index 0000000..e54df12 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_logout_24.xml b/app/src/main/res/drawable/ic_logout_24.xml deleted file mode 100644 index 7b5f586..0000000 --- a/app/src/main/res/drawable/ic_logout_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_person_add_24.xml b/app/src/main/res/drawable/ic_person_add_24.xml new file mode 100644 index 0000000..6bfaaea --- /dev/null +++ b/app/src/main/res/drawable/ic_person_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_profiles_24.xml b/app/src/main/res/drawable/ic_profiles_24.xml new file mode 100644 index 0000000..d4df1b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_profiles_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/dialog_client.xml b/app/src/main/res/layout/dialog_client.xml new file mode 100644 index 0000000..32ff1db --- /dev/null +++ b/app/src/main/res/layout/dialog_client.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_code.xml b/app/src/main/res/layout/dialog_new_account.xml similarity index 50% rename from app/src/main/res/layout/dialog_code.xml rename to app/src/main/res/layout/dialog_new_account.xml index 44c5047..5c8d172 100644 --- a/app/src/main/res/layout/dialog_code.xml +++ b/app/src/main/res/layout/dialog_new_account.xml @@ -1,92 +1,63 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_clients.xml b/app/src/main/res/layout/fragment_clients.xml new file mode 100644 index 0000000..5147493 --- /dev/null +++ b/app/src/main/res/layout/fragment_clients.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index ac841b5..810ddf8 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -68,6 +68,29 @@ app:icon="@drawable/ic_settings_24" /> + + diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml new file mode 100644 index 0000000..0dbef68 --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml new file mode 100644 index 0000000..048485d --- /dev/null +++ b/app/src/main/res/layout/fragment_profile.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_sign_in.xml b/app/src/main/res/layout/fragment_sign_in.xml deleted file mode 100644 index d3f72e5..0000000 --- a/app/src/main/res/layout/fragment_sign_in.xml +++ /dev/null @@ -1,180 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_client.xml b/app/src/main/res/layout/item_client.xml new file mode 100644 index 0000000..6470891 --- /dev/null +++ b/app/src/main/res/layout/item_client.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/menu/client_menu.xml b/app/src/main/res/menu/client_menu.xml new file mode 100644 index 0000000..47ac71d --- /dev/null +++ b/app/src/main/res/menu/client_menu.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index 718db52..f032fea 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -3,9 +3,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> \ No newline at end of file diff --git a/app/src/main/res/menu/profile_menu.xml b/app/src/main/res/menu/profile_menu.xml new file mode 100644 index 0000000..fa7603f --- /dev/null +++ b/app/src/main/res/menu/profile_menu.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 3a02aa0..9cb343a 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_graph.xml" - app:startDestination="@id/signInFragment"> + app:startDestination="@id/profileFragment"> - - - - + + android:label="SettingsFragment" + tools:layout="@layout/fragment_settings" /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5466297..468753e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,7 +34,6 @@ Chapter %1$d (%2$s) %1$s (%2$s) Video - Sign in Enter authorization code? Authorization url Authorization url @@ -72,4 +71,41 @@ Disable Ads Are you sure you want to disable ads?\n\nThis will remove all ads from the app. Ads help support the development of this app. Thank you for supporting the app! + Profiles + Add account + New account + Account nickname + Next + Please enter a nickname + Account by this name already exists + Switch account + Press back again to exit + Set as default + Rename + Delete + Are you certain you want to delete this account? + This action will revoke tokens and permanently delete all data associated with this account from the app. + Just delete + Edit nickname + Update + Nickname cannot be empty + Add new client + Are you sure you want to delete this client? + This will also delete all the accounts associated with this client. This action is irreversible. + Edit + Important Notice + This app requires critical drive access permission to function properly. Removing this permission will render the app non-functional. Please note that the required permission is: \'https://www.googleapis.com/auth/drive\'. Removal is not permitted. + Edit client + You cannot edit the client ID. Please create a new client, as editing the client ID will render any accounts using this client non-functional. + Done + 1. Click \'Launch in browser.\' + 2. Follow and complete the login process. + 3. Once completed, you will be directed to a broken webpage. + 4. Copy the entire URL and paste it below. + 5. Click \'Login\'. + Login to your Account + Launch in browser + Login + Please follow the login steps and enter the authorization URL. + Client ID: %1$s \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 6f39f37..77fd6fd 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -10,6 +10,16 @@ + + + +