/* * * 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 . */ 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, private val widgetRepository: WidgetRepository ) : RemoteViewsService.RemoteViewsFactory { private lateinit var widgetConfiguration: WidgetConfiguration private var widgetItems: List = emptyList() private var hasLoadMore = false override fun onCreate() { Log_OC.d(TAG, "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 { if (widgetConfiguration.user.isPresent) { val client = clientFactory.createNextcloudClient(widgetConfiguration.user.get()) val result = DashboardGetWidgetItemsRemoteOperation(widgetConfiguration.widgetId, LIMIT_SIZE) .execute(client) widgetItems = if (result.isSuccess) { result.resultData[widgetConfiguration.widgetId] ?: emptyList() } else { emptyList() } hasLoadMore = widgetConfiguration.moreButton != null && widgetItems.size == LIMIT_SIZE } else { Log_OC.w(TAG, "User not present for widget update") } } catch (e: ClientFactory.CreationException) { Log_OC.e(TAG, "Error updating widget", e) } } Log_OC.d(TAG, "onDataSetChanged") } override fun onDestroy() { Log_OC.d(TAG, "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") private fun createItemView(position: Int): RemoteViews { return RemoteViews(context.packageName, R.layout.widget_item).apply { if (widgetItems.isEmpty()) { return@apply } val widgetItem = widgetItems[position] if (widgetItem.iconUrl.isNotEmpty()) { loadIcon(widgetItem, this) } updateTexts(widgetItem, this) if (widgetItem.link.isNotEmpty()) { val clickIntent = Intent(Intent.ACTION_VIEW, Uri.parse(widgetItem.link)) setOnClickFillInIntent(R.id.text_container, clickIntent) } } } @Suppress("TooGenericExceptionCaught") private fun loadIcon(widgetItem: DashboardWidgetItem, remoteViews: RemoteViews) { val isIconSVG = Uri.parse(widgetItem.iconUrl).encodedPath!!.endsWith(".svg") val source: FutureTarget = if (isIconSVG) { loadSVGIcon(widgetItem) } else { loadBitmapIcon(widgetItem) } try { val bitmap: Bitmap = if (widgetConfiguration.roundIcon) { BitmapUtils.roundBitmap(source.get()) } else { source.get() } remoteViews.setImageViewBitmap(R.id.icon, bitmap) } catch (e: Exception) { Log_OC.d(TAG, "Error setting icon", e) remoteViews.setImageViewResource(R.id.icon, R.drawable.ic_dashboard) } } private fun loadSVGIcon(widgetItem: DashboardWidgetItem): FutureTarget { return 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) } private fun loadBitmapIcon(widgetItem: DashboardWidgetItem): FutureTarget { return Glide.with(context) .using(CustomGlideStreamLoader(widgetConfiguration.user.get(), clientFactory)) .load(widgetItem.iconUrl) .asBitmap() .into(SVG_SIZE, SVG_SIZE) } private fun updateTexts(widgetItem: DashboardWidgetItem, remoteViews: RemoteViews) { remoteViews.setTextViewText(R.id.title, widgetItem.title) if (widgetItem.subtitle.isNotEmpty()) { remoteViews.setViewVisibility(R.id.subtitle, View.VISIBLE) remoteViews.setTextViewText(R.id.subtitle, widgetItem.subtitle) } else { remoteViews.setViewVisibility(R.id.subtitle, View.GONE) } } 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 { private val TAG = DashboardWidgetService::class.simpleName const val LIMIT_SIZE = 14 } }