1
0
Fork 0
mirror of https://github.com/nextcloud/android.git synced 2024-12-04 19:16:36 +01:00

Add dashboard widgets

Signed-off-by: tobiasKaminsky <tobias@kaminsky.me>
Signed-off-by: Álvaro Brey <alvaro.brey@nextcloud.com>
Signed-off-by: tobiasKaminsky <tobias@kaminsky.me>
This commit is contained in:
tobiasKaminsky 2022-08-16 16:57:58 +02:00
parent 423944137c
commit efa886b455
No known key found for this signature in database
GPG key ID: 0E00D4D47D0C5AF7
48 changed files with 2033 additions and 164 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -0,0 +1,139 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2022 Tobias Kaminsky
* Copyright (C) 2022 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.ui
import android.graphics.BitmapFactory
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.test.espresso.intent.rule.IntentsTestRule
import com.nextcloud.client.TestActivity
import com.owncloud.android.AbstractIT
import com.owncloud.android.R
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.ScreenshotTest
import org.junit.Rule
import org.junit.Test
class BitmapIT : AbstractIT() {
@get:Rule
val testActivityRule = IntentsTestRule(TestActivity::class.java, true, false)
@Test
@ScreenshotTest
fun roundBitmap() {
val file = getFile("christine.jpg")
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val activity = testActivityRule.launchActivity(null)
val imageView = ImageView(activity).apply {
setImageBitmap(bitmap)
}
val bitmap2 = BitmapFactory.decodeFile(file.absolutePath)
val imageView2 = ImageView(activity).apply {
setImageBitmap(BitmapUtils.roundBitmap(bitmap2))
}
val linearLayout = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(context.getColor(R.color.grey_200))
}
linearLayout.addView(imageView, 200, 200)
linearLayout.addView(imageView2, 200, 200)
activity.addView(linearLayout)
screenshot(activity)
}
// @Test
// @ScreenshotTest
// fun glideSVG() {
// val activity = testActivityRule.launchActivity(null)
// val accountProvider = UserAccountManagerImpl.fromContext(activity)
// val clientFactory = ClientFactoryImpl(activity)
//
// val linearLayout = LinearLayout(activity).apply {
// orientation = LinearLayout.VERTICAL
// setBackgroundColor(context.getColor(R.color.grey_200))
// }
//
// val file = getFile("christine.jpg")
// val bitmap = BitmapFactory.decodeFile(file.absolutePath)
//
// ImageView(activity).apply {
// setImageBitmap(bitmap)
// linearLayout.addView(this, 50, 50)
// }
//
// downloadIcon(
// client.baseUri.toString() + "/apps/files/img/app.svg",
// activity,
// linearLayout,
// accountProvider,
// clientFactory
// )
//
// downloadIcon(
// client.baseUri.toString() + "/core/img/actions/group.svg",
// activity,
// linearLayout,
// accountProvider,
// clientFactory
// )
//
// activity.addView(linearLayout)
//
// longSleep()
//
// screenshot(activity)
// }
//
// private fun downloadIcon(
// url: String,
// activity: TestActivity,
// linearLayout: LinearLayout,
// accountProvider: UserAccountManager,
// clientFactory: ClientFactory
// ) {
// val view = ImageView(activity).apply {
// linearLayout.addView(this, 50, 50)
// }
// val target = object : SimpleTarget<Drawable>() {
// override fun onResourceReady(resource: Drawable?, glideAnimation: GlideAnimation<in Drawable>?) {
// view.setColorFilter(targetContext.getColor(R.color.dark), PorterDuff.Mode.SRC_ATOP)
// view.setImageDrawable(resource)
// }
// }
//
// testActivityRule.runOnUiThread {
// DisplayUtils.downloadIcon(
// accountProvider,
// clientFactory,
// activity,
// url,
// target,
// R.drawable.ic_user
// )
// }
// }
}

View file

@ -40,9 +40,7 @@
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
@ -151,6 +149,13 @@
<activity
android:name=".ui.activity.SyncedFoldersActivity"
android:exported="false" />
<activity
android:name="com.nextcloud.client.widget.DashboardWidgetConfigurationActivity"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver
android:name="com.nextcloud.client.jobs.MediaFoldersDetectionWork$NotificationReceiver"
@ -158,6 +163,17 @@
<receiver
android:name="com.nextcloud.client.jobs.NotificationWork$NotificationReceiver"
android:exported="false" />
<receiver
android:name="com.nextcloud.client.widget.DashboardWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/dashboard_widget_info" />
</receiver>
<activity
android:name=".ui.activity.UploadFilesActivity"
@ -220,7 +236,6 @@
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".syncadapter.FileSyncService"
android:exported="true"
@ -233,6 +248,10 @@
android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter_files" />
</service>
<service
android:name="com.nextcloud.client.widget.DashboardWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS"
android:exported="true" />
<provider
android:name=".providers.FileContentProvider"
@ -304,16 +323,12 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/exposed_filepaths" />
</provider>
<provider
android:name=".providers.DiskLruImageCacheFileProvider"
android:authorities="@string/image_cache_provider_authority"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:exported="true">
</provider>
<!-- Disable WorkManager initialization. Whoever designed this, should pay closer attention -->
android:permission="android.permission.MANAGE_DOCUMENTS"></provider> <!-- Disable WorkManager initialization. Whoever designed this, should pay closer attention -->
<!-- to "best before" dates in his fridge. -->
<!-- disable default provider -->
<provider
@ -327,8 +342,6 @@
tools:node="remove" />
</provider>
<activity
android:name=".authentication.AuthenticatorActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
@ -341,7 +354,6 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".authentication.DeepLinkLoginActivity"
android:clearTaskOnLaunch="true"
@ -391,11 +403,9 @@
<activity
android:name=".ui.activity.ErrorsWhileCopyingHandlerActivity"
android:exported="false" />
<activity
android:name="com.nextcloud.client.logger.ui.LogsActivity"
android:exported="false" />
<activity
android:name="com.nextcloud.client.errorhandling.ShowErrorActivity"
android:excludeFromRecents="true"
@ -465,7 +475,6 @@
android:label="@string/manage_space_title"
android:theme="@style/Theme.ownCloud" />
<service
android:name=".services.AccountManagerService"
android:enabled="true"
@ -476,12 +485,10 @@
android:name=".ui.activity.SsoGrantPermissionActivity"
android:exported="true"
android:theme="@style/Theme.ownCloud.Dialog.NoTitle" />
<activity
android:name="com.nextcloud.client.etm.EtmActivity"
android:exported="false"
android:theme="@style/Theme.ownCloud.Toolbar" />
<activity
android:name=".ui.preview.PreviewBitmapActivity"
android:exported="false"

View file

@ -29,6 +29,9 @@ import com.nextcloud.client.media.PlayerService;
import com.nextcloud.client.migrations.Migrations;
import com.nextcloud.client.onboarding.FirstRunActivity;
import com.nextcloud.client.onboarding.WhatsNewActivity;
import com.nextcloud.client.widget.DashboardWidgetConfigurationActivity;
import com.nextcloud.client.widget.DashboardWidgetProvider;
import com.nextcloud.client.widget.DashboardWidgetService;
import com.nextcloud.ui.ChooseAccountDialogFragment;
import com.nextcloud.ui.SetStatusDialogFragment;
import com.owncloud.android.MainApp;
@ -102,8 +105,8 @@ import com.owncloud.android.ui.fragment.FileDetailFragment;
import com.owncloud.android.ui.fragment.FileDetailSharingFragment;
import com.owncloud.android.ui.fragment.GalleryFragment;
import com.owncloud.android.ui.fragment.LocalFileListFragment;
import com.owncloud.android.ui.fragment.OCFileListBottomSheetDialogFragment;
import com.owncloud.android.ui.fragment.OCFileListBottomSheetDialog;
import com.owncloud.android.ui.fragment.OCFileListBottomSheetDialogFragment;
import com.owncloud.android.ui.fragment.OCFileListFragment;
import com.owncloud.android.ui.fragment.SharedListFragment;
import com.owncloud.android.ui.fragment.UnifiedSearchFragment;
@ -341,6 +344,9 @@ abstract class ComponentsModule {
@ContributesAndroidInjector
abstract FileSyncService fileSyncService();
@ContributesAndroidInjector
abstract DashboardWidgetService dashboardWidgetService();
@ContributesAndroidInjector
abstract PreviewPdfFragment previewPDFFragment();
@ -430,4 +436,10 @@ abstract class ComponentsModule {
@ContributesAndroidInjector
abstract SyncFileNotEnoughSpaceDialogFragment syncFileNotEnoughSpaceDialogFragment();
@ContributesAndroidInjector
abstract DashboardWidgetConfigurationActivity dashboardWidgetConfigurationActivity();
@ContributesAndroidInjector
abstract DashboardWidgetProvider dashboardWidgetProvider();
}

View file

@ -37,11 +37,11 @@ import com.owncloud.android.lib.common.accounts.AccountUtils;
import java.io.IOException;
class ClientFactoryImpl implements ClientFactory {
public class ClientFactoryImpl implements ClientFactory {
private Context context;
ClientFactoryImpl(Context context) {
public ClientFactoryImpl(Context context) {
this.context = context;
}
@ -49,8 +49,8 @@ class ClientFactoryImpl implements ClientFactory {
public OwnCloudClient create(User user) throws CreationException {
try {
return OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(user.toOwnCloudAccount(), context);
} catch (OperationCanceledException|
AuthenticatorException|
} catch (OperationCanceledException |
AuthenticatorException |
IOException e) {
throw new CreationException(e);
}

View file

@ -25,8 +25,8 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import com.nextcloud.client.account.CurrentAccountProvider;
import com.nextcloud.client.account.User;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.account.UserAccountManagerImpl;
import com.owncloud.android.datamodel.ArbitraryDataProvider;
import com.owncloud.android.datamodel.FileDataStorageManager;
@ -45,15 +45,14 @@ import static com.owncloud.android.ui.fragment.OCFileListFragment.FOLDER_LAYOUT_
/**
* Implementation of application-wide preferences using {@link SharedPreferences}.
*
* Users should not use this class directly. Please use {@link AppPreferences} interface
* instead.
* <p>
* Users should not use this class directly. Please use {@link AppPreferences} interface instead.
*/
public final class AppPreferencesImpl implements AppPreferences {
/**
* Constant to access value of last path selected by the user to upload a file shared from other app.
* Value handled by the app without direct access in the UI.
* Constant to access value of last path selected by the user to upload a file shared from other app. Value handled
* by the app without direct access in the UI.
*/
public static final String AUTO_PREF__LAST_SEEN_VERSION_CODE = "lastSeenVersionCode";
public static final String STORAGE_PATH = "storage_path";
@ -101,7 +100,7 @@ public final class AppPreferencesImpl implements AppPreferences {
private final Context context;
private final SharedPreferences preferences;
private final CurrentAccountProvider currentAccountProvider;
private final UserAccountManager userAccountManager;
private final ListenerRegistry listeners;
/**
@ -123,7 +122,7 @@ public final class AppPreferencesImpl implements AppPreferences {
}
}
void remove(@Nullable final Listener listener) {
void remove(@Nullable final Listener listener) {
if (listener != null) {
listeners.remove(listener);
}
@ -133,7 +132,7 @@ public final class AppPreferencesImpl implements AppPreferences {
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (PREF__DARK_THEME.equals(key)) {
DarkMode mode = preferences.getDarkThemeMode();
for(Listener l : listeners) {
for (Listener l : listeners) {
l.onDarkThemeModeChanged(mode);
}
}
@ -141,9 +140,9 @@ public final class AppPreferencesImpl implements AppPreferences {
}
/**
* This is a temporary workaround to access app preferences in places that cannot use
* dependency injection yet. Use injected component via {@link AppPreferences} interface.
*
* This is a temporary workaround to access app preferences in places that cannot use dependency injection yet. Use
* injected component via {@link AppPreferences} interface.
* <p>
* WARNING: this creates new instance! it does not return app-wide singleton
*
* @param context Context used to create shared preferences
@ -151,15 +150,15 @@ public final class AppPreferencesImpl implements AppPreferences {
*/
@Deprecated
public static AppPreferences fromContext(Context context) {
final CurrentAccountProvider currentAccountProvider = UserAccountManagerImpl.fromContext(context);
final UserAccountManager userAccountManager = UserAccountManagerImpl.fromContext(context);
final SharedPreferences prefs = android.preference.PreferenceManager.getDefaultSharedPreferences(context);
return new AppPreferencesImpl(context, prefs, currentAccountProvider);
return new AppPreferencesImpl(context, prefs, userAccountManager);
}
AppPreferencesImpl(Context appContext, SharedPreferences preferences, CurrentAccountProvider currentAccountProvider) {
AppPreferencesImpl(Context appContext, SharedPreferences preferences, UserAccountManager userAccountManager) {
this.context = appContext;
this.preferences = preferences;
this.currentAccountProvider = currentAccountProvider;
this.userAccountManager = userAccountManager;
this.listeners = new ListenerRegistry(this);
this.preferences.registerOnSharedPreferenceChangeListener(listeners);
}
@ -277,7 +276,7 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public String[] getPassCode() {
return new String[] {
return new String[]{
preferences.getString(PassCodeActivity.PREFERENCE_PASSCODE_D1, null),
preferences.getString(PassCodeActivity.PREFERENCE_PASSCODE_D2, null),
preferences.getString(PassCodeActivity.PREFERENCE_PASSCODE_D3, null),
@ -293,7 +292,7 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public String getFolderLayout(OCFile folder) {
return getFolderPreference(context,
currentAccountProvider.getUser(),
userAccountManager.getUser(),
PREF__FOLDER_LAYOUT,
folder,
FOLDER_LAYOUT_LIST);
@ -302,7 +301,7 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public void setFolderLayout(@Nullable OCFile folder, String layoutName) {
setFolderPreference(context,
currentAccountProvider.getUser(),
userAccountManager.getUser(),
PREF__FOLDER_LAYOUT,
folder,
layoutName);
@ -311,7 +310,7 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public FileSortOrder getSortOrderByFolder(OCFile folder) {
return FileSortOrder.sortOrders.get(getFolderPreference(context,
currentAccountProvider.getUser(),
userAccountManager.getUser(),
PREF__FOLDER_SORT_ORDER,
folder,
FileSortOrder.sort_a_to_z.name));
@ -320,7 +319,7 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public void setSortOrder(@Nullable OCFile folder, FileSortOrder sortOrder) {
setFolderPreference(context,
currentAccountProvider.getUser(),
userAccountManager.getUser(),
PREF__FOLDER_SORT_ORDER,
folder,
sortOrder.name);
@ -333,7 +332,7 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public FileSortOrder getSortOrderByType(FileSortOrder.Type type, FileSortOrder defaultOrder) {
User user = currentAccountProvider.getUser();
User user = userAccountManager.getUser();
if (user.isAnonymous()) {
return defaultOrder;
}
@ -347,7 +346,7 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public void setSortOrder(FileSortOrder.Type type, FileSortOrder sortOrder) {
User user = currentAccountProvider.getUser();
User user = userAccountManager.getUser();
ArbitraryDataProvider dataProvider = new ArbitraryDataProvider(context.getContentResolver());
dataProvider.storeOrUpdateKeyValue(user.getAccountName(), PREF__FOLDER_SORT_ORDER + "_" + type, sortOrder.name);
}
@ -506,19 +505,19 @@ public final class AppPreferencesImpl implements AppPreferences {
@Override
public void removeLegacyPreferences() {
preferences.edit()
.remove("instant_uploading")
.remove("instant_video_uploading")
.remove("instant_upload_path")
.remove("instant_upload_path_use_subfolders")
.remove("instant_upload_on_wifi")
.remove("instant_upload_on_charging")
.remove("instant_video_upload_path")
.remove("instant_video_upload_path_use_subfolders")
.remove("instant_video_upload_on_wifi")
.remove("instant_video_uploading")
.remove("instant_video_upload_on_charging")
.remove("prefs_instant_behaviour")
.apply();
.remove("instant_uploading")
.remove("instant_video_uploading")
.remove("instant_upload_path")
.remove("instant_upload_path_use_subfolders")
.remove("instant_upload_on_wifi")
.remove("instant_upload_on_charging")
.remove("instant_video_upload_path")
.remove("instant_video_upload_path_use_subfolders")
.remove("instant_video_upload_on_wifi")
.remove("instant_video_uploading")
.remove("instant_video_upload_on_charging")
.remove("prefs_instant_behaviour")
.apply();
}
@Override
@ -588,13 +587,12 @@ public final class AppPreferencesImpl implements AppPreferences {
}
/**
* Get preference value for a folder.
* If folder is not set itself, it finds an ancestor that is set.
* Get preference value for a folder. If folder is not set itself, it finds an ancestor that is set.
*
* @param context Context object.
* @param context Context object.
* @param preferenceName Name of the preference to lookup.
* @param folder Folder.
* @param defaultValue Fallback value in case no ancestor is set.
* @param folder Folder.
* @param defaultValue Fallback value in case no ancestor is set.
* @return Preference value
*/
private static String getFolderPreference(final Context context,
@ -621,10 +619,10 @@ public final class AppPreferencesImpl implements AppPreferences {
/**
* Set preference value for a folder.
*
* @param context Context object.
* @param context Context object.
* @param preferenceName Name of the preference to set.
* @param folder Folder.
* @param value Preference value to set.
* @param folder Folder.
* @param value Preference value to set.
*/
private static void setFolderPreference(final Context context,
final User user,
@ -637,7 +635,7 @@ public final class AppPreferencesImpl implements AppPreferences {
private static String getKeyFromFolder(String preferenceName, @Nullable OCFile folder) {
final String folderIdString = String.valueOf(folder != null ? folder.getFileId() :
FileDataStorageManager.ROOT_PARENT_ID);
FileDataStorageManager.ROOT_PARENT_ID);
return preferenceName + "_" + folderIdString;
}

View file

@ -3,7 +3,7 @@ package com.nextcloud.client.preferences;
import android.content.Context;
import android.content.SharedPreferences;
import com.nextcloud.client.account.CurrentAccountProvider;
import com.nextcloud.client.account.UserAccountManager;
import javax.inject.Singleton;
@ -23,7 +23,7 @@ public class PreferencesModule {
@Singleton
public AppPreferences appPreferences(Context context,
SharedPreferences sharedPreferences,
CurrentAccountProvider currentAccountProvider) {
return new AppPreferencesImpl(context, sharedPreferences, currentAccountProvider);
UserAccountManager userAccountManager) {
return new AppPreferencesImpl(context, sharedPreferences, userAccountManager);
}
}

View file

@ -0,0 +1,236 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2022 Tobias Kaminsky
* Copyright (C) 2022 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.recyclerview.widget.LinearLayoutManager
import com.nextcloud.android.lib.resources.dashboard.DashBoardButtonType
import com.nextcloud.android.lib.resources.dashboard.DashboardListWidgetsRemoteOperation
import com.nextcloud.android.lib.resources.dashboard.DashboardWidget
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.network.ClientFactory
import com.nextcloud.client.network.ClientFactory.CreationException
import com.owncloud.android.R
import com.owncloud.android.databinding.DashboardWidgetConfigurationLayoutBinding
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.adapter.DashboardWidgetListAdapter
import com.owncloud.android.ui.dialog.AccountChooserInterface
import com.owncloud.android.ui.dialog.MultipleAccountsDialog
import com.owncloud.android.utils.theme.ThemeDrawableUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
class DashboardWidgetConfigurationActivity :
AppCompatActivity(),
DashboardWidgetConfigurationInterface,
Injectable,
AccountChooserInterface {
private lateinit var mAdapter: DashboardWidgetListAdapter
private lateinit var binding: DashboardWidgetConfigurationLayoutBinding
private lateinit var currentUser: User
@Inject
lateinit var themeDrawableUtils: ThemeDrawableUtils
@Inject
lateinit var accountManager: UserAccountManager
@Inject
lateinit var clientFactory: ClientFactory
@Inject
lateinit var widgetRepository: WidgetRepository
@Inject
lateinit var widgetUpdater: DashboardWidgetUpdater
var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
public override fun onCreate(bundle: Bundle?) {
super.onCreate(bundle)
// Set the result to CANCELED. This will cause the widget host to cancel
// out of the widget placement if the user presses the back button.
setResult(RESULT_CANCELED)
binding = DashboardWidgetConfigurationLayoutBinding.inflate(layoutInflater)
setContentView(binding.root)
themeDrawableUtils.tintDrawable(binding.icon.drawable, getColor(R.color.dark))
val layoutManager = LinearLayoutManager(this)
// TODO follow our new architecture
mAdapter = DashboardWidgetListAdapter(themeDrawableUtils, accountManager, clientFactory, this, this)
binding.list.apply {
setHasFooter(false)
setAdapter(mAdapter)
setLayoutManager(layoutManager)
setEmptyView(binding.emptyView.emptyListView)
}
currentUser = accountManager.user
if (accountManager.allUsers.size > 1) {
binding.chooseWidget.visibility = View.GONE
binding.accountName.apply {
setCompoundDrawablesWithIntrinsicBounds(
null,
null,
themeDrawableUtils.tintDrawable(
AppCompatResources.getDrawable(
context,
R.drawable.ic_baseline_arrow_drop_down_24
),
R.color.black
),
null
)
visibility = View.VISIBLE
text = currentUser.accountName
setOnClickListener {
val dialog = MultipleAccountsDialog()
dialog.highlightCurrentlyActiveAccount = false
dialog.show(supportFragmentManager, null)
}
}
}
loadWidgets(currentUser)
binding.close.setOnClickListener { finish() }
// Find the widget id from the intent.
appWidgetId = intent?.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
// If this activity was started with an intent without an app widget ID, finish with an error.
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
}
private fun loadWidgets(user: User) {
CoroutineScope(Dispatchers.IO).launch {
withContext(Dispatchers.Main) {
binding.emptyView.root.visibility = View.GONE
if (accountManager.allUsers.size > 1) {
binding.accountName.text = user.accountName
}
}
try {
val client = clientFactory.createNextcloudClient(user)
val result = DashboardListWidgetsRemoteOperation().execute(client)
withContext(Dispatchers.Main) {
if (result.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND) {
withContext(Dispatchers.Main) {
mAdapter.setWidgetList(null)
binding.emptyView.root.visibility = View.VISIBLE
binding.emptyView.emptyListViewHeadline.setText(R.string.widgets_not_available_title)
binding.emptyView.emptyListIcon.apply {
setImageResource(R.drawable.ic_list_empty_error)
visibility = View.VISIBLE
}
binding.emptyView.emptyListViewText.apply {
setText(
String.format(
getString(R.string.widgets_not_available),
getString(R.string.app_name)
)
)
visibility = View.VISIBLE
}
}
} else {
mAdapter.setWidgetList(result.resultData)
}
}
} catch (e: CreationException) {
Log_OC.e(this, "Error loading widgets for user $user", e)
withContext(Dispatchers.Main) {
mAdapter.setWidgetList(null)
binding.emptyView.root.visibility = View.VISIBLE
binding.emptyView.emptyListIcon.apply {
setImageResource(R.drawable.ic_list_empty_error)
visibility = View.VISIBLE
}
binding.emptyView.emptyListViewText.apply {
setText(R.string.common_error)
visibility = View.VISIBLE
}
binding.emptyView.emptyListViewAction.apply {
visibility = View.VISIBLE
setText(R.string.reload)
setOnClickListener {
loadWidgets(user)
}
}
}
}
}
}
override fun onItemClicked(dashboardWidget: DashboardWidget) {
widgetRepository.saveWidget(appWidgetId, dashboardWidget, currentUser)
// update widget
val appWidgetManager = AppWidgetManager.getInstance(this)
widgetUpdater.updateAppWidget(
appWidgetManager,
appWidgetId,
dashboardWidget.title,
dashboardWidget.iconUrl,
dashboardWidget.buttons?.find { it.type == DashBoardButtonType.NEW }
)
val resultValue = Intent().apply {
putExtra(EXTRA_APPWIDGET_ID, appWidgetId)
}
setResult(RESULT_OK, resultValue)
finish()
}
override fun onAccountChosen(user: User) {
currentUser = user
loadWidgets(user)
}
}

View file

@ -0,0 +1,29 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2022 Tobias Kaminsky
* Copyright (C) 2022 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.widget
import com.nextcloud.android.lib.resources.dashboard.DashboardWidget
interface DashboardWidgetConfigurationInterface {
fun onItemClicked(dashboardWidget: DashboardWidget)
}

View file

@ -0,0 +1,80 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2022 Tobias Kaminsky
* Copyright (C) 2022 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import dagger.android.AndroidInjection
import javax.inject.Inject
/**
* Manages widgets
*/
class DashboardWidgetProvider : AppWidgetProvider() {
@Inject
lateinit var widgetRepository: WidgetRepository
@Inject
lateinit var widgetUpdater: DashboardWidgetUpdater
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
AndroidInjection.inject(this, context)
for (appWidgetId in appWidgetIds) {
val widgetConfiguration = widgetRepository.getWidget(appWidgetId)
widgetUpdater.updateAppWidget(
appWidgetManager,
appWidgetId,
widgetConfiguration.title,
widgetConfiguration.iconUrl,
widgetConfiguration.addButton
)
}
}
override fun onReceive(context: Context?, intent: Intent?) {
super.onReceive(context, intent)
AndroidInjection.inject(this, context)
if (intent?.action == OPEN_INTENT) {
val clickIntent = Intent(Intent.ACTION_VIEW, intent.data)
clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context?.startActivity(clickIntent)
}
}
override fun onDeleted(context: Context?, appWidgetIds: IntArray) {
AndroidInjection.inject(this, context)
for (appWidgetId in appWidgetIds) {
widgetRepository.deleteWidget(appWidgetId)
}
}
companion object {
const val OPEN_INTENT = "open"
}
}

View file

@ -0,0 +1,243 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2022 Tobias Kaminsky
* Copyright (C) 2022 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.view.View
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.model.StreamEncoder
import com.bumptech.glide.load.resource.file.FileToStreamDecoder
import com.bumptech.glide.request.FutureTarget
import com.nextcloud.android.lib.resources.dashboard.DashboardGetWidgetItemsRemoteOperation
import com.nextcloud.android.lib.resources.dashboard.DashboardWidgetItem
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.network.ClientFactory
import com.owncloud.android.R
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.DisplayUtils.SVG_SIZE
import com.owncloud.android.utils.glide.CustomGlideStreamLoader
import com.owncloud.android.utils.glide.CustomGlideUriLoader
import com.owncloud.android.utils.svg.SVGorImage
import com.owncloud.android.utils.svg.SvgOrImageBitmapTranscoder
import com.owncloud.android.utils.svg.SvgOrImageDecoder
import dagger.android.AndroidInjection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.InputStream
import javax.inject.Inject
class DashboardWidgetService : RemoteViewsService() {
@Inject
lateinit var userAccountManager: UserAccountManager
@Inject
lateinit var clientFactory: ClientFactory
@Inject
lateinit var widgetRepository: WidgetRepository
override fun onCreate() {
super.onCreate()
AndroidInjection.inject(this)
}
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return StackRemoteViewsFactory(
this.applicationContext,
userAccountManager,
clientFactory,
intent,
widgetRepository
)
}
}
class StackRemoteViewsFactory(
private val context: Context,
val userAccountManager: UserAccountManager,
val clientFactory: ClientFactory,
val intent: Intent,
val widgetRepository: WidgetRepository
) : RemoteViewsService.RemoteViewsFactory {
private lateinit var widgetConfiguration: WidgetConfiguration
private var widgetItems: List<DashboardWidgetItem> = emptyList()
private var hasLoadMore = false
override fun onCreate() {
Log_OC.d(this, "onCreate")
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)
widgetConfiguration = widgetRepository.getWidget(appWidgetId)
if (!widgetConfiguration.user.isPresent) {
// TODO show error
Log_OC.e(this, "No user found!")
}
onDataSetChanged()
}
override fun onDataSetChanged() {
CoroutineScope(Dispatchers.IO).launch {
try {
val client = clientFactory.createNextcloudClient(widgetConfiguration.user.get())
val result =
DashboardGetWidgetItemsRemoteOperation(widgetConfiguration.widgetId, LIMIT_SIZE).execute(client)
widgetItems = result.resultData[widgetConfiguration.widgetId] ?: emptyList()
hasLoadMore = widgetConfiguration.moreButton != null &&
widgetItems.size == LIMIT_SIZE
} catch (e: ClientFactory.CreationException) {
Log_OC.e(this, "Error updating widget", e)
}
}
Log_OC.d("WidgetService", "onDataSetChanged")
}
override fun onDestroy() {
Log_OC.d("WidgetService", "onDestroy")
widgetItems = emptyList()
}
override fun getCount(): Int {
return if (hasLoadMore && widgetItems.isNotEmpty()) {
widgetItems.size + 1
} else {
widgetItems.size
}
}
override fun getViewAt(position: Int): RemoteViews {
return if (position == widgetItems.size) {
createLoadMoreView()
} else {
createItemView(position)
}
}
private fun createLoadMoreView(): RemoteViews {
return RemoteViews(context.packageName, R.layout.widget_item_load_more).apply {
val clickIntent = Intent(Intent.ACTION_VIEW, Uri.parse(widgetConfiguration.moreButton?.link))
setTextViewText(R.id.load_more, widgetConfiguration.moreButton?.text)
setOnClickFillInIntent(R.id.load_more_container, clickIntent)
}
}
// we will switch soon to coil and then streamline all of this
// Kotlin cannot catch multiple exception types at same time
@Suppress("NestedBlockDepth", "TooGenericExceptionCaught")
private fun createItemView(position: Int): RemoteViews {
return RemoteViews(context.packageName, R.layout.widget_item).apply {
val widgetItem = widgetItems[position]
// icon bitmap/svg
if (widgetItem.iconUrl.isNotEmpty()) {
val glide: FutureTarget<Bitmap>
if (Uri.parse(widgetItem.iconUrl).encodedPath!!.endsWith(".svg")) {
glide = Glide.with(context)
.using(
CustomGlideUriLoader(userAccountManager.user, clientFactory),
InputStream::class.java
)
.from(Uri::class.java)
.`as`(SVGorImage::class.java)
.transcode(SvgOrImageBitmapTranscoder(SVG_SIZE, SVG_SIZE), Bitmap::class.java)
.sourceEncoder(StreamEncoder())
.cacheDecoder(FileToStreamDecoder(SvgOrImageDecoder()))
.decoder(SvgOrImageDecoder())
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
.load(Uri.parse(widgetItem.iconUrl))
.into(SVG_SIZE, SVG_SIZE)
} else {
glide = Glide.with(context)
.using(CustomGlideStreamLoader(widgetConfiguration.user.get(), clientFactory))
.load(widgetItem.iconUrl)
.asBitmap()
.into(SVG_SIZE, SVG_SIZE)
}
try {
if (widgetConfiguration.roundIcon) {
setImageViewBitmap(R.id.icon, BitmapUtils.roundBitmap(glide.get()))
} else {
setImageViewBitmap(R.id.icon, glide.get())
}
} catch (e: Exception) {
Log_OC.d(this, "Error setting icon", e)
setImageViewResource(R.id.icon, R.drawable.ic_dashboard)
}
}
// text
setTextViewText(R.id.title, widgetItem.title)
if (widgetItem.subtitle.isNotEmpty()) {
setViewVisibility(R.id.subtitle, View.VISIBLE)
setTextViewText(R.id.subtitle, widgetItem.subtitle)
} else {
setViewVisibility(R.id.subtitle, View.GONE)
}
if (widgetItem.link.isNotEmpty()) {
val clickIntent = Intent(Intent.ACTION_VIEW, Uri.parse(widgetItem.link))
setOnClickFillInIntent(R.id.text_container, clickIntent)
}
}
}
override fun getLoadingView(): RemoteViews? {
return null
}
override fun getViewTypeCount(): Int {
return if (hasLoadMore) {
2
} else {
1
}
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun hasStableIds(): Boolean {
return true
}
companion object {
const val LIMIT_SIZE = 14
}
}

View file

@ -0,0 +1,166 @@
/*
*
* Nextcloud Android client application
*
* @author Tobias Kaminsky
* Copyright (C) 2022 Tobias Kaminsky
* Copyright (C) 2022 Nextcloud GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.widget
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.view.View
import android.widget.RemoteViews
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.model.StreamEncoder
import com.bumptech.glide.load.resource.file.FileToStreamDecoder
import com.bumptech.glide.request.animation.GlideAnimation
import com.bumptech.glide.request.target.AppWidgetTarget
import com.nextcloud.android.lib.resources.dashboard.DashboardButton
import com.nextcloud.client.account.CurrentAccountProvider
import com.nextcloud.client.network.ClientFactory
import com.owncloud.android.R
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.DisplayUtils.SVG_SIZE
import com.owncloud.android.utils.glide.CustomGlideUriLoader
import com.owncloud.android.utils.svg.SVGorImage
import com.owncloud.android.utils.svg.SvgOrImageBitmapTranscoder
import com.owncloud.android.utils.svg.SvgOrImageDecoder
import java.io.InputStream
import javax.inject.Inject
class DashboardWidgetUpdater @Inject constructor(
private val context: Context,
private val clientFactory: ClientFactory,
private val accountProvider: CurrentAccountProvider
) {
fun updateAppWidget(
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
title: String,
iconUrl: String,
addButton: DashboardButton?
) {
val intent = Intent(context, DashboardWidgetService::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
val views = RemoteViews(context.packageName, R.layout.dashboard_widget).apply {
setRemoteAdapter(R.id.list, intent)
setEmptyView(R.id.list, R.id.empty_view)
setTextViewText(R.id.title, title)
setAddButton(addButton, appWidgetId, this)
setPendingReload(this, appWidgetId)
setPendingClick(this)
loadIcon(appWidgetId, iconUrl, this)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.list)
}
private fun setPendingReload(remoteViews: RemoteViews, appWidgetId: Int) {
val intentUpdate = Intent(context, DashboardWidgetProvider::class.java)
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val idArray = intArrayOf(appWidgetId)
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, idArray)
remoteViews.setOnClickPendingIntent(
R.id.reload,
PendingIntent.getBroadcast(
context,
appWidgetId,
intentUpdate,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
}
// clickPI needs to me mutable, as it is re-used. PendingIntent.FLAG_IMMUTABLE requires S (API 31)
@SuppressLint("UnspecifiedImmutableFlag")
private fun setPendingClick(remoteViews: RemoteViews) {
val clickPI = PendingIntent.getActivity(
context,
0,
Intent(),
PendingIntent.FLAG_UPDATE_CURRENT
)
remoteViews.setPendingIntentTemplate(R.id.list, clickPI)
}
private fun setAddButton(addButton: DashboardButton?, appWidgetId: Int, remoteViews: RemoteViews) {
// create add button
if (addButton == null) {
remoteViews.setViewVisibility(R.id.create, View.GONE)
} else {
remoteViews.setViewVisibility(R.id.create, View.VISIBLE)
remoteViews.setContentDescription(R.id.create, addButton.text)
val clickIntent = Intent(context, DashboardWidgetProvider::class.java)
clickIntent.action = DashboardWidgetProvider.OPEN_INTENT
clickIntent.data = Uri.parse(addButton.link)
remoteViews.setOnClickPendingIntent(
R.id.create,
PendingIntent.getBroadcast(
context,
appWidgetId,
clickIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
}
}
private fun loadIcon(appWidgetId: Int, iconUrl: String, remoteViews: RemoteViews) {
val iconTarget = object : AppWidgetTarget(context, remoteViews, R.id.icon, appWidgetId) {
override fun onResourceReady(resource: Bitmap?, glideAnimation: GlideAnimation<in Bitmap>?) {
if (resource != null) {
val tintedBitmap = BitmapUtils.tintImage(resource, R.color.black)
super.onResourceReady(tintedBitmap, glideAnimation)
}
}
}
Glide.with(context)
.using(
CustomGlideUriLoader(accountProvider.user, clientFactory),
InputStream::class.java
)
.from(Uri::class.java)
.`as`(SVGorImage::class.java)
.transcode(SvgOrImageBitmapTranscoder(SVG_SIZE, SVG_SIZE), Bitmap::class.java)
.sourceEncoder(StreamEncoder())
.cacheDecoder(FileToStreamDecoder(SvgOrImageDecoder()))
.decoder(SvgOrImageDecoder())
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
.load(Uri.parse(iconUrl))
.into(iconTarget)
}
}