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 new file mode 100644 index 0000000..38a7643 --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/data/local/AccountsDao.kt @@ -0,0 +1,58 @@ +package zechs.drive.stream.data.local + + +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 { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addClient(client: Client) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + 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 new file mode 100644 index 0000000..d88fdcf --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/data/local/AccountsDatabase.kt @@ -0,0 +1,18 @@ +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, Client::class], + version = 1, + exportSchema = false +) +abstract class AccountsDatabase : RoomDatabase() { + + abstract fun getAccountsDao(): AccountsDao + +} \ No newline at end of file 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/data/repository/DriveRepository.kt b/app/src/main/java/zechs/drive/stream/data/repository/DriveRepository.kt index 7eb781b..1a45dbf 100644 --- a/app/src/main/java/zechs/drive/stream/data/repository/DriveRepository.kt +++ b/app/src/main/java/zechs/drive/stream/data/repository/DriveRepository.kt @@ -103,7 +103,11 @@ class DriveRepository @Inject constructor( ) ) Log.d(TAG, "Received access token (${token.accessToken})") - sessionManager.saveAccessToken(token) + val currentTimeInSeconds = System.currentTimeMillis() / 1000 + val newToken = token.copy( + expiresIn = currentTimeInSeconds + token.expiresIn + ) + sessionManager.saveAccessToken(newToken) Resource.Success(token) } catch (e: Exception) { doOnError(e) @@ -140,7 +144,11 @@ class DriveRepository @Inject constructor( // saving in data store sessionManager.saveClient(client) sessionManager.saveRefreshToken(token.refreshToken) - sessionManager.saveAccessToken(token.toTokenResponse()) + val currentTimeInSeconds = System.currentTimeMillis() / 1000 + val newToken = token.toTokenResponse().copy( + expiresIn = currentTimeInSeconds + token.expiresIn + ) + sessionManager.saveAccessToken(newToken) Resource.Success(token) } catch (e: Exception) { 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/di/AppModule.kt b/app/src/main/java/zechs/drive/stream/di/AppModule.kt index c0dd0e8..0abd7ac 100755 --- a/app/src/main/java/zechs/drive/stream/di/AppModule.kt +++ b/app/src/main/java/zechs/drive/stream/di/AppModule.kt @@ -7,8 +7,10 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import zechs.drive.stream.utils.SessionManager +import zechs.drive.stream.data.local.AccountsDao import zechs.drive.stream.utils.AppSettings +import zechs.drive.stream.utils.FirstRunProfileMigrator +import zechs.drive.stream.utils.SessionManager import javax.inject.Singleton @@ -26,8 +28,9 @@ object AppModule { @Provides fun provideSessionDataStore( @ApplicationContext appContext: Context, - gson: Gson - ): SessionManager = SessionManager(appContext, gson) + gson: Gson, + accountsManager: AccountsDao + ): SessionManager = SessionManager(appContext, gson, accountsManager) @Singleton @Provides @@ -35,4 +38,15 @@ object AppModule { @ApplicationContext appContext: Context ): AppSettings = AppSettings(appContext) + @Provides + @Singleton + fun provideFirstRunProfileMigrator( + @ApplicationContext appContext: Context, + gson: Gson, + sessionManager: SessionManager, + accountsManager: AccountsDao, + ): FirstRunProfileMigrator { + return FirstRunProfileMigrator(appContext, gson, sessionManager, accountsManager) + } + } \ No newline at end of file diff --git a/app/src/main/java/zechs/drive/stream/di/DatabaseModule.kt b/app/src/main/java/zechs/drive/stream/di/DatabaseModule.kt index 83473ab..eef7c5d 100755 --- a/app/src/main/java/zechs/drive/stream/di/DatabaseModule.kt +++ b/app/src/main/java/zechs/drive/stream/di/DatabaseModule.kt @@ -7,6 +7,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import zechs.drive.stream.data.local.AccountsDatabase import zechs.drive.stream.data.local.WatchListDao import zechs.drive.stream.data.local.WatchListDatabase import zechs.drive.stream.data.repository.WatchListRepository @@ -17,6 +18,7 @@ import javax.inject.Singleton object DatabaseModule { private const val WATCHLIST_DATABASE_NAME = "watch_list.db" + private const val ACCOUNTS_DATABASE_NAME = "accounts.db" @Singleton @Provides @@ -42,4 +44,20 @@ object DatabaseModule { watchListDao: WatchListDao ) = WatchListRepository(watchListDao) + @Singleton + @Provides + fun provideAccountsDatabase( + @ApplicationContext appContext: Context + ) = Room.databaseBuilder( + appContext, + AccountsDatabase::class.java, + ACCOUNTS_DATABASE_NAME + ).build() + + @Singleton + @Provides + fun provideAccountsDao( + db: AccountsDatabase + ) = db.getAccountsDao() + } \ No newline at end of file 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 db35bd0..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 @@ -5,16 +5,19 @@ 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.delay import kotlinx.coroutines.flow.MutableSharedFlow 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 import zechs.drive.stream.utils.AppTheme +import zechs.drive.stream.utils.FirstRunProfileMigrator import zechs.drive.stream.utils.SessionManager import zechs.drive.stream.utils.VideoPlayer import zechs.drive.stream.utils.state.Resource @@ -24,14 +27,16 @@ import javax.inject.Inject class MainViewModel @Inject constructor( private val sessionManager: SessionManager, private val githubRepository: GithubRepository, - private val appSettings: AppSettings + 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> @@ -55,29 +60,33 @@ class MainViewModel @Inject constructor( var adsEnabled = false private set + var hasTransitionedToDefault = false + init { - viewModelScope.launch { + 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 new file mode 100644 index 0000000..5f2310f --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountItemDiffCallback.kt @@ -0,0 +1,17 @@ +package zechs.drive.stream.ui.profile.adapter + +import androidx.recyclerview.widget.DiffUtil +import zechs.drive.stream.data.model.AccountWithClient + +class AccountItemDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: AccountWithClient, + newItem: AccountWithClient + ): Boolean = oldItem.name == newItem.name + + override fun areContentsTheSame( + 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 new file mode 100644 index 0000000..a6c6efe --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsAdapter.kt @@ -0,0 +1,35 @@ +package zechs.drive.stream.ui.profile.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.AccountWithClient +import zechs.drive.stream.databinding.ItemTextBinding + +class AccountsAdapter( + val onClickListener: (AccountWithClient) -> Unit, + val onMenuClickListener: (View, AccountWithClient) -> Unit +) : ListAdapter(AccountItemDiffCallback()) { + + override fun onCreateViewHolder( + parent: ViewGroup, viewType: Int + ) = AccountsViewHolder( + itemBinding = ItemTextBinding.inflate( + LayoutInflater.from(parent.context), + parent, false + ), + accountsAdapter = this + ) + + override fun onBindViewHolder(holder: AccountsViewHolder, position: Int) { + val item = getItem(position) + return holder.bind(item) + } + + override fun getItemViewType( + position: Int + ) = R.layout.item_text + +} \ No newline at end of file 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 new file mode 100644 index 0000000..c523d9d --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/ui/profile/adapter/AccountsViewHolder.kt @@ -0,0 +1,27 @@ +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( + private val itemBinding: ItemTextBinding, + val accountsAdapter: AccountsAdapter +) : RecyclerView.ViewHolder(itemBinding.root) { + + fun bind(account: AccountWithClient) { + itemBinding.apply { + textView.text = account.name + root.setOnClickListener { + accountsAdapter.onClickListener.invoke(account) + } + roundCheck.isGone = !account.isDefault + btnMenu.setOnClickListener { + accountsAdapter.onMenuClickListener.invoke(btnMenu, account) + } + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..de745ea --- /dev/null +++ b/app/src/main/java/zechs/drive/stream/utils/FirstRunProfileMigrator.kt @@ -0,0 +1,70 @@ +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.preferencesDataStore +import com.google.gson.Gson +import kotlinx.coroutines.flow.first +import zechs.drive.stream.data.local.AccountsDao +import zechs.drive.stream.data.model.Account + + +class FirstRunProfileMigrator( + appContext: Context, + private val gson: Gson, + private val sessionManager: SessionManager, + private val accountsManager: AccountsDao, +) { + + private val sessionStore = appContext.dataStore + + + private suspend fun markFirstRun() { + if (!isFirstRun()) return + val dataStoreKey = booleanPreferencesKey(IS_FIRST_RUN) + sessionStore.edit { settings -> + settings[dataStoreKey] = false + } + Log.d(TAG, "First run completed") + } + + private suspend fun isFirstRun(): Boolean { + val dataStoreKey = booleanPreferencesKey(IS_FIRST_RUN) + val preferences = sessionStore.data.first() + val value = preferences[dataStoreKey] ?: true + Log.d(TAG, "isFirstRun: $value") + return value + } + + suspend fun migrateSessionToAccountsTable() { + 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.id, + refreshToken = refreshToken, + accessToken = gson.toJson(accessToken) + ) + accountsManager.addClient(client) + accountsManager.addAccount(account) + sessionManager.saveDefault(profileName) + Log.d(TAG, "migrateSessionToAccountsTable: $account") + } + markFirstRun() + } + + companion object { + private val Context.dataStore by preferencesDataStore( + "FIRST_RUN_PROFILE_MIGRATOR" + ) + const val TAG = "FirstRunProfileMigrator" + const val IS_FIRST_RUN = "IS_FIRST_RUN" + } + +} \ No newline at end of file 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 d4c3f62..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,8 @@ 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 import javax.inject.Inject @@ -18,11 +20,27 @@ import javax.inject.Singleton @Singleton class SessionManager @Inject constructor( @ApplicationContext appContext: Context, - private val gson: Gson + private val gson: Gson, + private val accountsManager: AccountsDao, ) { 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 -> @@ -45,12 +63,10 @@ class SessionManager @Inject constructor( suspend fun saveAccessToken(data: TokenResponse) { val dataStoreKey = stringPreferencesKey(ACCESS_TOKEN) - val currentTimeInSeconds = System.currentTimeMillis() / 1000 - val newData = data.copy( - expiresIn = currentTimeInSeconds + data.expiresIn - ) + val refreshToken = fetchRefreshToken()!! + accountsManager.updateAccessToken(refreshToken, gson.toJson(data)) sessionStore.edit { settings -> - settings[dataStoreKey] = gson.toJson(newData) + settings[dataStoreKey] = gson.toJson(data) } Log.d(TAG, "saveAccessToken: $data") } @@ -83,6 +99,26 @@ class SessionManager @Inject constructor( return value } + suspend fun saveDefault(profile: String?) { + val dataStoreKey = stringPreferencesKey(DEFAULT_PROFILE) + sessionStore.edit { settings -> + if (profile == null) { + settings.remove(dataStoreKey) + } else { + settings[dataStoreKey] = profile + } + } + Log.d(TAG, "saveDefault: $profile") + } + + suspend fun fetchDefault(): String? { + val dataStoreKey = stringPreferencesKey(DEFAULT_PROFILE) + val preferences = sessionStore.data.first() + val value = preferences[dataStoreKey] + Log.d(TAG, "fetchDefault: $value") + return value + } + suspend fun resetDataStore() { sessionStore.edit { it.clear() } } @@ -95,6 +131,8 @@ class SessionManager @Inject constructor( const val DRIVE_CLIENT = "DRIVE_CLIENT" 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_check_circle_24.xml b/app/src/main/res/drawable/ic_check_circle_24.xml new file mode 100644 index 0000000..547454b --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_24.xml @@ -0,0 +1,9 @@ + + + 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_more_24.xml b/app/src/main/res/drawable/ic_more_24.xml new file mode 100644 index 0000000..0fecb86 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_24.xml @@ -0,0 +1,10 @@ + + + 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/layout/item_text.xml b/app/src/main/res/layout/item_text.xml new file mode 100644 index 0000000..ca96921 --- /dev/null +++ b/app/src/main/res/layout/item_text.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + 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 @@ + + + +