diff options
author | ByteHamster <info@bytehamster.com> | 2022-11-01 13:07:18 +0100 |
---|---|---|
committer | ByteHamster <info@bytehamster.com> | 2022-11-03 22:46:56 +0100 |
commit | c1fbb538058369a4ed5bfcc6ff3622aceb6ce820 (patch) | |
tree | 09060bda32f866a3ed24cd811f6f339002ce1637 /ui/glide | |
parent | 6921d7162eaacc90c4522a3d9bf6d2d47d045ae7 (diff) | |
download | AntennaPod-c1fbb538058369a4ed5bfcc6ff3622aceb6ce820.zip |
Move Glide to its own module
Diffstat (limited to 'ui/glide')
15 files changed, 1138 insertions, 0 deletions
diff --git a/ui/glide/README.md b/ui/glide/README.md new file mode 100644 index 000000000..c246f2bea --- /dev/null +++ b/ui/glide/README.md @@ -0,0 +1,3 @@ +# :ui:glide + +Configuration and ModelLoaders for the Glide image loading library. diff --git a/ui/glide/build.gradle b/ui/glide/build.gradle new file mode 100644 index 000000000..ff61fc8e1 --- /dev/null +++ b/ui/glide/build.gradle @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") +} +apply from: "../../common.gradle" +apply from: "../../playFlavor.gradle" + +android { + lintOptions { + disable "InvalidPeriodicWorkRequestInterval", "ObsoleteLintCustomCheck", "DefaultLocale", "UnusedAttribute", + "ParcelClassLoader", "CheckResult", "TrustAllX509TrustManager", + "StaticFieldLeak", "IconDensities", "IconDuplicates", "MissingPermission", "AppCompatResource", + "GradleCompatible", "QueryPermissionsNeeded" + } +} + +dependencies { + implementation project(":model") + implementation project(":core") + + implementation "androidx.palette:palette:$paletteVersion" + + implementation "com.github.bumptech.glide:glide:$glideVersion" + implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion@aar" + annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" + implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" + implementation "commons-io:commons-io:$commonsioVersion" +} diff --git a/ui/glide/src/main/AndroidManifest.xml b/ui/glide/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ddc027b29 --- /dev/null +++ b/ui/glide/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest package="de.danoeh.antennapod.ui.glide" /> diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ApGlideModule.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ApGlideModule.java new file mode 100644 index 000000000..d233fba14 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ApGlideModule.java @@ -0,0 +1,56 @@ +package de.danoeh.antennapod.ui.glide; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Bitmap; +import android.util.Log; + +import androidx.annotation.NonNull; +import com.bumptech.glide.Glide; +import com.bumptech.glide.GlideBuilder; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.load.DecodeFormat; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory; +import com.bumptech.glide.module.AppGlideModule; + +import de.danoeh.antennapod.model.feed.EmbeddedChapterImage; +import java.io.InputStream; + +import com.bumptech.glide.request.RequestOptions; +import java.nio.ByteBuffer; + +/** + * {@see com.bumptech.glide.integration.okhttp.OkHttpGlideModule} + */ +@GlideModule +public class ApGlideModule extends AppGlideModule { + private static final String TAG = "ApGlideModule"; + private static final long MEGABYTES = 1024 * 1024; + private static final long GIGABYTES = 1024 * 1024 * 1024; + + @Override + public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) { + builder.setDefaultRequestOptions(new RequestOptions() + .format(DecodeFormat.PREFER_ARGB_8888) + .diskCacheStrategy(DiskCacheStrategy.ALL)); + builder.setLogLevel(Log.WARN); + @SuppressLint("UsableSpace") + long spaceAvailable = context.getCacheDir().getUsableSpace(); + long imageCacheSize = (spaceAvailable > 2 * GIGABYTES) ? (250 * MEGABYTES) : (50 * MEGABYTES); + Log.d(TAG, "Free space on cache dir: " + spaceAvailable + ", using image cache size: " + imageCacheSize); + builder.setDiskCache(new InternalCacheDiskCacheFactory(context, imageCacheSize)); + } + + @Override + public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + registry.replace(String.class, InputStream.class, new MetadataRetrieverLoader.Factory(context)); + registry.append(String.class, InputStream.class, new GenerativePlaceholderImageModelLoader.Factory()); + registry.append(String.class, InputStream.class, new ApOkHttpUrlLoader.Factory()); + registry.append(String.class, InputStream.class, new NoHttpStringLoader.StreamFactory()); + + registry.append(EmbeddedChapterImage.class, ByteBuffer.class, new ChapterImageModelLoader.Factory()); + registry.register(Bitmap.class, PaletteBitmap.class, new PaletteBitmapTranscoder()); + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ApOkHttpUrlLoader.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ApOkHttpUrlLoader.java new file mode 100644 index 000000000..5c3605594 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ApOkHttpUrlLoader.java @@ -0,0 +1,113 @@ +package de.danoeh.antennapod.ui.glide; + +import android.content.ContentResolver; +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; +import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; +import de.danoeh.antennapod.core.util.NetworkUtils; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedMedia; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.IOException; +import java.io.InputStream; + +/** + * {@see com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader}. + */ +class ApOkHttpUrlLoader implements ModelLoader<String, InputStream> { + + /** + * The default factory for {@link ApOkHttpUrlLoader}s. + */ + public static class Factory implements ModelLoaderFactory<String, InputStream> { + + private static volatile OkHttpClient internalClient; + private final OkHttpClient client; + + private static OkHttpClient getInternalClient() { + if (internalClient == null) { + synchronized (Factory.class) { + if (internalClient == null) { + OkHttpClient.Builder builder = AntennapodHttpClient.newBuilder(); + builder.interceptors().add(new NetworkAllowanceInterceptor()); + builder.cache(null); // Handled by Glide + internalClient = builder.build(); + } + } + } + return internalClient; + } + + /** + * Constructor for a new Factory that runs requests using a static singleton client. + */ + Factory() { + this.client = getInternalClient(); + } + + @NonNull + @Override + public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) { + return new ApOkHttpUrlLoader(client); + } + + @Override + public void teardown() { + // Do nothing, this instance doesn't own the client. + } + } + + private final OkHttpClient client; + + private ApOkHttpUrlLoader(OkHttpClient client) { + this.client = client; + } + + @Nullable + @Override + public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) { + return new LoadData<>(new ObjectKey(model), new ResizingOkHttpStreamFetcher(client, new GlideUrl(model))); + } + + @Override + public boolean handles(@NonNull String model) { + return !TextUtils.isEmpty(model) + // If the other loaders fail, do not attempt to load as web resource + && !model.startsWith(Feed.PREFIX_GENERATIVE_COVER) + && !model.startsWith(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER) + // Leave content URIs to Glide's default loaders + && !model.startsWith(ContentResolver.SCHEME_CONTENT) + && !model.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE); + } + + private static class NetworkAllowanceInterceptor implements Interceptor { + + @NonNull + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + if (NetworkUtils.isImageAllowed()) { + return chain.proceed(chain.request()); + } else { + return new Response.Builder() + .protocol(Protocol.HTTP_2) + .code(420) + .message("Policy Not Fulfilled") + .body(ResponseBody.create(null, new byte[0])) + .request(chain.request()) + .build(); + } + } + } +}
\ No newline at end of file diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/AudioCoverFetcher.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/AudioCoverFetcher.java new file mode 100644 index 000000000..d8e265ab5 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/AudioCoverFetcher.java @@ -0,0 +1,62 @@ +package de.danoeh.antennapod.ui.glide; + +import android.content.ContentResolver; +import android.content.Context; +import android.media.MediaMetadataRetriever; + +import android.net.Uri; +import androidx.annotation.NonNull; +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.DataFetcher; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +// see https://github.com/bumptech/glide/issues/699 +class AudioCoverFetcher implements DataFetcher<InputStream> { + private final String path; + private final Context context; + + public AudioCoverFetcher(String path, Context context) { + this.path = path; + this.context = context; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) { + try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) { + if (path.startsWith(ContentResolver.SCHEME_CONTENT)) { + retriever.setDataSource(context, Uri.parse(path)); + } else { + retriever.setDataSource(path); + } + byte[] picture = retriever.getEmbeddedPicture(); + if (picture != null) { + callback.onDataReady(new ByteArrayInputStream(picture)); + } + } catch (Exception e) { + callback.onLoadFailed(e); + } + } + + @Override public void cleanup() { + // nothing to clean up + } + + @Override public void cancel() { + // cannot cancel + } + + @NonNull + @Override + public Class<InputStream> getDataClass() { + return InputStream.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.LOCAL; + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ChapterImageModelLoader.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ChapterImageModelLoader.java new file mode 100644 index 000000000..9f82e0b31 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ChapterImageModelLoader.java @@ -0,0 +1,111 @@ +package de.danoeh.antennapod.ui.glide; + +import androidx.annotation.NonNull; +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; +import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; +import de.danoeh.antennapod.model.feed.EmbeddedChapterImage; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import okhttp3.Request; +import okhttp3.Response; +import org.apache.commons.io.IOUtils; + +public final class ChapterImageModelLoader implements ModelLoader<EmbeddedChapterImage, ByteBuffer> { + + public static class Factory implements ModelLoaderFactory<EmbeddedChapterImage, ByteBuffer> { + @NonNull + @Override + public ModelLoader<EmbeddedChapterImage, ByteBuffer> build(@NonNull MultiModelLoaderFactory unused) { + return new ChapterImageModelLoader(); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + @Override + public LoadData<ByteBuffer> buildLoadData(@NonNull EmbeddedChapterImage model, + int width, + int height, + @NonNull Options options) { + return new LoadData<>(new ObjectKey(model), new EmbeddedImageFetcher(model)); + } + + @Override + public boolean handles(@NonNull EmbeddedChapterImage model) { + return true; + } + + static class EmbeddedImageFetcher implements DataFetcher<ByteBuffer> { + private final EmbeddedChapterImage image; + + public EmbeddedImageFetcher(EmbeddedChapterImage image) { + this.image = image; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super ByteBuffer> callback) { + + BufferedInputStream stream = null; + try { + if (image.getMedia().localFileAvailable()) { + File localFile = new File(image.getMedia().getLocalMediaUrl()); + stream = new BufferedInputStream(new FileInputStream(localFile)); + IOUtils.skip(stream, image.getPosition()); + byte[] imageContent = new byte[image.getLength()]; + IOUtils.read(stream, imageContent, 0, image.getLength()); + callback.onDataReady(ByteBuffer.wrap(imageContent)); + } else { + Request.Builder httpReq = new Request.Builder(); + // Skipping would download the whole file + httpReq.header("Range", "bytes=" + image.getPosition() + + "-" + (image.getPosition() + image.getLength())); + httpReq.url(image.getMedia().getStreamUrl()); + Response response = AntennapodHttpClient.getHttpClient().newCall(httpReq.build()).execute(); + if (!response.isSuccessful() || response.body() == null) { + throw new IOException("Invalid response: " + response.code() + " " + response.message()); + } + callback.onDataReady(ByteBuffer.wrap(response.body().bytes())); + } + } catch (IOException e) { + callback.onLoadFailed(e); + } finally { + IOUtils.closeQuietly(stream); + } + } + + @Override + public void cleanup() { + // nothing to clean up + } + + @Override + public void cancel() { + // cannot cancel + } + + @NonNull + @Override + public Class<ByteBuffer> getDataClass() { + return ByteBuffer.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.LOCAL; + } + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/FastBlurTransformation.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/FastBlurTransformation.java new file mode 100644 index 000000000..a19611100 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/FastBlurTransformation.java @@ -0,0 +1,294 @@ +package de.danoeh.antennapod.ui.glide; + +import android.graphics.Bitmap; +import android.media.ThumbnailUtils; +import androidx.annotation.NonNull; +import android.util.Log; + +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; + +import java.nio.charset.Charset; +import java.security.MessageDigest; + +public class FastBlurTransformation extends BitmapTransformation { + private static final String ID = "de.danoeh.antennapod.core.glide.FastBlurTransformation"; + + private static final String TAG = FastBlurTransformation.class.getSimpleName(); + + private static final int STACK_BLUR_RADIUS = 10; + + public FastBlurTransformation() { + super(); + } + + @Override + protected Bitmap transform(@NonNull BitmapPool pool, + @NonNull Bitmap source, + int outWidth, + int outHeight) { + int targetWidth = outWidth / 3; + int targetHeight = (int) (1.0 * outHeight * targetWidth / outWidth); + Bitmap resized = ThumbnailUtils.extractThumbnail(source, targetWidth, targetHeight); + Bitmap result = fastBlur(resized, STACK_BLUR_RADIUS); + if (result == null) { + Log.w(TAG, "result was null"); + return source; + } + return result; + } + + @Override + public boolean equals(Object o) { + return o instanceof FastBlurTransformation; + } + + @Override + public int hashCode() { + return ID.hashCode(); + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(TAG.getBytes(Charset.defaultCharset())); + } + + private static Bitmap fastBlur(Bitmap bitmap, int radius) { + + // Stack Blur v1.0 from + // http://www.quasimondo.com/StackBlurForCanvas/StackBlurDemo.html + // + // Java Author: Mario Klingemann <mario at quasimondo.com> + // http://incubator.quasimondo.com + // created Feburary 29, 2004 + // Android port : Yahel Bouaziz <yahel at kayenko.com> + // http://www.kayenko.com + // ported april 5th, 2012 + + // This is a compromise between Gaussian Blur and Box blur + // It creates much better looking blurs than Box Blur, but is + // 7x faster than my Gaussian Blur implementation. + // + // I called it Stack Blur because this describes best how this + // filter works internally: it creates a kind of moving stack + // of colors whilst scanning through the image. Thereby it + // just has to add one new block of color to the right side + // of the stack and remove the leftmost color. The remaining + // colors on the topmost layer of the stack are either added on + // or reduced by one, depending on if they are on the right or + // on the left side of the stack. + // + // If you are using this algorithm in your code please add + // the following line: + // + // Stack Blur Algorithm by Mario Klingemann <mario@quasimondo.com> + + if (radius < 1) { + return null; + } + + int w = bitmap.getWidth(); + int h = bitmap.getHeight(); + + int[] pix = new int[w * h]; + bitmap.getPixels(pix, 0, w, 0, 0, w, h); + + int wm = w - 1; + int hm = h - 1; + int wh = w * h; + int div = radius + radius + 1; + + int[] r = new int[wh]; + int[] g = new int[wh]; + int[] b = new int[wh]; + int rsum; + int gsum; + int bsum; + int x; + int y; + int i; + int p; + int yp; + int yi; + int yw; + int[] vmin = new int[Math.max(w, h)]; + + int divsum = (div + 1) >> 1; + divsum *= divsum; + int[] dv = new int[256 * divsum]; + for (i = 0; i < 256 * divsum; i++) { + dv[i] = (i / divsum); + } + + yw = yi = 0; + + int[][] stack = new int[div][3]; + int stackpointer; + int stackstart; + int[] sir; + int rbs; + int r1 = radius + 1; + int routsum; + int goutsum; + int boutsum; + int rinsum; + int ginsum; + int binsum; + + for (y = 0; y < h; y++) { + rinsum = ginsum = binsum = routsum = goutsum = boutsum = rsum = gsum = bsum = 0; + for (i = -radius; i <= radius; i++) { + p = pix[yi + Math.min(wm, Math.max(i, 0))]; + sir = stack[i + radius]; + sir[0] = (p & 0xff0000) >> 16; + sir[1] = (p & 0x00ff00) >> 8; + sir[2] = (p & 0x0000ff); + rbs = r1 - Math.abs(i); + rsum += sir[0] * rbs; + gsum += sir[1] * rbs; + bsum += sir[2] * rbs; + if (i > 0) { + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + } else { + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + } + } + stackpointer = radius; + + for (x = 0; x < w; x++) { + + r[yi] = dv[rsum]; + g[yi] = dv[gsum]; + b[yi] = dv[bsum]; + + rsum -= routsum; + gsum -= goutsum; + bsum -= boutsum; + + stackstart = stackpointer - radius + div; + sir = stack[stackstart % div]; + + routsum -= sir[0]; + goutsum -= sir[1]; + boutsum -= sir[2]; + + if (y == 0) { + vmin[x] = Math.min(x + radius + 1, wm); + } + p = pix[yw + vmin[x]]; + + sir[0] = (p & 0xff0000) >> 16; + sir[1] = (p & 0x00ff00) >> 8; + sir[2] = (p & 0x0000ff); + + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + + rsum += rinsum; + gsum += ginsum; + bsum += binsum; + + stackpointer = (stackpointer + 1) % div; + sir = stack[(stackpointer) % div]; + + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + + rinsum -= sir[0]; + ginsum -= sir[1]; + binsum -= sir[2]; + + yi++; + } + yw += w; + } + for (x = 0; x < w; x++) { + rinsum = ginsum = binsum = routsum = goutsum = boutsum = rsum = gsum = bsum = 0; + yp = -radius * w; + for (i = -radius; i <= radius; i++) { + yi = Math.max(0, yp) + x; + + sir = stack[i + radius]; + + sir[0] = r[yi]; + sir[1] = g[yi]; + sir[2] = b[yi]; + + rbs = r1 - Math.abs(i); + + rsum += r[yi] * rbs; + gsum += g[yi] * rbs; + bsum += b[yi] * rbs; + + if (i > 0) { + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + } else { + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + } + + if (i < hm) { + yp += w; + } + } + yi = x; + stackpointer = radius; + for (y = 0; y < h; y++) { + // Set alpha to 1 + pix[yi] = 0xff000000 | (dv[rsum] << 16) | (dv[gsum] << 8) | dv[bsum]; + + rsum -= routsum; + gsum -= goutsum; + bsum -= boutsum; + + stackstart = stackpointer - radius + div; + sir = stack[stackstart % div]; + + routsum -= sir[0]; + goutsum -= sir[1]; + boutsum -= sir[2]; + + if (x == 0) { + vmin[y] = Math.min(y + r1, hm) * w; + } + p = x + vmin[y]; + + sir[0] = r[p]; + sir[1] = g[p]; + sir[2] = b[p]; + + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + + rsum += rinsum; + gsum += ginsum; + bsum += binsum; + + stackpointer = (stackpointer + 1) % div; + sir = stack[stackpointer]; + + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + + rinsum -= sir[0]; + ginsum -= sir[1]; + binsum -= sir[2]; + + yi += w; + } + } + bitmap.setPixels(pix, 0, w, 0, 0, w, h); + return bitmap; + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/GenerativePlaceholderImageModelLoader.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/GenerativePlaceholderImageModelLoader.java new file mode 100644 index 000000000..3ad8fba6a --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/GenerativePlaceholderImageModelLoader.java @@ -0,0 +1,139 @@ +package de.danoeh.antennapod.ui.glide; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Shader; +import androidx.annotation.NonNull; +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; +import de.danoeh.antennapod.model.feed.Feed; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.Random; + +public final class GenerativePlaceholderImageModelLoader implements ModelLoader<String, InputStream> { + + public static class Factory implements ModelLoaderFactory<String, InputStream> { + @NonNull + @Override + public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory unused) { + return new GenerativePlaceholderImageModelLoader(); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + @Override + public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) { + return new LoadData<>(new ObjectKey(model), new EmbeddedImageFetcher(model, width, height)); + } + + @Override + public boolean handles(@NonNull String model) { + return model.startsWith(Feed.PREFIX_GENERATIVE_COVER); + } + + static class EmbeddedImageFetcher implements DataFetcher<InputStream> { + private static final int[] PALETTES = {0xff78909c, 0xffff6f00, 0xff388e3c, + 0xff00838f, 0xff7b1fa2, 0xffb71c1c, 0xff2196f3}; + private final String model; + private final int width; + private final int height; + + public EmbeddedImageFetcher(String model, int width, int height) { + this.model = model; + this.width = width; + this.height = height; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) { + final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + final Random generator = new Random(model.hashCode()); + final int lineGridSteps = 4 + generator.nextInt(4); + final int slope = width / 4; + final float shadowWidth = width * 0.01f; + final float lineDistance = ((float) width / (lineGridSteps - 2)); + final int baseColor = PALETTES[generator.nextInt(PALETTES.length)]; + + Paint paint = new Paint(); + int color = randomShadeOfGrey(generator); + paint.setColor(color); + paint.setStrokeWidth(lineDistance); + paint.setColorFilter(new PorterDuffColorFilter(baseColor, PorterDuff.Mode.MULTIPLY)); + Paint paintShadow = new Paint(); + paintShadow.setColor(0xff000000); + paintShadow.setStrokeWidth(lineDistance); + + int forcedColorChange = 1 + generator.nextInt(lineGridSteps - 2); + for (int i = lineGridSteps - 1; i >= 0; i--) { + float linePos = (i - 0.5f) * lineDistance; + boolean switchColor = generator.nextFloat() < 0.3f || i == forcedColorChange; + if (switchColor) { + int newColor = color; + while (newColor == color) { + newColor = randomShadeOfGrey(generator); + } + color = newColor; + paint.setColor(newColor); + canvas.drawLine(linePos + slope + shadowWidth, -slope, + linePos - slope + shadowWidth, height + slope, paintShadow); + } + canvas.drawLine(linePos + slope, -slope, + linePos - slope, height + slope, paint); + } + + Paint gradientPaint = new Paint(); + paint.setDither(true); + gradientPaint.setShader(new LinearGradient(0, 0, 0, height, 0x00000000, 0x55000000, Shader.TileMode.CLAMP)); + canvas.drawRect(0, 0, width, height, gradientPaint); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos); + InputStream is = new ByteArrayInputStream(baos.toByteArray()); + callback.onDataReady(is); + } + + private static int randomShadeOfGrey(Random generator) { + return 0xff777777 + 0x222222 * generator.nextInt(5); + } + + @Override + public void cleanup() { + // nothing to clean up + } + + @Override + public void cancel() { + // cannot cancel + } + + @NonNull + @Override + public Class<InputStream> getDataClass() { + return InputStream.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.LOCAL; + } + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/MetadataRetrieverLoader.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/MetadataRetrieverLoader.java new file mode 100644 index 000000000..a5046b7d0 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/MetadataRetrieverLoader.java @@ -0,0 +1,57 @@ +package de.danoeh.antennapod.ui.glide; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; +import de.danoeh.antennapod.model.feed.FeedMedia; + +import java.io.InputStream; + +class MetadataRetrieverLoader implements ModelLoader<String, InputStream> { + + /** + * The default factory for {@link MetadataRetrieverLoader}s. + */ + public static class Factory implements ModelLoaderFactory<String, InputStream> { + private final Context context; + + Factory(Context context) { + this.context = context; + } + + @NonNull + @Override + public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) { + return new MetadataRetrieverLoader(context); + } + + @Override + public void teardown() { + // Do nothing, this instance doesn't own the client. + } + } + + private final Context context; + + private MetadataRetrieverLoader(Context context) { + this.context = context; + } + + @Nullable + @Override + public LoadData<InputStream> buildLoadData(@NonNull String model, + int width, int height, @NonNull Options options) { + return new LoadData<>(new ObjectKey(model), + new AudioCoverFetcher(model.replace(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER, ""), context)); + } + + @Override + public boolean handles(@NonNull String model) { + return model.startsWith(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER); + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/NoHttpStringLoader.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/NoHttpStringLoader.java new file mode 100644 index 000000000..63af834b6 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/NoHttpStringLoader.java @@ -0,0 +1,45 @@ +package de.danoeh.antennapod.ui.glide; + +import android.net.Uri; +import androidx.annotation.NonNull; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.load.model.StringLoader; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedMedia; + +import java.io.InputStream; + +/** + * StringLoader that does not handle http/https urls. Used to avoid fallback to StringLoader when + * AntennaPod blocks mobile image loading. + */ +public final class NoHttpStringLoader extends StringLoader<InputStream> { + + public static class StreamFactory implements ModelLoaderFactory<String, InputStream> { + @NonNull + @Override + public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) { + return new NoHttpStringLoader(multiFactory.build(Uri.class, InputStream.class)); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + public NoHttpStringLoader(ModelLoader<Uri, InputStream> uriLoader) { + super(uriLoader); + } + + @Override + public boolean handles(@NonNull String model) { + return !model.startsWith("http") + // If the custom loaders fail, do not attempt to load with Glide internal loaders + && !model.startsWith(Feed.PREFIX_GENERATIVE_COVER) + && !model.startsWith(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER) + && super.handles(model); + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmap.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmap.java new file mode 100644 index 000000000..a3b590ba2 --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmap.java @@ -0,0 +1,20 @@ +/* + * Source: https://github.com/bumptech/glide/wiki/Custom-targets#palette-example + */ + +package de.danoeh.antennapod.ui.glide; + +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; +import androidx.palette.graphics.Palette; + +public class PaletteBitmap { + public final Palette palette; + public final Bitmap bitmap; + + public PaletteBitmap(@NonNull Bitmap bitmap, Palette palette) { + this.bitmap = bitmap; + this.palette = palette; + } +}
\ No newline at end of file diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmapResource.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmapResource.java new file mode 100644 index 000000000..2fd18a0cb --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmapResource.java @@ -0,0 +1,40 @@ +/* + * Source: https://github.com/bumptech/glide/wiki/Custom-targets#palette-example + */ + +package de.danoeh.antennapod.ui.glide; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.util.Util; + +public class PaletteBitmapResource implements Resource<PaletteBitmap> { + private final PaletteBitmap paletteBitmap; + + public PaletteBitmapResource(@NonNull PaletteBitmap paletteBitmap) { + this.paletteBitmap = paletteBitmap; + } + + @NonNull + @Override + public Class<PaletteBitmap> getResourceClass() { + return PaletteBitmap.class; + } + + @NonNull + @Override + public PaletteBitmap get() { + return paletteBitmap; + } + + @Override + public int getSize() { + return Util.getBitmapByteSize(paletteBitmap.bitmap); + } + + @Override + public void recycle() { + paletteBitmap.bitmap.recycle(); + } +}
\ No newline at end of file diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmapTranscoder.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmapTranscoder.java new file mode 100644 index 000000000..75599a30a --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/PaletteBitmapTranscoder.java @@ -0,0 +1,32 @@ +/* + * Source: https://github.com/bumptech/glide/wiki/Custom-targets#palette-example + */ + +package de.danoeh.antennapod.ui.glide; + +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.palette.graphics.Palette; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; + +import de.danoeh.antennapod.core.preferences.UserPreferences; + +public class PaletteBitmapTranscoder implements ResourceTranscoder<Bitmap, PaletteBitmap> { + + @Nullable + @Override + public Resource<PaletteBitmap> transcode(@NonNull Resource<Bitmap> toTranscode, @NonNull Options options) { + Bitmap bitmap = toTranscode.get(); + Palette palette = null; + if (UserPreferences.shouldShowSubscriptionTitle()) { + palette = new Palette.Builder(bitmap).generate(); + } + PaletteBitmap result = new PaletteBitmap(bitmap, palette); + return new PaletteBitmapResource(result); + } +} diff --git a/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ResizingOkHttpStreamFetcher.java b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ResizingOkHttpStreamFetcher.java new file mode 100644 index 000000000..2d4e4d45f --- /dev/null +++ b/ui/glide/src/main/java/de/danoeh/antennapod/ui/glide/ResizingOkHttpStreamFetcher.java @@ -0,0 +1,138 @@ +package de.danoeh.antennapod.ui.glide; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.bumptech.glide.Priority; +import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.GlideUrl; +import okhttp3.Call; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class ResizingOkHttpStreamFetcher extends OkHttpStreamFetcher { + private static final String TAG = "ResizingOkHttpStreamFet"; + private static final int MAX_DIMENSIONS = 1500; + private static final int MAX_FILE_SIZE = 1024 * 1024; // 1 MB + + private FileInputStream stream; + private File tempIn; + private File tempOut; + + public ResizingOkHttpStreamFetcher(Call.Factory client, GlideUrl url) { + super(client, url); + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataFetcher.DataCallback<? super InputStream> callback) { + super.loadData(priority, new DataFetcher.DataCallback<InputStream>() { + @Override + public void onDataReady(@Nullable InputStream data) { + if (data == null) { + callback.onDataReady(null); + return; + } + try { + tempIn = File.createTempFile("resize_", null); + tempOut = File.createTempFile("resize_", null); + OutputStream outputStream = new FileOutputStream(tempIn); + IOUtils.copy(data, outputStream); + outputStream.close(); + IOUtils.closeQuietly(data); + + if (tempIn.length() <= MAX_FILE_SIZE) { + try { + stream = new FileInputStream(tempIn); + callback.onDataReady(stream); // Just deliver the original, non-scaled image + } catch (FileNotFoundException fileNotFoundException) { + callback.onLoadFailed(fileNotFoundException); + } + return; + } + + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + FileInputStream in = new FileInputStream(tempIn); + BitmapFactory.decodeStream(in, null, options); + IOUtils.closeQuietly(in); + + if (options.outWidth == -1 || options.outHeight == -1) { + throw new IOException("Not a valid image"); + } else if (Math.max(options.outHeight, options.outWidth) >= MAX_DIMENSIONS) { + double sampleSize = (double) Math.max(options.outHeight, options.outWidth) / MAX_DIMENSIONS; + options.inSampleSize = (int) Math.pow(2d, Math.floor(Math.log(sampleSize) / Math.log(2d))); + } + + options.inJustDecodeBounds = false; + in = new FileInputStream(tempIn); + Bitmap bitmap = BitmapFactory.decodeStream(in, null, options); + IOUtils.closeQuietly(in); + + Bitmap.CompressFormat format = Build.VERSION.SDK_INT < 30 + ? Bitmap.CompressFormat.WEBP : Bitmap.CompressFormat.WEBP_LOSSY; + + int quality = 100; + while (true) { + FileOutputStream out = new FileOutputStream(tempOut); + bitmap.compress(format, quality, out); + IOUtils.closeQuietly(out); + + if (tempOut.length() > 3 * MAX_FILE_SIZE && quality >= 45) { + quality -= 40; + } else if (tempOut.length() > 2 * MAX_FILE_SIZE && quality >= 25) { + quality -= 20; + } else if (tempOut.length() > MAX_FILE_SIZE && quality >= 15) { + quality -= 10; + } else if (tempOut.length() > MAX_FILE_SIZE && quality >= 10) { + quality -= 5; + } else { + break; + } + } + bitmap.recycle(); + + stream = new FileInputStream(tempOut); + callback.onDataReady(stream); + Log.d(TAG, "Compressed image from " + tempIn.length() / 1024 + + " to " + tempOut.length() / 1024 + " kB (quality: " + quality + "%)"); + } catch (Throwable e) { + e.printStackTrace(); + + try { + stream = new FileInputStream(tempIn); + callback.onDataReady(stream); // Just deliver the original, non-scaled image + } catch (FileNotFoundException fileNotFoundException) { + e.printStackTrace(); + callback.onLoadFailed(fileNotFoundException); + } + } + } + + @Override + public void onLoadFailed(@NonNull Exception e) { + callback.onLoadFailed(e); + } + }); + } + + @Override + public void cleanup() { + IOUtils.closeQuietly(stream); + FileUtils.deleteQuietly(tempIn); + FileUtils.deleteQuietly(tempOut); + super.cleanup(); + } +} |